feat(frontend): wa-sqlite memory-only + Factureaza + Vite polling + Appointments + Catalog
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import SQLiteESMFactory from '@journeyapps/wa-sqlite/dist/wa-sqlite-async.mjs'
|
||||
import { IDBBatchAtomicVFS } from '@journeyapps/wa-sqlite/src/examples/IDBBatchAtomicVFS.js'
|
||||
import SQLiteESMFactory from '@journeyapps/wa-sqlite/dist/wa-sqlite.mjs'
|
||||
import * as SQLite from '@journeyapps/wa-sqlite'
|
||||
import { SCHEMA_SQL } from './schema.js'
|
||||
|
||||
@@ -7,14 +6,19 @@ let db = null
|
||||
let sqlite3 = null
|
||||
const tableListeners = new Map()
|
||||
|
||||
export async function initDatabase() {
|
||||
if (db) return db
|
||||
let initPromise = null
|
||||
|
||||
export function initDatabase() {
|
||||
if (initPromise) return initPromise
|
||||
initPromise = _init()
|
||||
return initPromise
|
||||
}
|
||||
|
||||
async function _init() {
|
||||
const module = await SQLiteESMFactory()
|
||||
sqlite3 = SQLite.Factory(module)
|
||||
const vfs = await IDBBatchAtomicVFS.create('roaauto', module)
|
||||
sqlite3.vfs_register(vfs, true)
|
||||
db = await sqlite3.open_v2('roaauto.db',
|
||||
SQLite.SQLITE_OPEN_READWRITE | SQLite.SQLITE_OPEN_CREATE, 'roaauto')
|
||||
db = await sqlite3.open_v2(':memory:')
|
||||
|
||||
for (const sql of SCHEMA_SQL.split(';').filter(s => s.trim())) {
|
||||
await sqlite3.exec(db, sql)
|
||||
}
|
||||
@@ -34,10 +38,20 @@ export function onTableChange(table, cb) {
|
||||
export async function execSQL(sql, params = []) {
|
||||
if (!db) throw new Error('DB not initialized')
|
||||
const results = []
|
||||
await sqlite3.exec(db, sql, (row, cols) => {
|
||||
const obj = {}
|
||||
cols.forEach((c, i) => { obj[c] = row[i] })
|
||||
results.push(obj)
|
||||
})
|
||||
|
||||
if (params.length === 0) {
|
||||
await sqlite3.exec(db, sql, (row, cols) => {
|
||||
results.push(Object.fromEntries(cols.map((c, i) => [c, row[i]])))
|
||||
})
|
||||
} else {
|
||||
for await (const stmt of sqlite3.statements(db, sql)) {
|
||||
sqlite3.bind_collection(stmt, params)
|
||||
const cols = sqlite3.column_names(stmt)
|
||||
while ((await sqlite3.step(stmt)) === SQLite.SQLITE_ROW) {
|
||||
results.push(Object.fromEntries(cols.map((c, i) => [c, sqlite3.column(stmt, i)])))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
@@ -14,8 +14,13 @@ app.use(pinia)
|
||||
app.use(router)
|
||||
|
||||
const auth = useAuthStore()
|
||||
if (auth.isAuthenticated) {
|
||||
initDatabase().then(() => syncEngine.fullSync())
|
||||
|
||||
async function bootstrap() {
|
||||
if (auth.isAuthenticated) {
|
||||
await initDatabase()
|
||||
syncEngine.fullSync().catch(() => {})
|
||||
}
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
bootstrap()
|
||||
|
||||
@@ -1,6 +1,197 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">Programari</h1>
|
||||
<p class="text-gray-500">Programari - 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">Programari</h1>
|
||||
<button
|
||||
@click="showForm = !showForm"
|
||||
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
|
||||
>
|
||||
{{ showForm ? 'Anuleaza' : '+ Programare noua' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- New appointment 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">Programare noua</h2>
|
||||
<form @submit.prevent="handleCreate" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Nume client</label>
|
||||
<input v-model="form.client_nume" type="text" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="Ion Popescu" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Telefon client</label>
|
||||
<input v-model="form.client_telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="07xx xxx xxx" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Data si ora</label>
|
||||
<input v-model="form.data_ora" type="datetime-local" required 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">Durata (minute)</label>
|
||||
<input v-model.number="form.durata_minute" type="number" min="15" step="15" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Observatii</label>
|
||||
<textarea v-model="form.observatii" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="Detalii programare..."></textarea>
|
||||
</div>
|
||||
<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...' : 'Salveaza programarea' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Appointments 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="appointments.length === 0" class="p-8 text-center text-gray-500">
|
||||
Nicio programare 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">Data / Ora</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Client</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Telefon</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Durata</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-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Observatii</th>
|
||||
<th class="px-4 py-3 w-40"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr v-for="a in appointments" :key="a.id" class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ formatDataOra(a.data_ora) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700">{{ a.client_nume || '-' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">{{ a.client_telefon || '-' }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">{{ a.durata_minute || 60 }} min</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="statusClass(a.status)"
|
||||
>
|
||||
{{ a.status || 'PROGRAMAT' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500 hidden md:table-cell max-w-xs truncate">{{ a.observatii || '-' }}</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="a.status !== 'FINALIZAT' && a.status !== 'ANULAT'"
|
||||
@click="handleStatusChange(a.id, 'FINALIZAT')"
|
||||
class="px-2 py-1 bg-green-100 text-green-700 text-xs rounded hover:bg-green-200"
|
||||
>
|
||||
Finalizeaza
|
||||
</button>
|
||||
<button
|
||||
v-if="a.status !== 'ANULAT' && a.status !== 'FINALIZAT'"
|
||||
@click="handleStatusChange(a.id, 'ANULAT')"
|
||||
class="px-2 py-1 bg-red-100 text-red-700 text-xs rounded hover:bg-red-200"
|
||||
>
|
||||
Anuleaza
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { execSQL, notifyTableChanged, onTableChange } from '../../db/database.js'
|
||||
import { syncEngine } from '../../db/sync.js'
|
||||
import { useAuthStore } from '../../stores/auth.js'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const appointments = ref([])
|
||||
const loading = ref(true)
|
||||
const showForm = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
client_nume: '',
|
||||
client_telefon: '',
|
||||
data_ora: '',
|
||||
durata_minute: 60,
|
||||
observatii: '',
|
||||
})
|
||||
|
||||
async function loadAppointments() {
|
||||
loading.value = true
|
||||
const rows = await execSQL(
|
||||
`SELECT * FROM appointments WHERE tenant_id=? ORDER BY data_ora DESC`,
|
||||
[auth.tenantId]
|
||||
)
|
||||
appointments.value = rows
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
saving.value = true
|
||||
try {
|
||||
const id = crypto.randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
const data = {
|
||||
id,
|
||||
tenant_id: auth.tenantId,
|
||||
client_nume: form.client_nume,
|
||||
client_telefon: form.client_telefon,
|
||||
data_ora: form.data_ora,
|
||||
durata_minute: form.durata_minute,
|
||||
observatii: form.observatii,
|
||||
status: 'PROGRAMAT',
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
await execSQL(
|
||||
`INSERT INTO appointments (id, tenant_id, client_nume, client_telefon, data_ora, durata_minute, observatii, status, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
[data.id, data.tenant_id, data.client_nume, data.client_telefon, data.data_ora, data.durata_minute, data.observatii, data.status, data.created_at, data.updated_at]
|
||||
)
|
||||
notifyTableChanged('appointments')
|
||||
syncEngine.addToQueue('appointments', id, 'INSERT', data)
|
||||
showForm.value = false
|
||||
Object.assign(form, { client_nume: '', client_telefon: '', data_ora: '', durata_minute: 60, observatii: '' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(id, newStatus) {
|
||||
const now = new Date().toISOString()
|
||||
await execSQL('UPDATE appointments SET status=?, updated_at=? WHERE id=?', [newStatus, now, id])
|
||||
notifyTableChanged('appointments')
|
||||
syncEngine.addToQueue('appointments', id, 'UPDATE', { status: newStatus, updated_at: now })
|
||||
}
|
||||
|
||||
function formatDataOra(dataOra) {
|
||||
if (!dataOra) return '-'
|
||||
try {
|
||||
const d = new Date(dataOra)
|
||||
return d.toLocaleString('ro-RO', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
|
||||
} catch {
|
||||
return dataOra
|
||||
}
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
switch (status) {
|
||||
case 'PROGRAMAT': return 'bg-blue-100 text-blue-700'
|
||||
case 'FINALIZAT': return 'bg-green-100 text-green-700'
|
||||
case 'ANULAT': return 'bg-red-100 text-red-700'
|
||||
default: return 'bg-gray-100 text-gray-700'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAppointments()
|
||||
onTableChange('appointments', loadAppointments)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,408 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-6">Catalog</h1>
|
||||
<p class="text-gray-500">Catalog - va fi implementat in TASK-006.</p>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex border-b border-gray-200 mb-6">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@click="activeTab = tab.key"
|
||||
class="px-4 py-2 text-sm font-medium border-b-2 -mb-px transition-colors"
|
||||
:class="activeTab === tab.key
|
||||
? 'border-blue-600 text-blue-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Marci -->
|
||||
<div v-if="activeTab === 'marci'">
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden mb-6">
|
||||
<div v-if="marci.length === 0" class="p-8 text-center text-gray-500">Nicio marca 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">Denumire</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Activ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr v-for="m in marci" :key="m.id" class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm text-gray-900">{{ m.denumire }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<button
|
||||
@click="toggleMarcaActiv(m)"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium transition-colors"
|
||||
:class="m.activ ? 'bg-green-100 text-green-700 hover:bg-green-200' : 'bg-gray-100 text-gray-500 hover:bg-gray-200'"
|
||||
>
|
||||
{{ m.activ ? 'Activ' : 'Inactiv' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Add Marca form -->
|
||||
<div class="bg-white rounded-lg shadow p-4 max-w-md">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">Adauga marca</h3>
|
||||
<form @submit.prevent="addMarca" class="flex gap-2">
|
||||
<input v-model="newMarca" type="text" required placeholder="Denumire marca" class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
<button type="submit" :disabled="savingMarca" class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||
{{ savingMarca ? '...' : 'Adauga' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Modele -->
|
||||
<div v-if="activeTab === 'modele'">
|
||||
<div class="mb-4">
|
||||
<select v-model="filterMarcaId" @change="loadModele" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<option :value="null">-- Toate marcile --</option>
|
||||
<option v-for="m in marci" :key="m.id" :value="m.id">{{ m.denumire }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden mb-6">
|
||||
<div v-if="modele.length === 0" class="p-8 text-center text-gray-500">Niciun model 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">Marca</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Model</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr v-for="m in modele" :key="m.id" class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{ m.marca_denumire }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900">{{ m.denumire }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Add Model form -->
|
||||
<div class="bg-white rounded-lg shadow p-4 max-w-md">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">Adauga model</h3>
|
||||
<form @submit.prevent="addModel" class="space-y-2">
|
||||
<select v-model="newModel.marca_id" required class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
|
||||
<option :value="null">-- Selecteaza marca --</option>
|
||||
<option v-for="m in marci" :key="m.id" :value="m.id">{{ m.denumire }}</option>
|
||||
</select>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="newModel.denumire" type="text" required placeholder="Denumire model" class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
<button type="submit" :disabled="savingModel" class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||
{{ savingModel ? '...' : 'Adauga' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Ansamble -->
|
||||
<div v-if="activeTab === 'ansamble'">
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden mb-6">
|
||||
<div v-if="ansamble.length === 0" class="p-8 text-center text-gray-500">Niciun ansamblu 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr v-for="a in ansamble" :key="a.id" class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm text-gray-900">{{ a.denumire }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Add Ansamblu form -->
|
||||
<div class="bg-white rounded-lg shadow p-4 max-w-md">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">Adauga ansamblu</h3>
|
||||
<form @submit.prevent="addAnsamblu" class="flex gap-2">
|
||||
<input v-model="newAnsamblu" type="text" required placeholder="Denumire ansamblu" class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
<button type="submit" :disabled="savingAnsamblu" class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||
{{ savingAnsamblu ? '...' : 'Adauga' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Preturi -->
|
||||
<div v-if="activeTab === 'preturi'">
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden mb-6">
|
||||
<div v-if="preturi.length === 0" class="p-8 text-center text-gray-500">Niciun pret 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-right text-xs font-medium text-gray-500 uppercase">Pret</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">UM</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr v-for="p in preturi" :key="p.id" class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm text-gray-900">{{ p.denumire }}</td>
|
||||
<td class="px-4 py-3 text-sm text-right font-medium text-gray-900">{{ (p.pret || 0).toFixed(2) }}</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-500">{{ p.um || 'ora' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Add Pret 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 pret</h3>
|
||||
<form @submit.prevent="addPret" class="grid grid-cols-3 gap-2">
|
||||
<input v-model="newPret.denumire" type="text" required placeholder="Denumire" class="col-span-1 px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
<input v-model.number="newPret.pret" type="number" step="0.01" min="0" required placeholder="Pret" class="px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
<div class="flex gap-2">
|
||||
<input v-model="newPret.um" type="text" placeholder="UM (ora)" class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
<button type="submit" :disabled="savingPret" class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||
{{ savingPret ? '...' : 'Adauga' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab: Tipuri deviz -->
|
||||
<div v-if="activeTab === 'tipuri_deviz'">
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden mb-6">
|
||||
<div v-if="tipuriDeviz.length === 0" class="p-8 text-center text-gray-500">Niciun tip de deviz 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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr v-for="t in tipuriDeviz" :key="t.id" class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 text-sm text-gray-900">{{ t.denumire }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Add Tip deviz form -->
|
||||
<div class="bg-white rounded-lg shadow p-4 max-w-md">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">Adauga tip deviz</h3>
|
||||
<form @submit.prevent="addTipDeviz" class="flex gap-2">
|
||||
<input v-model="newTipDeviz" type="text" required placeholder="Denumire tip deviz" class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||
<button type="submit" :disabled="savingTipDeviz" class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50">
|
||||
{{ savingTipDeviz ? '...' : 'Adauga' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, watch } from 'vue'
|
||||
import { execSQL, notifyTableChanged, onTableChange } from '../../db/database.js'
|
||||
import { syncEngine } from '../../db/sync.js'
|
||||
import { useAuthStore } from '../../stores/auth.js'
|
||||
|
||||
const auth = useAuthStore()
|
||||
|
||||
const tabs = [
|
||||
{ key: 'marci', label: 'Marci' },
|
||||
{ key: 'modele', label: 'Modele' },
|
||||
{ key: 'ansamble', label: 'Ansamble' },
|
||||
{ key: 'preturi', label: 'Preturi' },
|
||||
{ key: 'tipuri_deviz', label: 'Tipuri deviz' },
|
||||
]
|
||||
const activeTab = ref('marci')
|
||||
|
||||
// ---- Marci ----
|
||||
const marci = ref([])
|
||||
const newMarca = ref('')
|
||||
const savingMarca = ref(false)
|
||||
|
||||
async function loadMarci() {
|
||||
marci.value = await execSQL(
|
||||
`SELECT * FROM catalog_marci WHERE tenant_id=? ORDER BY denumire`,
|
||||
[auth.tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
async function addMarca() {
|
||||
savingMarca.value = true
|
||||
try {
|
||||
const id = crypto.randomUUID()
|
||||
const now = new Date().toISOString()
|
||||
const data = { id, tenant_id: auth.tenantId, denumire: newMarca.value, activ: 1 }
|
||||
await execSQL(
|
||||
`INSERT INTO catalog_marci (id, tenant_id, denumire, activ) VALUES (?,?,?,?)`,
|
||||
[id, auth.tenantId, newMarca.value, 1]
|
||||
)
|
||||
notifyTableChanged('catalog_marci')
|
||||
syncEngine.addToQueue('catalog_marci', id, 'INSERT', data)
|
||||
newMarca.value = ''
|
||||
} finally {
|
||||
savingMarca.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleMarcaActiv(marca) {
|
||||
const newActiv = marca.activ ? 0 : 1
|
||||
const now = new Date().toISOString()
|
||||
await execSQL(`UPDATE catalog_marci SET activ=? WHERE id=?`, [newActiv, marca.id])
|
||||
notifyTableChanged('catalog_marci')
|
||||
syncEngine.addToQueue('catalog_marci', marca.id, 'UPDATE', { activ: newActiv })
|
||||
}
|
||||
|
||||
// ---- Modele ----
|
||||
const modele = ref([])
|
||||
const filterMarcaId = ref(null)
|
||||
const newModel = reactive({ marca_id: null, denumire: '' })
|
||||
const savingModel = ref(false)
|
||||
|
||||
async function loadModele() {
|
||||
if (filterMarcaId.value) {
|
||||
modele.value = await execSQL(
|
||||
`SELECT cm.*, ma.denumire AS marca_denumire
|
||||
FROM catalog_modele cm
|
||||
JOIN catalog_marci ma ON ma.id = cm.marca_id
|
||||
WHERE cm.marca_id=?
|
||||
ORDER BY cm.denumire`,
|
||||
[filterMarcaId.value]
|
||||
)
|
||||
} else {
|
||||
modele.value = await execSQL(
|
||||
`SELECT cm.*, ma.denumire AS marca_denumire
|
||||
FROM catalog_modele cm
|
||||
JOIN catalog_marci ma ON ma.id = cm.marca_id
|
||||
WHERE ma.tenant_id=?
|
||||
ORDER BY ma.denumire, cm.denumire`,
|
||||
[auth.tenantId]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function addModel() {
|
||||
if (!newModel.marca_id) return
|
||||
savingModel.value = true
|
||||
try {
|
||||
const id = crypto.randomUUID()
|
||||
const data = { id, marca_id: newModel.marca_id, denumire: newModel.denumire }
|
||||
await execSQL(
|
||||
`INSERT INTO catalog_modele (id, marca_id, denumire) VALUES (?,?,?)`,
|
||||
[id, newModel.marca_id, newModel.denumire]
|
||||
)
|
||||
notifyTableChanged('catalog_modele')
|
||||
syncEngine.addToQueue('catalog_modele', id, 'INSERT', data)
|
||||
newModel.marca_id = null
|
||||
newModel.denumire = ''
|
||||
} finally {
|
||||
savingModel.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Ansamble ----
|
||||
const ansamble = ref([])
|
||||
const newAnsamblu = ref('')
|
||||
const savingAnsamblu = ref(false)
|
||||
|
||||
async function loadAnsamble() {
|
||||
ansamble.value = await execSQL(
|
||||
`SELECT * FROM catalog_ansamble WHERE tenant_id=? ORDER BY denumire`,
|
||||
[auth.tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
async function addAnsamblu() {
|
||||
savingAnsamblu.value = true
|
||||
try {
|
||||
const id = crypto.randomUUID()
|
||||
const data = { id, tenant_id: auth.tenantId, denumire: newAnsamblu.value }
|
||||
await execSQL(
|
||||
`INSERT INTO catalog_ansamble (id, tenant_id, denumire) VALUES (?,?,?)`,
|
||||
[id, auth.tenantId, newAnsamblu.value]
|
||||
)
|
||||
notifyTableChanged('catalog_ansamble')
|
||||
syncEngine.addToQueue('catalog_ansamble', id, 'INSERT', data)
|
||||
newAnsamblu.value = ''
|
||||
} finally {
|
||||
savingAnsamblu.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Preturi ----
|
||||
const preturi = ref([])
|
||||
const newPret = reactive({ denumire: '', pret: null, um: 'ora' })
|
||||
const savingPret = ref(false)
|
||||
|
||||
async function loadPreturi() {
|
||||
preturi.value = await execSQL(
|
||||
`SELECT * FROM catalog_preturi WHERE tenant_id=? ORDER BY denumire`,
|
||||
[auth.tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
async function addPret() {
|
||||
savingPret.value = true
|
||||
try {
|
||||
const id = crypto.randomUUID()
|
||||
const um = newPret.um || 'ora'
|
||||
const data = { id, tenant_id: auth.tenantId, denumire: newPret.denumire, pret: newPret.pret, um }
|
||||
await execSQL(
|
||||
`INSERT INTO catalog_preturi (id, tenant_id, denumire, pret, um) VALUES (?,?,?,?,?)`,
|
||||
[id, auth.tenantId, newPret.denumire, newPret.pret, um]
|
||||
)
|
||||
notifyTableChanged('catalog_preturi')
|
||||
syncEngine.addToQueue('catalog_preturi', id, 'INSERT', data)
|
||||
Object.assign(newPret, { denumire: '', pret: null, um: 'ora' })
|
||||
} finally {
|
||||
savingPret.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tipuri deviz ----
|
||||
const tipuriDeviz = ref([])
|
||||
const newTipDeviz = ref('')
|
||||
const savingTipDeviz = ref(false)
|
||||
|
||||
async function loadTipuriDeviz() {
|
||||
tipuriDeviz.value = await execSQL(
|
||||
`SELECT * FROM catalog_tipuri_deviz WHERE tenant_id=? ORDER BY denumire`,
|
||||
[auth.tenantId]
|
||||
)
|
||||
}
|
||||
|
||||
async function addTipDeviz() {
|
||||
savingTipDeviz.value = true
|
||||
try {
|
||||
const id = crypto.randomUUID()
|
||||
const data = { id, tenant_id: auth.tenantId, denumire: newTipDeviz.value }
|
||||
await execSQL(
|
||||
`INSERT INTO catalog_tipuri_deviz (id, tenant_id, denumire) VALUES (?,?,?)`,
|
||||
[id, auth.tenantId, newTipDeviz.value]
|
||||
)
|
||||
notifyTableChanged('catalog_tipuri_deviz')
|
||||
syncEngine.addToQueue('catalog_tipuri_deviz', id, 'INSERT', data)
|
||||
newTipDeviz.value = ''
|
||||
} finally {
|
||||
savingTipDeviz.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Register table change listeners
|
||||
onTableChange('catalog_marci', loadMarci)
|
||||
onTableChange('catalog_modele', loadModele)
|
||||
onTableChange('catalog_ansamble', loadAnsamble)
|
||||
onTableChange('catalog_preturi', loadPreturi)
|
||||
onTableChange('catalog_tipuri_deviz', loadTipuriDeviz)
|
||||
|
||||
// Load data for tab when switching
|
||||
watch(activeTab, (tab) => {
|
||||
if (tab === 'marci') loadMarci()
|
||||
else if (tab === 'modele') { loadMarci(); loadModele() }
|
||||
else if (tab === 'ansamble') loadAnsamble()
|
||||
else if (tab === 'preturi') loadPreturi()
|
||||
else if (tab === 'tipuri_deviz') loadTipuriDeviz()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
loadMarci()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -20,6 +20,14 @@
|
||||
>
|
||||
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>
|
||||
<span
|
||||
class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium"
|
||||
:class="statusClass(order.status)"
|
||||
@@ -137,15 +145,19 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useOrdersStore } from '../../stores/orders.js'
|
||||
import { onTableChange } from '../../db/database.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'
|
||||
|
||||
const route = useRoute()
|
||||
const ordersStore = useOrdersStore()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const order = ref(null)
|
||||
const lines = ref([])
|
||||
const facturand = ref(false)
|
||||
|
||||
async function loadOrder() {
|
||||
order.value = await ordersStore.getById(route.params.id)
|
||||
@@ -173,6 +185,31 @@ async function handleValidate() {
|
||||
await ordersStore.validateOrder(route.params.id)
|
||||
}
|
||||
|
||||
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)}`
|
||||
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,
|
||||
(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]
|
||||
)
|
||||
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 })
|
||||
} finally {
|
||||
facturand.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
switch (status) {
|
||||
case 'DRAFT': return 'bg-yellow-100 text-yellow-800'
|
||||
|
||||
@@ -42,6 +42,10 @@ export default defineConfig({
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
watch: {
|
||||
usePolling: true,
|
||||
interval: 1000,
|
||||
},
|
||||
headers: {
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
|
||||
Reference in New Issue
Block a user