feat: add clients nomenclator, order edit/delete/devalidate, invoice types, dashboard redesign

- New clients table with PF/PJ support, fiscal data (CUI, IBAN, eFactura fields)
- Full CRUD API for clients with search, sync integration
- Order lifecycle: edit header (DRAFT), devalidate (VALIDAT→DRAFT), delete order/invoice
- Invoice types: FACTURA (B2B) vs BON_FISCAL (B2C) with different nr formats
- OrderCreateView redesigned as multi-step flow (client→vehicle→details)
- Autocomplete from catalog_norme/catalog_preturi in OrderLineForm
- Dashboard now combines stats + full orders table with filter tabs and search
- ClientPicker and VehiclePicker with inline creation capability
- Frontend schema aligned with backend (missing columns causing sync errors)
- Mobile responsive fixes for OrderDetailView buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 00:36:40 +02:00
parent 3e449d0b0b
commit 9db4e746e3
34 changed files with 2221 additions and 211 deletions

View File

@@ -0,0 +1,211 @@
<template>
<div class="relative">
<label v-if="label" class="block text-sm font-medium text-gray-700 mb-1">{{ label }}</label>
<input
v-model="query"
type="text"
:placeholder="placeholder"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
@input="onSearch"
@focus="showDropdown = true"
/>
<!-- Selected client display -->
<div v-if="selected" class="mt-1 text-sm text-gray-600">
{{ displayName(selected) }}
<span v-if="selected.cod_fiscal" class="text-gray-400"> ({{ selected.cod_fiscal }})</span>
<button @click="clear" class="ml-2 text-red-500 hover:text-red-700 text-xs">Sterge</button>
</div>
<!-- Dropdown results -->
<ul
v-if="showDropdown && results.length > 0"
class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-md shadow-lg max-h-48 overflow-auto"
>
<li
v-for="c in results"
:key="c.id"
class="px-3 py-2 hover:bg-blue-50 cursor-pointer text-sm"
@mousedown="selectClient(c)"
>
<span class="font-medium">{{ displayName(c) }}</span>
<span v-if="c.cod_fiscal" class="text-gray-400 ml-2">({{ c.cod_fiscal }})</span>
<span v-if="c.telefon" class="text-gray-400 ml-2">{{ c.telefon }}</span>
</li>
</ul>
<!-- No results -->
<div
v-if="showDropdown && query.length >= 2 && results.length === 0 && !loading"
class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-md shadow-lg p-3 text-sm text-gray-500"
>
Niciun client gasit
</div>
<!-- Create new client inline -->
<div v-if="!selected" class="mt-2">
<button
type="button"
@click="showNewForm = !showNewForm"
class="text-sm text-blue-600 hover:underline"
>
{{ showNewForm ? 'Ascunde formular' : '+ Client nou' }}
</button>
<div v-if="showNewForm" class="mt-3 space-y-3 bg-gray-50 rounded-lg p-4">
<div class="flex gap-4 mb-2">
<label class="flex items-center gap-1.5 text-xs">
<input v-model="newClient.tip_persoana" type="radio" value="PF" class="text-blue-600" />
PF
</label>
<label class="flex items-center gap-1.5 text-xs">
<input v-model="newClient.tip_persoana" type="radio" value="PJ" class="text-blue-600" />
PJ
</label>
</div>
<template v-if="newClient.tip_persoana === 'PF'">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Nume</label>
<input v-model="newClient.nume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Prenume</label>
<input v-model="newClient.prenume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">CNP</label>
<input v-model="newClient.cod_fiscal" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Telefon</label>
<input v-model="newClient.telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
</template>
<template v-if="newClient.tip_persoana === 'PJ'">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Denumire firma</label>
<input v-model="newClient.denumire" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">CUI</label>
<input v-model="newClient.cod_fiscal" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Telefon</label>
<input v-model="newClient.telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Email</label>
<input v-model="newClient.email" type="email" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
</template>
<div>
<label class="block text-xs text-gray-500 mb-1">Adresa</label>
<input v-model="newClient.adresa" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<button
type="button"
@click="handleCreateClient"
:disabled="creatingClient"
class="px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 disabled:opacity-50"
>
{{ creatingClient ? 'Se salveaza...' : 'Salveaza client' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { useClientsStore } from '../../stores/clients.js'
const props = defineProps({
modelValue: { type: String, default: null },
label: { type: String, default: 'Client' },
placeholder: { type: String, default: 'Cauta dupa denumire, CUI, telefon...' },
})
const emit = defineEmits(['update:modelValue', 'select'])
const clientsStore = useClientsStore()
const query = ref('')
const results = ref([])
const selected = ref(null)
const showDropdown = ref(false)
const loading = ref(false)
const showNewForm = ref(false)
const creatingClient = ref(false)
const newClient = reactive({
tip_persoana: 'PF',
denumire: '', nume: '', prenume: '',
cod_fiscal: '', telefon: '', email: '', adresa: '',
})
let searchTimeout = null
function displayName(c) {
if (c.tip_persoana === 'PJ' && c.denumire) return c.denumire
const parts = [c.nume, c.prenume].filter(Boolean)
return parts.length > 0 ? parts.join(' ') : (c.denumire || '-')
}
function onSearch() {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(async () => {
if (query.value.length < 2) {
results.value = []
return
}
loading.value = true
results.value = await clientsStore.search(query.value)
loading.value = false
}, 200)
}
function selectClient(c) {
selected.value = c
query.value = displayName(c)
showDropdown.value = false
showNewForm.value = false
emit('update:modelValue', c.id)
emit('select', c)
}
function clear() {
selected.value = null
query.value = ''
emit('update:modelValue', null)
emit('select', null)
}
async function handleCreateClient() {
creatingClient.value = true
try {
const row = await clientsStore.create({ ...newClient })
selectClient(row)
showNewForm.value = false
Object.assign(newClient, {
tip_persoana: 'PF', denumire: '', nume: '', prenume: '',
cod_fiscal: '', telefon: '', email: '', adresa: '',
})
} finally {
creatingClient.value = false
}
}
// Load initial client if modelValue is set
watch(() => props.modelValue, async (id) => {
if (id && !selected.value) {
const c = await clientsStore.getById(id)
if (c) {
selected.value = c
query.value = displayName(c)
}
}
}, { immediate: true })
</script>

View File

@@ -14,15 +14,34 @@
</label>
</div>
<!-- Descriere -->
<div>
<!-- Descriere with autocomplete -->
<div class="relative">
<input
v-model="form.descriere"
type="text"
required
placeholder="Descriere operatiune / material"
:placeholder="form.tip === 'manopera' ? 'Cauta norma sau scrie descriere...' : 'Cauta material sau scrie descriere...'"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
@input="onDescriereInput"
@focus="showSuggestions = true"
/>
<!-- Autocomplete dropdown -->
<ul
v-if="showSuggestions && suggestions.length > 0"
class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-md shadow-lg max-h-48 overflow-auto"
>
<li
v-for="s in suggestions"
:key="s.id"
class="px-3 py-2 hover:bg-blue-50 cursor-pointer text-sm"
@mousedown="selectSuggestion(s)"
>
<span class="font-medium">{{ s.denumire }}</span>
<span v-if="s.ansamblu_denumire" class="text-gray-400 ml-2">({{ s.ansamblu_denumire }})</span>
<span v-if="s.cod" class="text-gray-400 ml-1">[{{ s.cod }}]</span>
<span v-if="s.pret !== undefined" class="text-gray-400 ml-2">{{ (s.pret || 0).toFixed(2) }}/{{ s.um || 'buc' }}</span>
</li>
</ul>
</div>
<!-- Manopera fields -->
@@ -99,13 +118,16 @@
</template>
<script setup>
import { reactive, computed } from 'vue'
import { reactive, computed, ref, watch } from 'vue'
import { useOrdersStore } from '../../stores/orders.js'
const emit = defineEmits(['add'])
const ordersStore = useOrdersStore()
const form = reactive({
tip: 'manopera',
descriere: '',
norma_id: null,
ore: 0,
pret_ora: 0,
cantitate: 0,
@@ -113,16 +135,57 @@ const form = reactive({
um: 'buc',
})
const suggestions = ref([])
const showSuggestions = ref(false)
let searchTimeout = null
const computedTotal = computed(() => {
if (form.tip === 'manopera') return (form.ore || 0) * (form.pret_ora || 0)
return (form.cantitate || 0) * (form.pret_unitar || 0)
})
function onDescriereInput() {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(async () => {
if (form.descriere.length < 2) {
suggestions.value = []
return
}
if (form.tip === 'manopera') {
suggestions.value = await ordersStore.searchNorme(form.descriere)
} else {
suggestions.value = await ordersStore.searchPreturi(form.descriere)
}
showSuggestions.value = true
}, 200)
}
function selectSuggestion(s) {
showSuggestions.value = false
if (form.tip === 'manopera') {
form.descriere = s.denumire
form.norma_id = s.id
form.ore = s.ore_normate || 0
} else {
form.descriere = s.denumire
form.pret_unitar = s.pret || 0
form.um = s.um || 'buc'
form.cantitate = 1
}
}
// Clear suggestions when switching tip
watch(() => form.tip, () => {
suggestions.value = []
showSuggestions.value = false
})
function handleSubmit() {
if (!form.descriere) return
emit('add', { ...form })
// Reset
form.descriere = ''
form.norma_id = null
form.ore = 0
form.pret_ora = 0
form.cantitate = 0

View File

@@ -12,7 +12,7 @@
<!-- Selected vehicle display -->
<div v-if="selected" class="mt-1 text-sm text-gray-600">
{{ selected.nr_inmatriculare }} - {{ selected.client_nume }}
<span v-if="selected.marca_denumire"> ({{ selected.marca_denounire }} {{ selected.model_denumire }})</span>
<span v-if="selected.marca_denumire"> ({{ selected.marca_denumire }} {{ selected.model_denumire }})</span>
<button @click="clear" class="ml-2 text-red-500 hover:text-red-700 text-xs">Sterge</button>
</div>
<!-- Dropdown results -->
@@ -40,27 +40,117 @@
>
Niciun vehicul gasit
</div>
<!-- Create new vehicle inline -->
<div v-if="!selected" class="mt-2">
<button
type="button"
@click="showNewVehicle = !showNewVehicle"
class="text-sm text-blue-600 hover:underline"
>
{{ showNewVehicle ? 'Ascunde formular vehicul' : '+ Vehicul nou' }}
</button>
<div v-if="showNewVehicle" class="mt-3 space-y-3 bg-gray-50 rounded-lg p-4">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Nr. inmatriculare</label>
<input v-model="newVehicle.nr_inmatriculare" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="B 123 ABC" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Serie sasiu (VIN)</label>
<input v-model="newVehicle.serie_sasiu" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Marca</label>
<div class="flex gap-1">
<select v-model="newVehicle.marca_id" class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm" @change="onNewMarcaChange">
<option :value="null">-- Selecteaza --</option>
<option v-for="m in marci" :key="m.id" :value="m.id">{{ m.denumire }}</option>
</select>
<div v-if="showNewMarca" class="flex gap-1">
<input v-model="newMarcaName" type="text" placeholder="Marca noua" class="w-28 px-2 py-2 border border-gray-300 rounded-md text-sm" />
<button type="button" @click="createMarca" class="px-2 py-1 bg-green-600 text-white text-xs rounded-md hover:bg-green-700">OK</button>
</div>
<button v-else type="button" @click="showNewMarca = true" class="px-2 py-1 text-blue-600 text-xs hover:underline">+</button>
</div>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Model</label>
<div class="flex gap-1">
<select v-model="newVehicle.model_id" class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm">
<option :value="null">-- Selecteaza --</option>
<option v-for="m in modele" :key="m.id" :value="m.id">{{ m.denumire }}</option>
</select>
<div v-if="showNewModel && newVehicle.marca_id" class="flex gap-1">
<input v-model="newModelName" type="text" placeholder="Model nou" class="w-28 px-2 py-2 border border-gray-300 rounded-md text-sm" />
<button type="button" @click="createModel" class="px-2 py-1 bg-green-600 text-white text-xs rounded-md hover:bg-green-700">OK</button>
</div>
<button v-else-if="newVehicle.marca_id" type="button" @click="showNewModel = true" class="px-2 py-1 text-blue-600 text-xs hover:underline">+</button>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">An fabricatie</label>
<input v-model.number="newVehicle.an_fabricatie" type="number" min="1900" max="2030" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Nume client</label>
<input v-model="newVehicle.client_nume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<button
type="button"
@click="handleCreateVehicle"
:disabled="creatingVehicle"
class="px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 disabled:opacity-50"
>
{{ creatingVehicle ? 'Se salveaza...' : 'Salveaza vehicul' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ref, reactive, watch, onMounted } from 'vue'
import { useVehiclesStore } from '../../stores/vehicles.js'
import { execSQL, notifyTableChanged } from '../../db/database.js'
import { syncEngine } from '../../db/sync.js'
import { useAuthStore } from '../../stores/auth.js'
const props = defineProps({
modelValue: { type: String, default: null },
label: { type: String, default: 'Vehicul' },
placeholder: { type: String, default: 'Cauta dupa nr. inmatriculare sau client...' },
clientId: { type: String, default: null },
})
const emit = defineEmits(['update:modelValue', 'select'])
const vehiclesStore = useVehiclesStore()
const auth = useAuthStore()
const query = ref('')
const results = ref([])
const selected = ref(null)
const showDropdown = ref(false)
const loading = ref(false)
const showNewVehicle = ref(false)
const creatingVehicle = ref(false)
const marci = ref([])
const modele = ref([])
const showNewMarca = ref(false)
const showNewModel = ref(false)
const newMarcaName = ref('')
const newModelName = ref('')
const newVehicle = reactive({
nr_inmatriculare: '', serie_sasiu: '',
marca_id: null, model_id: null,
an_fabricatie: null, client_nume: '',
})
let searchTimeout = null
@@ -72,7 +162,7 @@ function onSearch() {
return
}
loading.value = true
results.value = await vehiclesStore.search(query.value)
results.value = await vehiclesStore.search(query.value, props.clientId)
loading.value = false
}, 200)
}
@@ -81,6 +171,7 @@ function selectVehicle(v) {
selected.value = v
query.value = v.nr_inmatriculare
showDropdown.value = false
showNewVehicle.value = false
emit('update:modelValue', v.id)
emit('select', v)
}
@@ -92,6 +183,70 @@ function clear() {
emit('select', null)
}
async function onNewMarcaChange() {
newVehicle.model_id = null
showNewModel.value = false
newModelName.value = ''
if (newVehicle.marca_id) {
modele.value = await vehiclesStore.getModele(newVehicle.marca_id)
} else {
modele.value = []
}
}
async function createMarca() {
if (!newMarcaName.value.trim()) return
const id = crypto.randomUUID()
const data = { id, tenant_id: auth.tenantId, denumire: newMarcaName.value.trim(), activ: 1 }
await execSQL(
`INSERT INTO catalog_marci (id, tenant_id, denumire, activ) VALUES (?,?,?,?)`,
[id, auth.tenantId, newMarcaName.value.trim(), 1]
)
notifyTableChanged('catalog_marci')
await syncEngine.addToQueue('catalog_marci', id, 'INSERT', data)
marci.value = await vehiclesStore.getMarci()
newVehicle.marca_id = id
showNewMarca.value = false
newMarcaName.value = ''
modele.value = []
}
async function createModel() {
if (!newModelName.value.trim() || !newVehicle.marca_id) return
const id = crypto.randomUUID()
const data = { id, marca_id: newVehicle.marca_id, denumire: newModelName.value.trim() }
await execSQL(
`INSERT INTO catalog_modele (id, marca_id, denumire) VALUES (?,?,?)`,
[id, newVehicle.marca_id, newModelName.value.trim()]
)
notifyTableChanged('catalog_modele')
await syncEngine.addToQueue('catalog_modele', id, 'INSERT', data)
modele.value = await vehiclesStore.getModele(newVehicle.marca_id)
newVehicle.model_id = id
showNewModel.value = false
newModelName.value = ''
}
async function handleCreateVehicle() {
if (!newVehicle.nr_inmatriculare) return
creatingVehicle.value = true
try {
const vehicleData = { ...newVehicle }
if (props.clientId) vehicleData.client_id = props.clientId
const id = await vehiclesStore.create(vehicleData)
const v = await vehiclesStore.getById(id)
if (v) selectVehicle(v)
showNewVehicle.value = false
Object.assign(newVehicle, {
nr_inmatriculare: '', serie_sasiu: '',
marca_id: null, model_id: null,
an_fabricatie: null, client_nume: '',
})
} finally {
creatingVehicle.value = false
}
}
// Load initial vehicle if modelValue is set
watch(() => props.modelValue, async (id) => {
if (id && !selected.value) {
@@ -103,6 +258,25 @@ watch(() => props.modelValue, async (id) => {
}
}, { immediate: true })
// Close dropdown on outside click
function onClickOutside() { showDropdown.value = false }
// When clientId changes, reset selection and load client vehicles
watch(() => props.clientId, async (newClientId, oldClientId) => {
if (newClientId !== oldClientId) {
selected.value = null
query.value = ''
results.value = []
emit('update:modelValue', null)
emit('select', null)
// Auto-load client vehicles
if (newClientId) {
loading.value = true
results.value = await vehiclesStore.getByClient(newClientId)
loading.value = false
if (results.value.length > 0) showDropdown.value = true
}
}
})
onMounted(async () => {
marci.value = await vehiclesStore.getMarci()
})
</script>

View File

@@ -4,23 +4,50 @@ export const SCHEMA_SQL = `
adresa TEXT, telefon TEXT, email TEXT, iban TEXT, banca TEXT,
plan TEXT DEFAULT 'free', trial_expires_at TEXT, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
tip_persoana TEXT DEFAULT 'PF',
denumire TEXT,
nume TEXT,
prenume TEXT,
cod_fiscal TEXT,
reg_com TEXT,
telefon TEXT,
email TEXT,
adresa TEXT,
judet TEXT,
oras TEXT,
cod_postal TEXT,
tara TEXT DEFAULT 'RO',
cont_iban TEXT,
banca TEXT,
activ INTEGER DEFAULT 1,
oracle_id INTEGER,
created_at TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS vehicles (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
client_id TEXT,
client_nume TEXT, client_telefon TEXT, client_email TEXT,
client_cod_fiscal TEXT, client_adresa TEXT,
client_cod_fiscal TEXT, client_cui TEXT, client_adresa TEXT,
nr_inmatriculare TEXT, marca_id TEXT, model_id TEXT,
an_fabricatie INTEGER, serie_sasiu TEXT, tip_motor_id TEXT,
an_fabricatie INTEGER, vin TEXT, serie_sasiu TEXT, tip_motor_id TEXT,
capacitate_motor TEXT, putere_kw TEXT,
oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
nr_comanda TEXT, data_comanda TEXT, vehicle_id TEXT,
client_id TEXT,
tip_deviz_id TEXT, status TEXT DEFAULT 'DRAFT',
km_intrare INTEGER, observatii TEXT,
mecanic_id TEXT,
client_nume TEXT, client_telefon TEXT, nr_auto TEXT,
marca_denumire TEXT, model_denumire TEXT,
total_manopera REAL DEFAULT 0, total_materiale REAL DEFAULT 0, total_general REAL DEFAULT 0,
token_client TEXT, created_by TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT
token_client TEXT, status_client TEXT, created_by TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS order_lines (
id TEXT PRIMARY KEY, order_id TEXT NOT NULL, tenant_id TEXT NOT NULL,
@@ -32,9 +59,12 @@ export const SCHEMA_SQL = `
);
CREATE TABLE IF NOT EXISTS invoices (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, order_id TEXT,
client_id TEXT,
nr_factura TEXT, serie_factura TEXT, data_factura TEXT,
tip_document TEXT DEFAULT 'FACTURA',
modalitate_plata TEXT, client_nume TEXT, client_cod_fiscal TEXT, nr_auto TEXT,
total_fara_tva REAL, tva REAL, total_general REAL,
total REAL DEFAULT 0, status TEXT DEFAULT 'EMISA',
oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS appointments (
@@ -46,31 +76,31 @@ export const SCHEMA_SQL = `
);
CREATE TABLE IF NOT EXISTS catalog_marci (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, activ INTEGER DEFAULT 1,
oracle_id INTEGER, updated_at TEXT
oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS catalog_modele (
id TEXT PRIMARY KEY, marca_id TEXT, denumire TEXT, oracle_id INTEGER, updated_at TEXT
id TEXT PRIMARY KEY, marca_id TEXT, denumire TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS catalog_ansamble (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, updated_at TEXT
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS catalog_norme (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, cod TEXT, denumire TEXT,
ore_normate REAL, ansamblu_id TEXT, oracle_id INTEGER, updated_at TEXT
ore_normate REAL, ansamblu_id TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS catalog_preturi (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, pret REAL, um TEXT,
oracle_id INTEGER, updated_at TEXT
oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS catalog_tipuri_deviz (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, updated_at TEXT
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS catalog_tipuri_motoare (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, updated_at TEXT
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS mecanici (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, user_id TEXT,
nume TEXT, prenume TEXT, activ INTEGER DEFAULT 1, oracle_id INTEGER, updated_at TEXT
nume TEXT, prenume TEXT, activ INTEGER DEFAULT 1, oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS _sync_queue (
id TEXT PRIMARY KEY, table_name TEXT, row_id TEXT,
@@ -82,7 +112,7 @@ export const SCHEMA_SQL = `
`;
export const SYNC_TABLES = [
'vehicles', 'orders', 'order_lines', 'invoices', 'appointments',
'clients', 'vehicles', 'orders', 'order_lines', 'invoices', 'appointments',
'catalog_marci', 'catalog_modele', 'catalog_ansamble', 'catalog_norme',
'catalog_preturi', 'catalog_tipuri_deviz', 'catalog_tipuri_motoare', 'mecanici'
];

View File

@@ -87,8 +87,8 @@ const mobileMenuOpen = ref(false)
const navItems = [
{ path: '/dashboard', label: 'Dashboard' },
{ path: '/orders', label: 'Comenzi' },
{ path: '/invoices', label: 'Facturi' },
{ path: '/clients', label: 'Clienti' },
{ path: '/vehicles', label: 'Vehicule' },
{ path: '/appointments', label: 'Programari' },
{ path: '/catalog', label: 'Catalog' },
@@ -97,7 +97,7 @@ const navItems = [
const mobileNavItems = [
{ path: '/dashboard', label: 'Acasa' },
{ path: '/orders', label: 'Comenzi' },
{ path: '/clients', label: 'Clienti' },
{ path: '/vehicles', label: 'Vehicule' },
{ path: '/appointments', label: 'Programari' },
{ path: '/settings', label: 'Setari' },

View File

@@ -7,9 +7,10 @@ const router = createRouter({
{ path: '/login', component: () => import('../views/auth/LoginView.vue'), meta: { layout: 'auth' } },
{ path: '/register', component: () => import('../views/auth/RegisterView.vue'), meta: { layout: 'auth' } },
{ path: '/dashboard', component: () => import('../views/dashboard/DashboardView.vue'), meta: { requiresAuth: true } },
{ path: '/orders', component: () => import('../views/orders/OrdersListView.vue'), meta: { requiresAuth: true } },
{ path: '/orders', redirect: '/dashboard' },
{ path: '/orders/new', component: () => import('../views/orders/OrderCreateView.vue'), meta: { requiresAuth: true } },
{ path: '/orders/:id', component: () => import('../views/orders/OrderDetailView.vue'), meta: { requiresAuth: true } },
{ path: '/clients', component: () => import('../views/clients/ClientsListView.vue'), meta: { requiresAuth: true } },
{ path: '/vehicles', component: () => import('../views/vehicles/VehiclesListView.vue'), meta: { requiresAuth: true } },
{ path: '/appointments', component: () => import('../views/appointments/AppointmentsView.vue'), meta: { requiresAuth: true } },
{ path: '/catalog', component: () => import('../views/catalog/CatalogView.vue'), meta: { requiresAuth: true } },

View File

@@ -0,0 +1,91 @@
import { defineStore } from 'pinia'
import { execSQL, notifyTableChanged } from '../db/database.js'
import { syncEngine } from '../db/sync.js'
import { useAuthStore } from './auth.js'
export const useClientsStore = defineStore('clients', () => {
const auth = useAuthStore()
async function getAll(search = '') {
let sql = `SELECT * FROM clients WHERE tenant_id = ?`
const params = [auth.tenantId]
if (search && search.length >= 2) {
sql += ` AND (denumire LIKE ? OR cod_fiscal LIKE ? OR telefon LIKE ? OR nume LIKE ? OR prenume LIKE ?)`
const like = `%${search}%`
params.push(like, like, like, like, like)
}
sql += ` ORDER BY denumire ASC`
return await execSQL(sql, params)
}
async function getById(id) {
const rows = await execSQL(`SELECT * FROM clients WHERE id = ? AND tenant_id = ?`, [id, auth.tenantId])
return rows[0] || null
}
async function create(data) {
const id = data.id || crypto.randomUUID()
const now = new Date().toISOString()
const row = {
id, tenant_id: auth.tenantId,
tip_persoana: data.tip_persoana || 'PF',
denumire: data.denumire || null,
nume: data.nume || null,
prenume: data.prenume || null,
cod_fiscal: data.cod_fiscal || null,
reg_com: data.reg_com || null,
telefon: data.telefon || null,
email: data.email || null,
adresa: data.adresa || null,
judet: data.judet || null,
oras: data.oras || null,
cod_postal: data.cod_postal || null,
tara: data.tara || 'RO',
cont_iban: data.cont_iban || null,
banca: data.banca || null,
activ: data.activ ?? 1,
oracle_id: data.oracle_id || null,
created_at: now, updated_at: now
}
await execSQL(
`INSERT INTO clients (id, tenant_id, tip_persoana, denumire, nume, prenume, cod_fiscal, reg_com, telefon, email, adresa, judet, oras, cod_postal, tara, cont_iban, banca, activ, oracle_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[row.id, row.tenant_id, row.tip_persoana, row.denumire, row.nume, row.prenume, row.cod_fiscal, row.reg_com, row.telefon, row.email, row.adresa, row.judet, row.oras, row.cod_postal, row.tara, row.cont_iban, row.banca, row.activ, row.oracle_id, row.created_at, row.updated_at]
)
await syncEngine.addToQueue('clients', id, 'INSERT', row)
notifyTableChanged('clients')
return row
}
async function update(id, data) {
const fields = []
const values = []
for (const [key, val] of Object.entries(data)) {
if (val !== undefined && key !== 'id' && key !== 'tenant_id') {
fields.push(`${key} = ?`)
values.push(val)
}
}
if (fields.length === 0) return
const now = new Date().toISOString()
fields.push('updated_at = ?')
values.push(now)
values.push(id, auth.tenantId)
await execSQL(`UPDATE clients SET ${fields.join(', ')} WHERE id = ? AND tenant_id = ?`, values)
const updated = await getById(id)
await syncEngine.addToQueue('clients', id, 'UPDATE', updated)
notifyTableChanged('clients')
return updated
}
async function search(query) {
if (!query || query.length < 2) return []
const like = `%${query}%`
return await execSQL(
`SELECT * FROM clients WHERE tenant_id = ? AND (denumire LIKE ? OR cod_fiscal LIKE ? OR telefon LIKE ? OR nume LIKE ? OR prenume LIKE ?) LIMIT 10`,
[auth.tenantId, like, like, like, like, like]
)
}
return { getAll, getById, create, update, search }
})

View File

@@ -6,13 +6,19 @@ import { useAuthStore } from './auth.js'
export const useOrdersStore = defineStore('orders', () => {
const auth = useAuthStore()
async function getAll(statusFilter = null) {
let sql = `SELECT * FROM orders WHERE tenant_id = ? ORDER BY created_at DESC`
async function getAll(statusFilter = null, search = '') {
let sql = `SELECT * FROM orders WHERE tenant_id = ?`
const params = [auth.tenantId]
if (statusFilter) {
sql = `SELECT * FROM orders WHERE tenant_id = ? AND status = ? ORDER BY created_at DESC`
sql += ` AND status = ?`
params.push(statusFilter)
}
if (search && search.length >= 2) {
sql += ` AND (nr_auto LIKE ? OR client_nume LIKE ? OR nr_comanda LIKE ?)`
const like = `%${search}%`
params.push(like, like, like)
}
sql += ` ORDER BY created_at DESC`
return execSQL(sql, params)
}
@@ -40,13 +46,23 @@ export const useOrdersStore = defineStore('orders', () => {
const now = new Date().toISOString()
const nr = `CMD-${Date.now().toString(36).toUpperCase()}`
// Lookup client info for denormalized fields
let clientNume = '', clientTelefon = ''
if (data.client_id) {
const [c] = await execSQL(`SELECT * FROM clients WHERE id = ?`, [data.client_id])
if (c) {
clientNume = c.tip_persoana === 'PJ' ? (c.denumire || '') : [c.nume, c.prenume].filter(Boolean).join(' ')
clientTelefon = c.telefon || ''
}
}
// Lookup vehicle info for denormalized fields
let clientNume = '', clientTelefon = '', nrAuto = '', marcaDenumire = '', modelDenumire = ''
let nrAuto = '', marcaDenumire = '', modelDenumire = ''
if (data.vehicle_id) {
const [v] = await execSQL(`SELECT * FROM vehicles WHERE id = ?`, [data.vehicle_id])
if (v) {
clientNume = v.client_nume || ''
clientTelefon = v.client_telefon || ''
if (!clientNume) clientNume = v.client_nume || ''
if (!clientTelefon) clientTelefon = v.client_telefon || ''
nrAuto = v.nr_inmatriculare || ''
const [marca] = await execSQL(`SELECT denumire FROM catalog_marci WHERE id = ?`, [v.marca_id])
const [model] = await execSQL(`SELECT denumire FROM catalog_modele WHERE id = ?`, [v.model_id])
@@ -56,16 +72,18 @@ export const useOrdersStore = defineStore('orders', () => {
}
await execSQL(
`INSERT INTO orders (id, tenant_id, nr_comanda, data_comanda, vehicle_id, tip_deviz_id, status, km_intrare, observatii, client_nume, client_telefon, nr_auto, marca_denumire, model_denumire, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, auth.tenantId, nr, now, data.vehicle_id || null, data.tip_deviz_id || null,
`INSERT INTO orders (id, tenant_id, nr_comanda, data_comanda, vehicle_id, client_id, tip_deviz_id, status, km_intrare, observatii, client_nume, client_telefon, nr_auto, marca_denumire, model_denumire, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, auth.tenantId, nr, now, data.vehicle_id || null, data.client_id || null,
data.tip_deviz_id || null,
'DRAFT', data.km_intrare || 0, data.observatii || '',
clientNume, clientTelefon, nrAuto, marcaDenumire, modelDenumire, now, now]
)
notifyTableChanged('orders')
await syncEngine.addToQueue('orders', id, 'INSERT', {
id, tenant_id: auth.tenantId, nr_comanda: nr, data_comanda: now,
vehicle_id: data.vehicle_id, tip_deviz_id: data.tip_deviz_id,
vehicle_id: data.vehicle_id, client_id: data.client_id,
tip_deviz_id: data.tip_deviz_id,
status: 'DRAFT', km_intrare: data.km_intrare || 0, observatii: data.observatii || '',
client_nume: clientNume, client_telefon: clientTelefon, nr_auto: nrAuto,
marca_denumire: marcaDenumire, model_denumire: modelDenumire
@@ -137,6 +155,49 @@ export const useOrdersStore = defineStore('orders', () => {
})
}
async function updateHeader(orderId, data) {
const now = new Date().toISOString()
// Re-denormalize client and vehicle info if IDs changed
let clientNume = data.client_nume, clientTelefon = data.client_telefon
let nrAuto = data.nr_auto, marcaDenumire = data.marca_denumire, modelDenumire = data.model_denumire
if (data.client_id) {
const [c] = await execSQL(`SELECT * FROM clients WHERE id = ?`, [data.client_id])
if (c) {
clientNume = c.tip_persoana === 'PJ' ? (c.denumire || '') : [c.nume, c.prenume].filter(Boolean).join(' ')
clientTelefon = c.telefon || ''
}
}
if (data.vehicle_id) {
const [v] = await execSQL(`SELECT * FROM vehicles WHERE id = ?`, [data.vehicle_id])
if (v) {
nrAuto = v.nr_inmatriculare || ''
const [marca] = await execSQL(`SELECT denumire FROM catalog_marci WHERE id = ?`, [v.marca_id])
const [model] = await execSQL(`SELECT denumire FROM catalog_modele WHERE id = ?`, [v.model_id])
marcaDenumire = marca?.denumire || ''
modelDenumire = model?.denumire || ''
}
}
await execSQL(
`UPDATE orders SET client_id=?, vehicle_id=?, tip_deviz_id=?, km_intrare=?, observatii=?,
client_nume=?, client_telefon=?, nr_auto=?, marca_denumire=?, model_denumire=?, updated_at=?
WHERE id=?`,
[data.client_id || null, data.vehicle_id || null, data.tip_deviz_id || null,
data.km_intrare || 0, data.observatii || '',
clientNume || '', clientTelefon || '', nrAuto || '', marcaDenumire || '', modelDenumire || '',
now, orderId]
)
notifyTableChanged('orders')
await syncEngine.addToQueue('orders', orderId, 'UPDATE', {
client_id: data.client_id, vehicle_id: data.vehicle_id, tip_deviz_id: data.tip_deviz_id,
km_intrare: data.km_intrare, observatii: data.observatii,
client_nume: clientNume, client_telefon: clientTelefon, nr_auto: nrAuto,
marca_denumire: marcaDenumire, model_denumire: modelDenumire
})
}
async function validateOrder(orderId) {
const now = new Date().toISOString()
await execSQL(`UPDATE orders SET status='VALIDAT', updated_at=? WHERE id=?`, [now, orderId])
@@ -144,6 +205,59 @@ export const useOrdersStore = defineStore('orders', () => {
await syncEngine.addToQueue('orders', orderId, 'UPDATE', { status: 'VALIDAT' })
}
async function devalidateOrder(orderId) {
const now = new Date().toISOString()
await execSQL(`UPDATE orders SET status='DRAFT', updated_at=? WHERE id=?`, [now, orderId])
notifyTableChanged('orders')
await syncEngine.addToQueue('orders', orderId, 'UPDATE', { status: 'DRAFT' })
}
async function deleteOrder(orderId) {
await execSQL(`DELETE FROM order_lines WHERE order_id = ?`, [orderId])
await execSQL(`DELETE FROM orders WHERE id = ?`, [orderId])
notifyTableChanged('order_lines')
notifyTableChanged('orders')
await syncEngine.addToQueue('orders', orderId, 'DELETE', {})
}
async function deleteInvoice(invoiceId) {
const [inv] = await execSQL(`SELECT * FROM invoices WHERE id = ?`, [invoiceId])
if (!inv) return
// Set order back to VALIDAT
if (inv.order_id) {
const now = new Date().toISOString()
await execSQL(`UPDATE orders SET status='VALIDAT', updated_at=? WHERE id=?`, [now, inv.order_id])
notifyTableChanged('orders')
await syncEngine.addToQueue('orders', inv.order_id, 'UPDATE', { status: 'VALIDAT' })
}
await execSQL(`DELETE FROM invoices WHERE id = ?`, [invoiceId])
notifyTableChanged('invoices')
await syncEngine.addToQueue('invoices', invoiceId, 'DELETE', {})
}
async function searchNorme(query) {
if (!query || query.length < 2) return []
const like = `%${query}%`
return execSQL(
`SELECT n.*, a.denumire as ansamblu_denumire
FROM catalog_norme n
LEFT JOIN catalog_ansamble a ON n.ansamblu_id = a.id
WHERE n.tenant_id = ? AND (n.denumire LIKE ? OR n.cod LIKE ?)
ORDER BY n.denumire LIMIT 10`,
[auth.tenantId, like, like]
)
}
async function searchPreturi(query) {
if (!query || query.length < 2) return []
const like = `%${query}%`
return execSQL(
`SELECT * FROM catalog_preturi WHERE tenant_id = ? AND denumire LIKE ?
ORDER BY denumire LIMIT 10`,
[auth.tenantId, like]
)
}
async function getStats() {
const [total] = await execSQL(
`SELECT COUNT(*) as cnt FROM orders WHERE tenant_id = ?`, [auth.tenantId]
@@ -154,20 +268,28 @@ export const useOrdersStore = defineStore('orders', () => {
const [validat] = await execSQL(
`SELECT COUNT(*) as cnt FROM orders WHERE tenant_id = ? AND status = 'VALIDAT'`, [auth.tenantId]
)
const [facturat] = await execSQL(
`SELECT COUNT(*) as cnt FROM orders WHERE tenant_id = ? AND status = 'FACTURAT'`, [auth.tenantId]
)
const [totalVehicles] = await execSQL(
`SELECT COUNT(*) as cnt FROM vehicles WHERE tenant_id = ?`, [auth.tenantId]
)
const [revenue] = await execSQL(
`SELECT COALESCE(SUM(total_general), 0) as s FROM orders WHERE tenant_id = ? AND status = 'VALIDAT'`, [auth.tenantId]
`SELECT COALESCE(SUM(total_general), 0) as s FROM orders WHERE tenant_id = ? AND status IN ('VALIDAT', 'FACTURAT')`, [auth.tenantId]
)
return {
totalOrders: total?.cnt || 0,
draftOrders: draft?.cnt || 0,
validatedOrders: validat?.cnt || 0,
facturatedOrders: facturat?.cnt || 0,
totalVehicles: totalVehicles?.cnt || 0,
totalRevenue: revenue?.s || 0,
}
}
return { getAll, getById, getLines, getRecentOrders, create, addLine, removeLine, validateOrder, getStats }
return {
getAll, getById, getLines, getRecentOrders, create, addLine, removeLine,
updateHeader, validateOrder, devalidateOrder, deleteOrder, deleteInvoice,
searchNorme, searchPreturi, getStats
}
})

View File

@@ -42,14 +42,26 @@ export const useVehiclesStore = defineStore('vehicles', () => {
return rows[0] || null
}
async function getByClient(clientId) {
return execSQL(
`SELECT v.*, m.denumire as marca_denumire, mo.denumire as model_denumire
FROM vehicles v
LEFT JOIN catalog_marci m ON v.marca_id = m.id
LEFT JOIN catalog_modele mo ON v.model_id = mo.id
WHERE v.client_id = ? AND v.tenant_id = ?
ORDER BY v.nr_inmatriculare`,
[clientId, auth.tenantId]
)
}
async function create(data) {
const id = crypto.randomUUID()
const now = new Date().toISOString()
await execSQL(
`INSERT INTO vehicles (id, tenant_id, client_nume, client_telefon, client_email, client_cod_fiscal, client_adresa, nr_inmatriculare, marca_id, model_id, an_fabricatie, serie_sasiu, tip_motor_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, auth.tenantId, data.client_nume || '', data.client_telefon || '',
`INSERT INTO vehicles (id, tenant_id, client_id, client_nume, client_telefon, client_email, client_cod_fiscal, client_adresa, nr_inmatriculare, marca_id, model_id, an_fabricatie, serie_sasiu, tip_motor_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, auth.tenantId, data.client_id || null, data.client_nume || '', data.client_telefon || '',
data.client_email || '', data.client_cod_fiscal || '', data.client_adresa || '',
data.nr_inmatriculare || '', data.marca_id || null, data.model_id || null,
data.an_fabricatie || null, data.serie_sasiu || '', data.tip_motor_id || null, now, now]
@@ -72,9 +84,24 @@ export const useVehiclesStore = defineStore('vehicles', () => {
await syncEngine.addToQueue('vehicles', id, 'UPDATE', data)
}
async function search(query) {
if (!query || query.length < 2) return []
async function search(query, clientId = null) {
if (!query || query.length < 2) {
// If no query but clientId, return client vehicles
if (clientId) return getByClient(clientId)
return []
}
const like = `%${query}%`
if (clientId) {
return execSQL(
`SELECT v.*, m.denumire as marca_denumire, mo.denumire as model_denumire
FROM vehicles v
LEFT JOIN catalog_marci m ON v.marca_id = m.id
LEFT JOIN catalog_modele mo ON v.model_id = mo.id
WHERE v.tenant_id = ? AND v.client_id = ? AND (v.nr_inmatriculare LIKE ? OR v.client_nume LIKE ?)
ORDER BY v.nr_inmatriculare LIMIT 10`,
[auth.tenantId, clientId, like, like]
)
}
return execSQL(
`SELECT v.*, m.denumire as marca_denumire, mo.denumire as model_denumire
FROM vehicles v
@@ -100,5 +127,5 @@ export const useVehiclesStore = defineStore('vehicles', () => {
)
}
return { getAll, getById, create, update, search, getMarci, getModele }
return { getAll, getById, getByClient, create, update, search, getMarci, getModele }
})

View File

@@ -128,6 +128,51 @@
</div>
</div>
<!-- Tab: Norme -->
<div v-if="activeTab === 'norme'">
<div class="bg-white rounded-lg shadow overflow-hidden mb-6">
<div v-if="norme.length === 0" class="p-8 text-center text-gray-500">Nicio norma gasita.</div>
<table v-else class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Cod</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Denumire</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Ansamblu</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Ore normate</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="n in norme" :key="n.id" class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-500">{{ n.cod || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-900">{{ n.denumire }}</td>
<td class="px-4 py-3 text-sm text-gray-500 hidden md:table-cell">{{ n.ansamblu_denumire || '-' }}</td>
<td class="px-4 py-3 text-sm text-right font-medium text-gray-900">{{ n.ore_normate || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Add Norma form -->
<div class="bg-white rounded-lg shadow p-4 max-w-lg">
<h3 class="text-sm font-semibold text-gray-700 mb-3">Adauga norma</h3>
<form @submit.prevent="addNorma" class="space-y-2">
<div class="grid grid-cols-2 gap-2">
<input v-model="newNorma.cod" type="text" placeholder="Cod" class="px-3 py-2 border border-gray-300 rounded-md text-sm" />
<select v-model="newNorma.ansamblu_id" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
<option :value="null">-- Ansamblu --</option>
<option v-for="a in ansamble" :key="a.id" :value="a.id">{{ a.denumire }}</option>
</select>
</div>
<div class="flex gap-2">
<input v-model="newNorma.denumire" type="text" required placeholder="Denumire norma" class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm" />
<input v-model.number="newNorma.ore_normate" type="number" step="0.1" min="0" placeholder="Ore" class="w-20 px-3 py-2 border border-gray-300 rounded-md text-sm" />
<button type="submit" :disabled="savingNorma" class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ savingNorma ? '...' : 'Adauga' }}
</button>
</div>
</form>
</div>
</div>
<!-- Tab: Preturi -->
<div v-if="activeTab === 'preturi'">
<div class="bg-white rounded-lg shadow overflow-hidden mb-6">
@@ -208,6 +253,7 @@ const tabs = [
{ key: 'marci', label: 'Marci' },
{ key: 'modele', label: 'Modele' },
{ key: 'ansamble', label: 'Ansamble' },
{ key: 'norme', label: 'Norme' },
{ key: 'preturi', label: 'Preturi' },
{ key: 'tipuri_deviz', label: 'Tipuri deviz' },
]
@@ -327,6 +373,43 @@ async function addAnsamblu() {
}
}
// ---- Norme ----
const norme = ref([])
const newNorma = reactive({ cod: '', denumire: '', ore_normate: null, ansamblu_id: null })
const savingNorma = ref(false)
async function loadNorme() {
norme.value = await execSQL(
`SELECT n.*, a.denumire AS ansamblu_denumire
FROM catalog_norme n
LEFT JOIN catalog_ansamble a ON a.id = n.ansamblu_id
WHERE n.tenant_id=?
ORDER BY n.denumire`,
[auth.tenantId]
)
}
async function addNorma() {
savingNorma.value = true
try {
const id = crypto.randomUUID()
const data = {
id, tenant_id: auth.tenantId, cod: newNorma.cod || null,
denumire: newNorma.denumire, ore_normate: newNorma.ore_normate || 0,
ansamblu_id: newNorma.ansamblu_id || null
}
await execSQL(
`INSERT INTO catalog_norme (id, tenant_id, cod, denumire, ore_normate, ansamblu_id) VALUES (?,?,?,?,?,?)`,
[id, auth.tenantId, data.cod, data.denumire, data.ore_normate, data.ansamblu_id]
)
notifyTableChanged('catalog_norme')
syncEngine.addToQueue('catalog_norme', id, 'INSERT', data)
Object.assign(newNorma, { cod: '', denumire: '', ore_normate: null, ansamblu_id: null })
} finally {
savingNorma.value = false
}
}
// ---- Preturi ----
const preturi = ref([])
const newPret = reactive({ denumire: '', pret: null, um: 'ora' })
@@ -390,6 +473,7 @@ async function addTipDeviz() {
onTableChange('catalog_marci', loadMarci)
onTableChange('catalog_modele', loadModele)
onTableChange('catalog_ansamble', loadAnsamble)
onTableChange('catalog_norme', loadNorme)
onTableChange('catalog_preturi', loadPreturi)
onTableChange('catalog_tipuri_deviz', loadTipuriDeviz)
@@ -398,6 +482,7 @@ watch(activeTab, (tab) => {
if (tab === 'marci') loadMarci()
else if (tab === 'modele') { loadMarci(); loadModele() }
else if (tab === 'ansamble') loadAnsamble()
else if (tab === 'norme') { loadAnsamble(); loadNorme() }
else if (tab === 'preturi') loadPreturi()
else if (tab === 'tipuri_deviz') loadTipuriDeviz()
})

View File

@@ -0,0 +1,287 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Clienti</h1>
<button
@click="showForm = !showForm; editingId = null"
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
{{ showForm ? 'Anuleaza' : '+ Adauga client' }}
</button>
</div>
<!-- New / Edit client form -->
<div v-if="showForm" class="bg-white rounded-lg shadow p-6 mb-6 max-w-2xl">
<h2 class="text-lg font-semibold mb-4">{{ editingId ? 'Editeaza client' : 'Client nou' }}</h2>
<form @submit.prevent="handleSave" class="space-y-4">
<!-- Tip persoana toggle -->
<div class="flex gap-4">
<label class="flex items-center gap-1.5 text-sm">
<input v-model="form.tip_persoana" type="radio" value="PF" class="text-blue-600" />
Persoana fizica (PF)
</label>
<label class="flex items-center gap-1.5 text-sm">
<input v-model="form.tip_persoana" type="radio" value="PJ" class="text-blue-600" />
Persoana juridica (PJ)
</label>
</div>
<!-- PF fields -->
<template v-if="form.tip_persoana === 'PF'">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nume</label>
<input v-model="form.nume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Prenume</label>
<input v-model="form.prenume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">CNP</label>
<input v-model="form.cod_fiscal" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="CNP" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
<input v-model="form.telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input v-model="form.email" type="email" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Adresa</label>
<input v-model="form.adresa" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
</template>
<!-- PJ fields -->
<template v-if="form.tip_persoana === 'PJ'">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Denumire firma</label>
<input v-model="form.denumire" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">CUI</label>
<input v-model="form.cod_fiscal" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="RO12345678" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Reg. comertului</label>
<input v-model="form.reg_com" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="J40/1234/2020" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
<input v-model="form.telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input v-model="form.email" type="email" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Adresa</label>
<input v-model="form.adresa" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">IBAN</label>
<input v-model="form.cont_iban" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Banca</label>
<input v-model="form.banca" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
</template>
<div class="flex gap-2">
<button type="submit" :disabled="saving" class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ saving ? 'Se salveaza...' : (editingId ? 'Salveaza modificarile' : 'Salveaza client') }}
</button>
<button v-if="editingId" type="button" @click="cancelEdit" class="px-4 py-2 text-gray-600 hover:text-gray-800 text-sm">
Anuleaza
</button>
</div>
</form>
</div>
<!-- Search -->
<div class="mb-4">
<input
v-model="searchQuery"
type="text"
placeholder="Cauta dupa denumire, CUI, telefon..."
class="w-full max-w-md px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
@input="onSearch"
/>
</div>
<!-- Clients table -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div v-if="loading" class="p-4 text-center text-gray-500">Se incarca...</div>
<div v-else-if="clients.length === 0" class="p-8 text-center text-gray-500">
Niciun client gasit.
</div>
<table v-else class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Denumire</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">CUI/CNP</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Telefon</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Email</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Tip</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr
v-for="c in clients"
:key="c.id"
class="hover:bg-gray-50 cursor-pointer"
@click="startEdit(c)"
>
<td class="px-4 py-3 text-sm font-medium text-gray-900">
{{ clientDisplayName(c) }}
</td>
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">{{ c.cod_fiscal || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ c.telefon || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">{{ c.email || '-' }}</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="c.tip_persoana === 'PJ' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'"
>
{{ c.tip_persoana || 'PF' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { useClientsStore } from '../../stores/clients.js'
import { onTableChange } from '../../db/database.js'
const clientsStore = useClientsStore()
const clients = ref([])
const loading = ref(true)
const searchQuery = ref('')
const showForm = ref(false)
const saving = ref(false)
const editingId = ref(null)
const form = reactive({
tip_persoana: 'PF',
denumire: '',
nume: '',
prenume: '',
cod_fiscal: '',
reg_com: '',
telefon: '',
email: '',
adresa: '',
judet: '',
oras: '',
cod_postal: '',
tara: 'RO',
cont_iban: '',
banca: '',
})
let searchTimeout = null
async function loadClients() {
loading.value = true
clients.value = await clientsStore.getAll(searchQuery.value)
loading.value = false
}
function onSearch() {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(loadClients, 300)
}
function clientDisplayName(c) {
if (c.tip_persoana === 'PJ' && c.denumire) return c.denumire
const parts = [c.nume, c.prenume].filter(Boolean)
return parts.length > 0 ? parts.join(' ') : (c.denumire || '-')
}
function resetForm() {
Object.assign(form, {
tip_persoana: 'PF', denumire: '', nume: '', prenume: '',
cod_fiscal: '', reg_com: '', telefon: '', email: '',
adresa: '', judet: '', oras: '', cod_postal: '', tara: 'RO',
cont_iban: '', banca: '',
})
}
function startEdit(client) {
editingId.value = client.id
showForm.value = true
Object.assign(form, {
tip_persoana: client.tip_persoana || 'PF',
denumire: client.denumire || '',
nume: client.nume || '',
prenume: client.prenume || '',
cod_fiscal: client.cod_fiscal || '',
reg_com: client.reg_com || '',
telefon: client.telefon || '',
email: client.email || '',
adresa: client.adresa || '',
judet: client.judet || '',
oras: client.oras || '',
cod_postal: client.cod_postal || '',
tara: client.tara || 'RO',
cont_iban: client.cont_iban || '',
banca: client.banca || '',
})
}
function cancelEdit() {
editingId.value = null
showForm.value = false
resetForm()
}
async function handleSave() {
saving.value = true
try {
if (editingId.value) {
await clientsStore.update(editingId.value, { ...form })
editingId.value = null
} else {
await clientsStore.create({ ...form })
}
showForm.value = false
resetForm()
} finally {
saving.value = false
}
}
let unsubscribe = null
onMounted(() => {
loadClients()
unsubscribe = onTableChange('clients', loadClients)
})
onUnmounted(() => {
if (unsubscribe) unsubscribe()
})
</script>

View File

@@ -1,88 +1,163 @@
<template>
<div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
<router-link
to="/orders/new"
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
+ Comanda noua
</router-link>
</div>
<UpgradeBanner />
<!-- Stats cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-lg shadow p-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow p-4 cursor-pointer hover:ring-2 hover:ring-blue-300" @click="activeFilter = null">
<p class="text-sm text-gray-500">Total comenzi</p>
<p class="text-2xl font-bold text-gray-900">{{ stats.totalOrders }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="bg-white rounded-lg shadow p-4 cursor-pointer hover:ring-2 hover:ring-yellow-300" @click="activeFilter = 'DRAFT'">
<p class="text-sm text-gray-500">In lucru (draft)</p>
<p class="text-2xl font-bold text-yellow-600">{{ stats.draftOrders }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="bg-white rounded-lg shadow p-4 cursor-pointer hover:ring-2 hover:ring-green-300" @click="activeFilter = 'VALIDAT'">
<p class="text-sm text-gray-500">Validate</p>
<p class="text-2xl font-bold text-green-600">{{ stats.validatedOrders }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Vehicule</p>
<p class="text-2xl font-bold text-blue-600">{{ stats.totalVehicles }}</p>
<div class="bg-white rounded-lg shadow p-4 cursor-pointer hover:ring-2 hover:ring-blue-300" @click="activeFilter = 'FACTURAT'">
<p class="text-sm text-gray-500">Facturate</p>
<p class="text-2xl font-bold text-blue-600">{{ stats.facturatedOrders }}</p>
</div>
</div>
<!-- Revenue card -->
<div class="bg-white rounded-lg shadow p-4 mb-8">
<p class="text-sm text-gray-500">Venituri totale (comenzi validate)</p>
<p class="text-3xl font-bold text-gray-900">{{ stats.totalRevenue.toFixed(2) }} RON</p>
<!-- Filter tabs + Search -->
<div class="flex flex-col md:flex-row md:items-center gap-3 mb-4">
<div class="flex gap-2">
<button
v-for="f in filters"
:key="f.value"
@click="activeFilter = f.value"
class="px-3 py-1.5 text-sm rounded-md"
:class="activeFilter === f.value
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50'"
>
{{ f.label }}
</button>
</div>
<input
v-model="searchQuery"
type="text"
placeholder="Cauta dupa nr. auto, client, nr. comanda..."
class="flex-1 max-w-md px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
@input="onSearch"
/>
</div>
<!-- Recent orders -->
<div class="bg-white rounded-lg shadow">
<div class="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Comenzi recente</h2>
<router-link to="/orders" class="text-sm text-blue-600 hover:underline">Vezi toate</router-link>
<!-- Orders table -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div v-if="loading" class="p-4 text-center text-gray-500">Se incarca...</div>
<div v-else-if="orders.length === 0" class="p-8 text-center text-gray-500">
Nicio comanda gasita.
</div>
<div v-if="recentOrders.length === 0" class="p-4 text-sm text-gray-500">
Nicio comanda inca.
</div>
<ul v-else class="divide-y divide-gray-100">
<li v-for="o in recentOrders" :key="o.id">
<router-link
:to="`/orders/${o.id}`"
class="flex items-center justify-between px-4 py-3 hover:bg-gray-50"
<table v-else class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nr. comanda</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nr. auto</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Client</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Marca / Model</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Total</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr
v-for="o in orders"
:key="o.id"
class="hover:bg-gray-50 cursor-pointer"
@click="$router.push(`/orders/${o.id}`)"
>
<div>
<p class="text-sm font-medium text-gray-900">{{ o.nr_comanda }}</p>
<p class="text-xs text-gray-500">
{{ o.nr_auto || 'Fara vehicul' }}
<span v-if="o.client_nume"> - {{ o.client_nume }}</span>
</p>
</div>
<div class="text-right">
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ o.nr_comanda }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ o.nr_auto || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">{{ o.client_nume || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">
{{ [o.marca_denumire, o.model_denumire].filter(Boolean).join(' ') || '-' }}
</td>
<td class="px-4 py-3">
<span
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium"
:class="statusClass(o.status)"
>
{{ o.status }}
</span>
<p class="text-sm font-medium text-gray-900 mt-1">{{ (o.total_general || 0).toFixed(2) }} RON</p>
</div>
</router-link>
</li>
</ul>
</td>
<td class="px-4 py-3 text-sm text-right font-medium text-gray-900">
{{ (o.total_general || 0).toFixed(2) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useOrdersStore } from '../../stores/orders.js'
import { useSync } from '../../composables/useSync.js'
import { onTableChange } from '../../db/database.js'
import UpgradeBanner from '../../components/common/UpgradeBanner.vue'
useSync()
const ordersStore = useOrdersStore()
const stats = ref({ totalOrders: 0, draftOrders: 0, validatedOrders: 0, totalVehicles: 0, totalRevenue: 0 })
const recentOrders = ref([])
const stats = ref({ totalOrders: 0, draftOrders: 0, validatedOrders: 0, facturatedOrders: 0, totalVehicles: 0, totalRevenue: 0 })
const orders = ref([])
const loading = ref(true)
const activeFilter = ref(null)
const searchQuery = ref('')
const filters = [
{ label: 'Toate', value: null },
{ label: 'Draft', value: 'DRAFT' },
{ label: 'Validate', value: 'VALIDAT' },
{ label: 'Facturate', value: 'FACTURAT' },
]
let searchTimeout = null
async function loadOrders() {
loading.value = true
orders.value = await ordersStore.getAll(activeFilter.value, searchQuery.value)
loading.value = false
}
async function loadStats() {
stats.value = await ordersStore.getStats()
}
function onSearch() {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(loadOrders, 300)
}
watch(activeFilter, loadOrders)
let unsubOrders = null
onMounted(async () => {
stats.value = await ordersStore.getStats()
recentOrders.value = await ordersStore.getRecentOrders(5)
await Promise.all([loadStats(), loadOrders()])
unsubOrders = onTableChange('orders', () => {
loadOrders()
loadStats()
})
})
onUnmounted(() => {
if (unsubOrders) unsubOrders()
})
function statusClass(status) {

View File

@@ -11,6 +11,7 @@
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nr. factura</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Tip</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Data</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Client</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Nr. auto</th>
@@ -23,6 +24,14 @@
<td class="px-4 py-3 text-sm font-medium text-gray-900">
{{ inv.serie_factura }}{{ inv.nr_factura }}
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="inv.tip_document === 'BON_FISCAL' ? 'bg-gray-100 text-gray-700' : 'bg-blue-100 text-blue-700'"
>
{{ inv.tip_document === 'BON_FISCAL' ? 'BON FISCAL' : 'FACTURA' }}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ formatDate(inv.data_factura) }}</td>
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">{{ inv.client_nume || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">{{ inv.nr_auto || '-' }}</td>

View File

@@ -2,77 +2,69 @@
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Comanda noua</h1>
<router-link to="/orders" class="text-sm text-gray-500 hover:text-gray-700">Inapoi la comenzi</router-link>
<router-link to="/dashboard" class="text-sm text-gray-500 hover:text-gray-700">Inapoi</router-link>
</div>
<form @submit.prevent="handleCreate" class="bg-white rounded-lg shadow p-6 space-y-4 max-w-2xl">
<!-- Vehicle picker -->
<VehiclePicker v-model="form.vehicle_id" @select="onVehicleSelect" />
<!-- Or create new vehicle inline -->
<div v-if="!form.vehicle_id" class="border-t pt-4">
<button
type="button"
@click="showNewVehicle = !showNewVehicle"
class="text-sm text-blue-600 hover:underline"
>
{{ showNewVehicle ? 'Ascunde formular vehicul' : '+ Vehicul nou' }}
</button>
<div v-if="showNewVehicle" class="mt-3 space-y-3 bg-gray-50 rounded-lg p-4">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Nr. inmatriculare</label>
<input v-model="newVehicle.nr_inmatriculare" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="B 123 ABC" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Nume client</label>
<input v-model="newVehicle.client_nume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Telefon client</label>
<input v-model="newVehicle.client_telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Serie sasiu (VIN)</label>
<input v-model="newVehicle.serie_sasiu" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
</div>
</div>
<!-- Tip deviz -->
<div class="bg-white rounded-lg shadow p-6 space-y-6 max-w-2xl">
<!-- Step 1: Select Client -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Tip deviz</label>
<select v-model="form.tip_deviz_id" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
<option :value="null">-- Selecteaza --</option>
<option v-for="t in tipuriDeviz" :key="t.id" :value="t.id">{{ t.denumire }}</option>
</select>
<div class="flex items-center gap-2 mb-3">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-600 text-white text-xs font-bold">1</span>
<h2 class="text-lg font-semibold text-gray-900">Selecteaza client</h2>
</div>
<ClientPicker v-model="form.client_id" @select="onClientSelect" />
</div>
<!-- KM + Observatii -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">KM intrare</label>
<input v-model.number="form.km_intrare" type="number" min="0" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
<!-- Step 2: Select Vehicle (visible after client is selected) -->
<div v-if="form.client_id" class="border-t pt-6">
<div class="flex items-center gap-2 mb-3">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-600 text-white text-xs font-bold">2</span>
<h2 class="text-lg font-semibold text-gray-900">Selecteaza vehicul</h2>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Observatii</label>
<input v-model="form.observatii" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
<VehiclePicker v-model="form.vehicle_id" :client-id="form.client_id" @select="onVehicleSelect" />
</div>
<!-- Step 3: Order Details (visible after vehicle is selected) -->
<div v-if="form.vehicle_id" class="border-t pt-6">
<div class="flex items-center gap-2 mb-3">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-600 text-white text-xs font-bold">3</span>
<h2 class="text-lg font-semibold text-gray-900">Detalii comanda</h2>
</div>
<div class="space-y-4">
<!-- Tip deviz -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Tip deviz</label>
<select v-model="form.tip_deviz_id" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
<option :value="null">-- Selecteaza --</option>
<option v-for="t in tipuriDeviz" :key="t.id" :value="t.id">{{ t.denumire }}</option>
</select>
</div>
<!-- KM + Observatii -->
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">KM intrare</label>
<input v-model.number="form.km_intrare" type="number" min="0" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Observatii</label>
<input v-model="form.observatii" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
</div>
</div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<button
type="submit"
v-if="form.vehicle_id"
@click="handleCreate"
:disabled="saving"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{{ saving ? 'Se creeaza...' : 'Creeaza comanda' }}
</button>
</form>
</div>
</div>
</template>
@@ -80,31 +72,23 @@
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useOrdersStore } from '../../stores/orders.js'
import { useVehiclesStore } from '../../stores/vehicles.js'
import { execSQL } from '../../db/database.js'
import { useAuthStore } from '../../stores/auth.js'
import ClientPicker from '../../components/clients/ClientPicker.vue'
import VehiclePicker from '../../components/vehicles/VehiclePicker.vue'
const router = useRouter()
const ordersStore = useOrdersStore()
const vehiclesStore = useVehiclesStore()
const auth = useAuthStore()
const form = reactive({
client_id: null,
vehicle_id: null,
tip_deviz_id: null,
km_intrare: 0,
observatii: '',
})
const newVehicle = reactive({
nr_inmatriculare: '',
client_nume: '',
client_telefon: '',
serie_sasiu: '',
})
const showNewVehicle = ref(false)
const tipuriDeviz = ref([])
const error = ref('')
const saving = ref(false)
@@ -116,18 +100,20 @@ onMounted(async () => {
)
})
function onClientSelect(client) {
if (!client) {
form.vehicle_id = null
}
}
function onVehicleSelect(v) {
if (v) showNewVehicle.value = false
// vehicle selected
}
async function handleCreate() {
error.value = ''
saving.value = true
try {
// Create new vehicle if needed
if (!form.vehicle_id && showNewVehicle.value && newVehicle.nr_inmatriculare) {
form.vehicle_id = await vehiclesStore.create({ ...newVehicle })
}
const id = await ordersStore.create(form)
router.push(`/orders/${id}`)
} catch (e) {

View File

@@ -1,33 +1,11 @@
<template>
<div v-if="order">
<div class="flex items-center justify-between mb-6">
<div>
<router-link to="/orders" class="text-sm text-gray-500 hover:text-gray-700">Comenzi</router-link>
<h1 class="text-2xl font-bold text-gray-900">{{ order.nr_comanda }}</h1>
</div>
<div class="flex gap-2">
<PdfDownloadButton
v-if="order.status !== 'DRAFT'"
type="deviz"
:order-id="order.id"
:nr-comanda="order.nr_comanda"
label="PDF Deviz"
/>
<button
v-if="order.status === 'DRAFT'"
@click="handleValidate"
class="px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700"
>
Valideaza
</button>
<button
v-if="order.status === 'VALIDAT'"
@click="handleFactureaza"
:disabled="facturand"
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{{ facturand ? 'Se proceseaza...' : 'Factureaza' }}
</button>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
<div class="flex items-center gap-3">
<div>
<router-link to="/dashboard" class="text-sm text-gray-500 hover:text-gray-700">Comenzi</router-link>
<h1 class="text-2xl font-bold text-gray-900">{{ order.nr_comanda }}</h1>
</div>
<span
class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium"
:class="statusClass(order.status)"
@@ -35,10 +13,105 @@
{{ order.status }}
</span>
</div>
<div class="flex gap-2 flex-wrap">
<PdfDownloadButton
v-if="order.status !== 'DRAFT'"
type="deviz"
:order-id="order.id"
:nr-comanda="order.nr_comanda"
label="PDF Deviz"
/>
<!-- Edit button (DRAFT only) -->
<button
v-if="order.status === 'DRAFT' && !editing"
@click="startEdit"
class="px-4 py-2 bg-gray-600 text-white text-sm rounded-md hover:bg-gray-700"
>
Editeaza
</button>
<!-- Validate button (DRAFT only) -->
<button
v-if="order.status === 'DRAFT' && !editing"
@click="handleValidate"
class="px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700"
>
Valideaza
</button>
<!-- Devalidate button (VALIDAT, no invoice) -->
<button
v-if="order.status === 'VALIDAT'"
@click="confirmDevalidate = true"
class="px-4 py-2 bg-yellow-600 text-white text-sm rounded-md hover:bg-yellow-700"
>
Devalideaza
</button>
<!-- Facturare button (VALIDAT) -->
<button
v-if="order.status === 'VALIDAT'"
@click="showFacturareDialog = true"
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
Factureaza
</button>
<!-- Delete invoice button (FACTURAT) -->
<button
v-if="order.status === 'FACTURAT' && invoice"
@click="confirmDeleteInvoice = true"
class="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
>
Sterge factura
</button>
<!-- Delete order button (DRAFT or VALIDAT) -->
<button
v-if="order.status === 'DRAFT' || order.status === 'VALIDAT'"
@click="confirmDelete = true"
class="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
>
Sterge
</button>
</div>
</div>
<!-- Order info -->
<div class="bg-white rounded-lg shadow p-4 mb-6">
<!-- Edit form (DRAFT only) -->
<div v-if="editing" class="bg-white rounded-lg shadow p-6 mb-6 max-w-2xl">
<h2 class="text-lg font-semibold mb-4">Editeaza comanda</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Client</label>
<ClientPicker v-model="editForm.client_id" @select="onEditClientSelect" />
</div>
<div v-if="editForm.client_id">
<label class="block text-sm font-medium text-gray-700 mb-1">Vehicul</label>
<VehiclePicker v-model="editForm.vehicle_id" :client-id="editForm.client_id" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Tip deviz</label>
<select v-model="editForm.tip_deviz_id" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
<option :value="null">-- Selecteaza --</option>
<option v-for="t in tipuriDeviz" :key="t.id" :value="t.id">{{ t.denumire }}</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">KM intrare</label>
<input v-model.number="editForm.km_intrare" type="number" min="0" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Observatii</label>
<input v-model="editForm.observatii" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
</div>
<div class="flex gap-2 mt-4">
<button @click="handleSaveEdit" :disabled="savingEdit" class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ savingEdit ? 'Se salveaza...' : 'Salveaza' }}
</button>
<button @click="cancelEdit" class="px-4 py-2 text-gray-600 hover:text-gray-800 text-sm">Anuleaza</button>
</div>
</div>
<!-- Order info (read mode) -->
<div v-if="!editing" class="bg-white rounded-lg shadow p-4 mb-6">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p class="text-gray-500">Nr. auto</p>
@@ -134,6 +207,65 @@
</div>
</div>
</div>
<!-- Confirm Devalidate dialog -->
<div v-if="confirmDevalidate" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="confirmDevalidate = false">
<div class="bg-white rounded-lg shadow-lg p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Devalideaza comanda?</h3>
<p class="text-sm text-gray-600 mb-4">Comanda va reveni la status DRAFT si va putea fi editata.</p>
<div class="flex gap-2 justify-end">
<button @click="confirmDevalidate = false" class="px-4 py-2 text-gray-600 text-sm hover:text-gray-800">Anuleaza</button>
<button @click="handleDevalidate" class="px-4 py-2 bg-yellow-600 text-white text-sm rounded-md hover:bg-yellow-700">Da, devalideaza</button>
</div>
</div>
</div>
<!-- Confirm Delete dialog -->
<div v-if="confirmDelete" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="confirmDelete = false">
<div class="bg-white rounded-lg shadow-lg p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Sterge comanda?</h3>
<p class="text-sm text-gray-600 mb-4">Comanda si toate liniile aferente vor fi sterse definitiv.</p>
<div class="flex gap-2 justify-end">
<button @click="confirmDelete = false" class="px-4 py-2 text-gray-600 text-sm hover:text-gray-800">Anuleaza</button>
<button @click="handleDelete" class="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700">Da, sterge</button>
</div>
</div>
</div>
<!-- Confirm Delete Invoice dialog -->
<div v-if="confirmDeleteInvoice" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="confirmDeleteInvoice = false">
<div class="bg-white rounded-lg shadow-lg p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Sterge factura?</h3>
<p class="text-sm text-gray-600 mb-4">Factura va fi stearsa si comanda va reveni la status VALIDAT.</p>
<div class="flex gap-2 justify-end">
<button @click="confirmDeleteInvoice = false" class="px-4 py-2 text-gray-600 text-sm hover:text-gray-800">Anuleaza</button>
<button @click="handleDeleteInvoice" class="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700">Da, sterge</button>
</div>
</div>
</div>
<!-- Facturare dialog -->
<div v-if="showFacturareDialog" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="showFacturareDialog = false">
<div class="bg-white rounded-lg shadow-lg p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Tip document</h3>
<div class="space-y-3 mb-4">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="facturaTip" type="radio" value="FACTURA" class="text-blue-600" />
<span>Factura</span>
</label>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="facturaTip" type="radio" value="BON_FISCAL" class="text-blue-600" />
<span>Bon fiscal</span>
</label>
</div>
<div class="flex gap-2 justify-end">
<button @click="showFacturareDialog = false" class="px-4 py-2 text-gray-600 text-sm hover:text-gray-800">Anuleaza</button>
<button @click="handleFactureaza" :disabled="facturand" class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ facturand ? 'Se proceseaza...' : 'Creeaza' }}
</button>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-12 text-gray-500">
@@ -142,30 +274,64 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useOrdersStore } from '../../stores/orders.js'
import { execSQL, onTableChange, notifyTableChanged } from '../../db/database.js'
import { syncEngine } from '../../db/sync.js'
import { useAuthStore } from '../../stores/auth.js'
import OrderLineForm from '../../components/orders/OrderLineForm.vue'
import PdfDownloadButton from '../../components/orders/PdfDownloadButton.vue'
import ClientPicker from '../../components/clients/ClientPicker.vue'
import VehiclePicker from '../../components/vehicles/VehiclePicker.vue'
const route = useRoute()
const router = useRouter()
const ordersStore = useOrdersStore()
const auth = useAuthStore()
const order = ref(null)
const lines = ref([])
const invoice = ref(null)
const facturand = ref(false)
const editing = ref(false)
const savingEdit = ref(false)
const confirmDevalidate = ref(false)
const confirmDelete = ref(false)
const confirmDeleteInvoice = ref(false)
const showFacturareDialog = ref(false)
const facturaTip = ref('FACTURA')
const tipuriDeviz = ref([])
const editForm = reactive({
client_id: null,
vehicle_id: null,
tip_deviz_id: null,
km_intrare: 0,
observatii: '',
})
async function loadOrder() {
order.value = await ordersStore.getById(route.params.id)
lines.value = await ordersStore.getLines(route.params.id)
// Load invoice for FACTURAT orders
if (order.value?.status === 'FACTURAT') {
const invoices = await execSQL(
`SELECT * FROM invoices WHERE order_id = ? AND tenant_id = ?`,
[route.params.id, auth.tenantId]
)
invoice.value = invoices[0] || null
} else {
invoice.value = null
}
}
onMounted(() => {
loadOrder()
onMounted(async () => {
await loadOrder()
tipuriDeviz.value = await execSQL(
`SELECT * FROM catalog_tipuri_deviz WHERE tenant_id = ? ORDER BY denumire`,
[auth.tenantId]
)
onTableChange('orders', loadOrder)
onTableChange('order_lines', async () => {
lines.value = await ordersStore.getLines(route.params.id)
@@ -173,6 +339,35 @@ onMounted(() => {
})
})
function startEdit() {
editForm.client_id = order.value.client_id || null
editForm.vehicle_id = order.value.vehicle_id || null
editForm.tip_deviz_id = order.value.tip_deviz_id || null
editForm.km_intrare = order.value.km_intrare || 0
editForm.observatii = order.value.observatii || ''
editing.value = true
}
function cancelEdit() {
editing.value = false
}
function onEditClientSelect(client) {
if (!client) {
editForm.vehicle_id = null
}
}
async function handleSaveEdit() {
savingEdit.value = true
try {
await ordersStore.updateHeader(route.params.id, { ...editForm })
editing.value = false
} finally {
savingEdit.value = false
}
}
async function handleAddLine(lineData) {
await ordersStore.addLine(route.params.id, lineData)
}
@@ -185,18 +380,42 @@ async function handleValidate() {
await ordersStore.validateOrder(route.params.id)
}
async function handleDevalidate() {
await ordersStore.devalidateOrder(route.params.id)
confirmDevalidate.value = false
}
async function handleDelete() {
await ordersStore.deleteOrder(route.params.id)
confirmDelete.value = false
router.push('/dashboard')
}
async function handleDeleteInvoice() {
if (invoice.value) {
await ordersStore.deleteInvoice(invoice.value.id)
}
confirmDeleteInvoice.value = false
}
async function handleFactureaza() {
if (!order.value) return
facturand.value = true
try {
const id = crypto.randomUUID()
const now = new Date().toISOString()
const nrFactura = `F${Date.now().toString().slice(-6)}`
const year = new Date().getFullYear()
const seq = Date.now().toString().slice(-4)
const nrFactura = facturaTip.value === 'FACTURA'
? `F-${year}-${seq}`
: `BF-${year}-${seq}`
await execSQL(
`INSERT INTO invoices (id, tenant_id, order_id, nr_factura, serie_factura, data_factura, client_nume, nr_auto, total_fara_tva, tva, total_general, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, auth.tenantId, order.value.id, nrFactura, 'ROA', now.slice(0,10),
order.value.client_nume, order.value.nr_auto,
`INSERT INTO invoices (id, tenant_id, order_id, client_id, nr_factura, serie_factura, data_factura, tip_document, modalitate_plata, client_nume, client_cod_fiscal, nr_auto, total_fara_tva, tva, total_general, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, auth.tenantId, order.value.id, order.value.client_id || null,
nrFactura, 'ROA', now.slice(0, 10), facturaTip.value, null,
order.value.client_nume, null, order.value.nr_auto,
(order.value.total_general || 0) / 1.19,
(order.value.total_general || 0) - (order.value.total_general || 0) / 1.19,
order.value.total_general || 0, now, now]
@@ -204,7 +423,13 @@ async function handleFactureaza() {
await execSQL(`UPDATE orders SET status='FACTURAT', updated_at=? WHERE id=?`, [now, order.value.id])
notifyTableChanged('invoices')
notifyTableChanged('orders')
syncEngine.addToQueue('invoices', id, 'INSERT', { id, tenant_id: auth.tenantId, order_id: order.value.id, nr_factura: nrFactura })
await syncEngine.addToQueue('invoices', id, 'INSERT', {
id, tenant_id: auth.tenantId, order_id: order.value.id,
client_id: order.value.client_id, nr_factura: nrFactura,
tip_document: facturaTip.value
})
await syncEngine.addToQueue('orders', order.value.id, 'UPDATE', { status: 'FACTURAT' })
showFacturareDialog.value = false
} finally {
facturand.value = false
}