feat(frontend): Portal public client + PDF download + Facturi view

- DevizPublicView: standalone public page /p/:token (no auth, no layout)
  - Loads order/tenant/lines from backend API
  - Accept/Reject buttons with feedback banners
  - Mobile-first design with service branding
- usePdf composable: fetch PDF blob from backend and trigger browser download
- PdfDownloadButton component: reusable button for deviz/invoice PDF download
- InvoicesView: table with invoice list from wa-sqlite, PDF download per row
- OrderDetailView: added PDF Deviz download button (visible when not DRAFT)
- Router: added /invoices route, portal /p/:token uses layout: 'none'
- App.vue: supports layout: 'none' for standalone pages
- AppLayout: added Facturi link in sidebar nav

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:31:41 +02:00
parent 3a922a50e6
commit efc9545ae6
8 changed files with 371 additions and 5 deletions

View File

@@ -1,5 +1,6 @@
<template>
<component :is="layout">
<router-view v-if="route.meta.layout === 'none'" />
<component v-else :is="layout">
<router-view />
</component>
</template>

View File

@@ -0,0 +1,44 @@
<template>
<button
@click="handleDownload"
:disabled="downloading"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
{{ downloading ? 'Se descarca...' : label }}
</button>
</template>
<script setup>
import { ref } from 'vue'
import { usePdf } from '../../composables/usePdf.js'
const props = defineProps({
type: { type: String, required: true, validator: v => ['deviz', 'invoice'].includes(v) },
orderId: { type: String, default: null },
invoiceId: { type: String, default: null },
nrComanda: { type: String, default: '' },
nrFactura: { type: String, default: '' },
label: { type: String, default: 'PDF' },
})
const { downloadDevizPdf, downloadInvoicePdf } = usePdf()
const downloading = ref(false)
async function handleDownload() {
downloading.value = true
try {
if (props.type === 'deviz') {
await downloadDevizPdf(props.orderId, props.nrComanda)
} else {
await downloadInvoicePdf(props.invoiceId, props.nrFactura)
}
} catch (e) {
alert(e.message)
} finally {
downloading.value = false
}
}
</script>

View File

@@ -0,0 +1,34 @@
const API_URL = import.meta.env.VITE_API_URL || '/api'
export function usePdf() {
function getToken() {
return localStorage.getItem('token')
}
async function downloadPdf(url, filename) {
const token = getToken()
const res = await fetch(`${API_URL}${url}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
if (!res.ok) throw new Error('Eroare la descarcarea PDF-ului')
const blob = await res.blob()
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(objectUrl)
}
async function downloadDevizPdf(orderId, nrComanda) {
await downloadPdf(`/orders/${orderId}/pdf/deviz`, `deviz-${nrComanda || orderId}.pdf`)
}
async function downloadInvoicePdf(invoiceId, nrFactura) {
await downloadPdf(`/invoices/${invoiceId}/pdf`, `factura-${nrFactura || invoiceId}.pdf`)
}
return { downloadDevizPdf, downloadInvoicePdf }
}

View File

@@ -88,6 +88,7 @@ const mobileMenuOpen = ref(false)
const navItems = [
{ path: '/dashboard', label: 'Dashboard' },
{ path: '/orders', label: 'Comenzi' },
{ path: '/invoices', label: 'Facturi' },
{ path: '/vehicles', label: 'Vehicule' },
{ path: '/appointments', label: 'Programari' },
{ path: '/catalog', label: 'Catalog' },

View File

@@ -13,8 +13,9 @@ const router = createRouter({
{ 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 } },
{ path: '/invoices', component: () => import('../views/orders/InvoicesView.vue'), meta: { requiresAuth: true } },
{ path: '/settings', component: () => import('../views/settings/SettingsView.vue'), meta: { requiresAuth: true } },
{ path: '/p/:token', component: () => import('../views/client/DevizPublicView.vue') },
{ path: '/p/:token', component: () => import('../views/client/DevizPublicView.vue'), meta: { layout: 'none' } },
{ path: '/', redirect: '/dashboard' },
],
scrollBehavior: () => ({ top: 0 })

View File

@@ -1,6 +1,207 @@
<template>
<div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Deviz</h1>
<p class="text-gray-500">Portal public client - va fi implementat in TASK-008.</p>
<div class="min-h-screen bg-gray-100">
<!-- Header -->
<header class="bg-white border-b border-gray-200">
<div class="max-w-3xl mx-auto px-4 py-4">
<h1 class="text-xl font-bold text-gray-900">{{ tenant?.nume || 'Service Auto' }}</h1>
<p v-if="tenant?.telefon" class="text-sm text-gray-500">Tel: {{ tenant.telefon }}</p>
</div>
</header>
<!-- Loading -->
<div v-if="loading" class="max-w-3xl mx-auto px-4 py-12 text-center text-gray-500">
Se incarca devizul...
</div>
<!-- Error -->
<div v-else-if="error" class="max-w-3xl mx-auto px-4 py-12 text-center">
<p class="text-red-600 text-lg">{{ error }}</p>
</div>
<!-- Content -->
<div v-else-if="order" class="max-w-3xl mx-auto px-4 py-6 space-y-6">
<!-- Status banner -->
<div
v-if="responded"
class="rounded-lg p-4 text-center"
:class="responded === 'accepted' ? 'bg-green-50 text-green-800' : 'bg-red-50 text-red-800'"
>
{{ responded === 'accepted' ? 'Devizul a fost acceptat. Va multumim!' : 'Devizul a fost refuzat.' }}
</div>
<!-- Order info card -->
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">Deviz {{ order.nr_comanda }}</h2>
<span
class="px-2 py-0.5 rounded-full text-xs font-medium"
:class="statusClass(order.status)"
>
{{ order.status }}
</span>
</div>
<div class="grid grid-cols-2 gap-3 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">Data</p>
<p class="font-medium">{{ formatDate(order.data_comanda) }}</p>
</div>
<div>
<p class="text-gray-500">KM intrare</p>
<p class="font-medium">{{ order.km_intrare || '-' }}</p>
</div>
</div>
</div>
<!-- Lines -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div class="px-4 py-3 border-b border-gray-200">
<h3 class="font-semibold text-gray-900">Detalii lucrari</h3>
</div>
<div v-if="!lines.length" class="p-4 text-sm text-gray-500">Nicio linie.</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">Descriere</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase hidden sm:table-cell">Detalii</th>
<th class="px-4 py-2 text-right text-xs font-medium text-gray-500 uppercase">Total (RON)</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="l in lines" :key="l.id">
<td class="px-4 py-3 text-sm">
<span
class="inline-block mr-1.5 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>
{{ l.descriere }}
</td>
<td class="px-4 py-3 text-sm text-right text-gray-500 hidden sm:table-cell">
{{ l.tip === 'manopera' ? `${l.ore}h x ${(l.pret_ora || 0).toFixed(2)}` : `${l.cantitate} ${l.um} x ${(l.pret_unitar || 0).toFixed(2)}` }}
</td>
<td class="px-4 py-3 text-sm text-right font-medium">{{ (l.total || 0).toFixed(2) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Totals -->
<div class="bg-white rounded-lg shadow p-4">
<div class="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-2 text-lg">
<span class="font-semibold">Total general:</span>
<span class="font-bold">{{ (order.total_general || 0).toFixed(2) }} RON</span>
</div>
</div>
</div>
<!-- Accept/Reject buttons -->
<div v-if="!responded && order.status !== 'FACTURAT'" class="flex gap-3">
<button
@click="handleAccept"
:disabled="submitting"
class="flex-1 py-3 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 disabled:opacity-50"
>
{{ submitting ? 'Se proceseaza...' : 'Accept devizul' }}
</button>
<button
@click="handleReject"
:disabled="submitting"
class="flex-1 py-3 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 disabled:opacity-50"
>
{{ submitting ? 'Se proceseaza...' : 'Refuz devizul' }}
</button>
</div>
<!-- Footer -->
<p class="text-center text-xs text-gray-400 pb-4">
Generat de ROA AUTO
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
const API_URL = import.meta.env.VITE_API_URL || '/api'
const route = useRoute()
const order = ref(null)
const tenant = ref(null)
const lines = ref([])
const loading = ref(true)
const error = ref('')
const submitting = ref(false)
const responded = ref(null)
onMounted(async () => {
try {
const res = await fetch(`${API_URL}/p/${route.params.token}`)
if (!res.ok) {
error.value = res.status === 404 ? 'Devizul nu a fost gasit.' : 'Eroare la incarcarea devizului.'
return
}
const data = await res.json()
order.value = data.order
tenant.value = data.tenant
lines.value = data.lines || []
} catch {
error.value = 'Eroare de conexiune.'
} finally {
loading.value = false
}
})
async function handleAccept() {
submitting.value = true
try {
const res = await fetch(`${API_URL}/p/${route.params.token}/accept`, { method: 'POST' })
if (res.ok) responded.value = 'accepted'
} finally {
submitting.value = false
}
}
async function handleReject() {
submitting.value = true
try {
const res = await fetch(`${API_URL}/p/${route.params.token}/reject`, { method: 'POST' })
if (res.ok) responded.value = 'rejected'
} finally {
submitting.value = false
}
}
function formatDate(d) {
if (!d) return '-'
try { return new Date(d).toLocaleDateString('ro-RO') } catch { return d }
}
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>

View File

@@ -0,0 +1,76 @@
<template>
<div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Facturi</h1>
<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="invoices.length === 0" class="p-8 text-center text-gray-500">
Nicio factura 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. factura</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>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Total</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">PDF</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="inv in invoices" :key="inv.id" class="hover:bg-gray-50">
<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 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>
<td class="px-4 py-3 text-sm text-right font-medium text-gray-900">
{{ (inv.total_general || 0).toFixed(2) }} RON
</td>
<td class="px-4 py-3 text-right">
<PdfDownloadButton
type="invoice"
:invoice-id="inv.id"
:nr-factura="inv.nr_factura"
label="Descarca"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { execSQL } from '../../db/database.js'
import { onTableChange } from '../../db/database.js'
import { useAuthStore } from '../../stores/auth.js'
import PdfDownloadButton from '../../components/orders/PdfDownloadButton.vue'
const auth = useAuthStore()
const invoices = ref([])
const loading = ref(true)
async function loadInvoices() {
loading.value = true
invoices.value = await execSQL(
`SELECT * FROM invoices WHERE tenant_id = ? ORDER BY data_factura DESC`,
[auth.tenantId]
)
loading.value = false
}
onMounted(() => {
loadInvoices()
onTableChange('invoices', loadInvoices)
})
function formatDate(d) {
if (!d) return '-'
try { return new Date(d).toLocaleDateString('ro-RO') } catch { return d }
}
</script>

View File

@@ -6,6 +6,13 @@
<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"
@@ -132,6 +139,7 @@ import { useRoute } from 'vue-router'
import { useOrdersStore } from '../../stores/orders.js'
import { onTableChange } from '../../db/database.js'
import OrderLineForm from '../../components/orders/OrderLineForm.vue'
import PdfDownloadButton from '../../components/orders/PdfDownloadButton.vue'
const route = useRoute()
const ordersStore = useOrdersStore()