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:
@@ -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>
|
||||
|
||||
44
frontend/src/components/orders/PdfDownloadButton.vue
Normal file
44
frontend/src/components/orders/PdfDownloadButton.vue
Normal 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>
|
||||
34
frontend/src/composables/usePdf.js
Normal file
34
frontend/src/composables/usePdf.js
Normal 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 }
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
|
||||
76
frontend/src/views/orders/InvoicesView.vue
Normal file
76
frontend/src/views/orders/InvoicesView.vue
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user