feat(frontend): Dashboard + Orders UI + Vehicle Picker + Vehicles list
- Pinia stores: orders (CRUD, line management, totals recalc, stats) and vehicles (CRUD, search, marca/model cascade) - useSync composable: auto-sync on window focus + periodic 60s interval - VehiclePicker component: debounced autocomplete search by nr. inmatriculare or client name - OrderLineForm component: manopera/material toggle with live total preview - DashboardView: stats cards (orders, vehicles, revenue), recent orders list - OrdersListView: filterable table (all/draft/validat/facturat), clickable rows - OrderCreateView: vehicle picker + inline new vehicle form, tip deviz select, km/observatii - OrderDetailView: order info, lines table with add/remove, totals, validate action - VehiclesListView: searchable table, inline create form with marca/model cascade - AppLayout: mobile hamburger menu with slide-in sidebar overlay Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,139 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">Comanda noua</h1>
|
||||
<p class="text-gray-500">Creare comanda - va fi implementat in TASK-006.</p>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||
|
||||
<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 creeaza...' : 'Creeaza comanda' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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 VehiclePicker from '../../components/vehicles/VehiclePicker.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const ordersStore = useOrdersStore()
|
||||
const vehiclesStore = useVehiclesStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const form = reactive({
|
||||
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)
|
||||
|
||||
onMounted(async () => {
|
||||
tipuriDeviz.value = await execSQL(
|
||||
`SELECT * FROM catalog_tipuri_deviz WHERE tenant_id = ? ORDER BY denumire`,
|
||||
[auth.tenantId]
|
||||
)
|
||||
})
|
||||
|
||||
function onVehicleSelect(v) {
|
||||
if (v) showNewVehicle.value = false
|
||||
}
|
||||
|
||||
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) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,176 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">Detalii comanda</h1>
|
||||
<p class="text-gray-500">Detalii comanda - va fi implementat in TASK-008.</p>
|
||||
<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">
|
||||
<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>
|
||||
<span
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium"
|
||||
:class="statusClass(order.status)"
|
||||
>
|
||||
{{ order.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order info -->
|
||||
<div 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>
|
||||
<p class="font-medium">{{ order.nr_auto || '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Client</p>
|
||||
<p class="font-medium">{{ order.client_nume || '-' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">Marca / Model</p>
|
||||
<p class="font-medium">{{ order.marca_denumire || '-' }} {{ order.model_denumire || '' }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-gray-500">KM intrare</p>
|
||||
<p class="font-medium">{{ order.km_intrare || '-' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="order.observatii" class="mt-3 text-sm">
|
||||
<p class="text-gray-500">Observatii</p>
|
||||
<p>{{ order.observatii }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Order lines -->
|
||||
<div class="bg-white rounded-lg shadow mb-6">
|
||||
<div class="px-4 py-3 border-b border-gray-200">
|
||||
<h2 class="text-lg font-semibold text-gray-900">Linii comanda</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="lines.length === 0" class="p-4 text-sm text-gray-500">
|
||||
Nicio linie adaugata.
|
||||
</div>
|
||||
|
||||
<table v-else class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Tip</th>
|
||||
<th class="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase">Descriere</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Cant/Ore</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Pret</th>
|
||||
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Total</th>
|
||||
<th v-if="order.status === 'DRAFT'" class="px-4 py-2 w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr v-for="l in lines" :key="l.id">
|
||||
<td class="px-4 py-2 text-xs">
|
||||
<span
|
||||
class="px-1.5 py-0.5 rounded text-xs font-medium"
|
||||
:class="l.tip === 'manopera' ? 'bg-purple-100 text-purple-700' : 'bg-orange-100 text-orange-700'"
|
||||
>
|
||||
{{ l.tip === 'manopera' ? 'MAN' : 'MAT' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm text-gray-900">{{ l.descriere }}</td>
|
||||
<td class="px-4 py-2 text-sm text-right text-gray-600 hidden md:table-cell">
|
||||
{{ l.tip === 'manopera' ? `${l.ore}h` : `${l.cantitate} ${l.um}` }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm text-right text-gray-600 hidden md:table-cell">
|
||||
{{ l.tip === 'manopera' ? `${(l.pret_ora || 0).toFixed(2)}/h` : `${(l.pret_unitar || 0).toFixed(2)}/${l.um}` }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-sm text-right font-medium text-gray-900">
|
||||
{{ (l.total || 0).toFixed(2) }}
|
||||
</td>
|
||||
<td v-if="order.status === 'DRAFT'" class="px-4 py-2">
|
||||
<button @click="handleRemoveLine(l.id)" class="text-red-500 hover:text-red-700 text-xs">X</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Add line form (only for draft) -->
|
||||
<OrderLineForm v-if="order.status === 'DRAFT'" @add="handleAddLine" class="mb-6" />
|
||||
|
||||
<!-- Totals -->
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div class="flex justify-end">
|
||||
<div class="w-64 space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Manopera:</span>
|
||||
<span class="font-medium">{{ (order.total_manopera || 0).toFixed(2) }} RON</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-500">Materiale:</span>
|
||||
<span class="font-medium">{{ (order.total_materiale || 0).toFixed(2) }} RON</span>
|
||||
</div>
|
||||
<div class="flex justify-between border-t pt-1 text-base">
|
||||
<span class="font-semibold">Total general:</span>
|
||||
<span class="font-bold">{{ (order.total_general || 0).toFixed(2) }} RON</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="text-center py-12 text-gray-500">
|
||||
Se incarca...
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useOrdersStore } from '../../stores/orders.js'
|
||||
import { onTableChange } from '../../db/database.js'
|
||||
import OrderLineForm from '../../components/orders/OrderLineForm.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const ordersStore = useOrdersStore()
|
||||
|
||||
const order = ref(null)
|
||||
const lines = ref([])
|
||||
|
||||
async function loadOrder() {
|
||||
order.value = await ordersStore.getById(route.params.id)
|
||||
lines.value = await ordersStore.getLines(route.params.id)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOrder()
|
||||
onTableChange('orders', loadOrder)
|
||||
onTableChange('order_lines', async () => {
|
||||
lines.value = await ordersStore.getLines(route.params.id)
|
||||
order.value = await ordersStore.getById(route.params.id)
|
||||
})
|
||||
})
|
||||
|
||||
async function handleAddLine(lineData) {
|
||||
await ordersStore.addLine(route.params.id, lineData)
|
||||
}
|
||||
|
||||
async function handleRemoveLine(lineId) {
|
||||
await ordersStore.removeLine(lineId, route.params.id)
|
||||
}
|
||||
|
||||
async function handleValidate() {
|
||||
await ordersStore.validateOrder(route.params.id)
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
switch (status) {
|
||||
case 'DRAFT': return 'bg-yellow-100 text-yellow-800'
|
||||
case 'VALIDAT': return 'bg-green-100 text-green-800'
|
||||
case 'FACTURAT': return 'bg-blue-100 text-blue-800'
|
||||
default: return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,110 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">Comenzi</h1>
|
||||
<p class="text-gray-500">Lista comenzi - va fi implementat in TASK-006.</p>
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Comenzi</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>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="flex gap-2 mb-4">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
<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">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}`)"
|
||||
>
|
||||
<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">
|
||||
<span
|
||||
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="statusClass(o.status)"
|
||||
>
|
||||
{{ o.status }}
|
||||
</span>
|
||||
</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, watch, onMounted } from 'vue'
|
||||
import { useOrdersStore } from '../../stores/orders.js'
|
||||
import { onTableChange } from '../../db/database.js'
|
||||
|
||||
const ordersStore = useOrdersStore()
|
||||
const orders = ref([])
|
||||
const loading = ref(true)
|
||||
const activeFilter = ref(null)
|
||||
|
||||
const filters = [
|
||||
{ label: 'Toate', value: null },
|
||||
{ label: 'Draft', value: 'DRAFT' },
|
||||
{ label: 'Validate', value: 'VALIDAT' },
|
||||
{ label: 'Facturate', value: 'FACTURAT' },
|
||||
]
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
orders.value = await ordersStore.getAll(activeFilter.value)
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
watch(activeFilter, loadOrders)
|
||||
|
||||
onMounted(() => {
|
||||
loadOrders()
|
||||
onTableChange('orders', loadOrders)
|
||||
})
|
||||
|
||||
function statusClass(status) {
|
||||
switch (status) {
|
||||
case 'DRAFT': return 'bg-yellow-100 text-yellow-800'
|
||||
case 'VALIDAT': return 'bg-green-100 text-green-800'
|
||||
case 'FACTURAT': return 'bg-blue-100 text-blue-800'
|
||||
default: return 'bg-gray-100 text-gray-800'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user