feat(data-entry): Add unified receipt form with OCR confidence tracking

New unified receipt creation system with:
- UnifiedReceiptForm component with inline OCR preview and confidence indicators
- Compact upload zone with drag-drop and camera support
- TVA and Payment fields with dynamic add/remove
- Supplier dual-field with autocomplete and OCR hint
- Receipt form sections with collapsible auxiliary data

Backend OCR improvements:
- Add confidence_tva and confidence_payment to extraction results
- Update TVA extraction to return confidence scores
- Include TVA (15%) and payment (10%) in overall_confidence calculation

Also includes:
- CSS design system rules documentation
- Port check helper function for service scripts
- Expanded design tokens documentation in CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-01-08 21:48:37 +00:00
parent cc98d6f21f
commit b4a226409c
21 changed files with 4876 additions and 55 deletions

View File

@@ -0,0 +1,871 @@
<template>
<div class="receipt-unified-view">
<!-- Header -->
<div class="view-header">
<div class="header-left">
<Button
icon="pi pi-arrow-left"
severity="secondary"
text
rounded
@click="goBack"
/>
<h1 class="view-title">
<i :class="modeIcon"></i>
{{ modeTitle }}
</h1>
<Tag
v-if="isViewMode && receipt"
:value="getStatusLabel(receipt.status)"
:severity="getStatusSeverity(receipt.status)"
/>
</div>
<div class="header-right">
<!-- Create/Edit mode actions -->
<template v-if="!isViewMode">
<Button
icon="pi pi-save"
label="Salveaza"
:loading="saving"
@click="saveReceipt"
/>
<Button
v-if="isEditMode && receipt?.status === 'draft'"
icon="pi pi-send"
label="Trimite"
severity="success"
:loading="submitting"
@click="submitForReview"
/>
</template>
<!-- View mode actions -->
<template v-else>
<Button
v-if="receipt?.status === 'draft' || receipt?.status === 'rejected'"
icon="pi pi-pencil"
label="Editeaza"
@click="goToEdit"
/>
<Button
v-if="receipt?.status === 'draft'"
icon="pi pi-send"
label="Trimite"
severity="success"
@click="submitReceipt"
:loading="submitting"
/>
<Button
v-if="receipt?.status === 'pending_review'"
icon="pi pi-check"
label="Valideaza"
severity="success"
@click="approveReceipt"
:loading="approving"
/>
<Button
v-if="receipt?.status === 'pending_review'"
icon="pi pi-times"
label="Respinge"
severity="danger"
@click="openRejectDialog"
/>
</template>
</div>
</div>
<!-- Rejection Alert -->
<Message
v-if="receipt?.rejection_reason"
severity="warn"
:closable="false"
class="rejection-message"
>
<strong>Motiv respingere:</strong> {{ receipt.rejection_reason }}
<br>
<small>Respins de {{ receipt.reviewed_by }} la {{ formatDateTime(receipt.reviewed_at) }}</small>
</Message>
<!-- Unified Form -->
<UnifiedReceiptForm
ref="unifiedFormRef"
v-model="form"
:ocr-data="ocrData"
:partners="partners"
:expense-types="expenseTypes"
:supplier-source="supplierSource"
:supplier-warning="supplierWarning.show"
:syncing-suppliers="syncingSuppliers"
:existing-attachments="existingAttachments"
:selected-files="selectedFiles"
:readonly="isViewMode"
@ocr-result="onOCRResult"
@file-selected="onFileSelected"
@ocr-error="onOCRError"
@partner-selected="onPartnerSelected"
@sync-suppliers="syncSuppliers"
@create-supplier="createLocalSupplierFromWarning"
@add-files="onAddFiles"
@remove-file="onRemoveFile"
@remove-attachment="removeExistingAttachment"
@download-attachment="downloadAttachment"
/>
<!-- Validation Banners -->
<div class="validation-banners" v-if="!isViewMode && validationErrors.length > 0">
<Message
v-for="(error, idx) in validationErrors"
:key="idx"
severity="warn"
:closable="false"
>
{{ error }}
</Message>
</div>
<!-- Reject Dialog -->
<Dialog
v-model:visible="showRejectDialog"
header="Respinge Bon"
:modal="true"
:style="{ width: '450px' }"
>
<div class="dialog-content">
<p>Introduceti motivul respingerii:</p>
<Textarea
v-model="rejectReason"
rows="3"
class="w-full"
placeholder="Motiv respingere (minim 5 caractere)..."
/>
</div>
<template #footer>
<Button
label="Anuleaza"
severity="secondary"
@click="showRejectDialog = false"
/>
<Button
label="Respinge"
icon="pi pi-times"
severity="danger"
@click="rejectReceipt"
:loading="rejecting"
:disabled="!rejectReason || rejectReason.length < 5"
/>
</template>
</Dialog>
<!-- Create Supplier Dialog -->
<Dialog
v-model:visible="showCreateSupplierDialog"
header="Creaza Furnizor Local"
:modal="true"
:style="{ width: '400px' }"
>
<div class="dialog-content">
<p>Furnizorul nu a fost gasit. Creati un furnizor local?</p>
<div class="form-field">
<label>Nume</label>
<InputText v-model="pendingSupplierData.name" class="w-full" />
</div>
<div class="form-field">
<label>CUI</label>
<InputText v-model="pendingSupplierData.fiscal_code" class="w-full" disabled />
</div>
<div class="form-field">
<label>Adresa</label>
<InputText v-model="pendingSupplierData.address" class="w-full" />
</div>
</div>
<template #footer>
<Button label="Anuleaza" severity="secondary" @click="showCreateSupplierDialog = false" />
<Button label="Creaza" icon="pi pi-plus" @click="createLocalSupplier" />
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import Message from 'primevue/message'
import Dialog from 'primevue/dialog'
import Textarea from 'primevue/textarea'
import InputText from 'primevue/inputtext'
import { useReceiptsStore } from '@data-entry/stores/receiptsStore'
import { useCompanyStore } from '@data-entry/stores/sharedStores'
import UnifiedReceiptForm from '@data-entry/components/receipts/UnifiedReceiptForm.vue'
import {
getDefaultUnifiedFormState,
legacyToUnifiedForm,
unifiedFormToApiPayload,
apiToUiTva,
apiToUiPayments,
} from '@data-entry/utils/receiptConversions'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const store = useReceiptsStore()
const companyStore = useCompanyStore()
// Mode detection
const isViewMode = computed(() => !!route.params.id && !route.path.endsWith('/edit'))
const isEditMode = computed(() => !!route.params.id && route.path.endsWith('/edit'))
const isCreateMode = computed(() => !route.params.id)
const modeTitle = computed(() => {
if (isViewMode.value) return `Bon #${receipt.value?.id || ''}`
if (isEditMode.value) return 'Editare Bon'
return 'Bon Fiscal Nou'
})
const modeIcon = computed(() => {
if (isViewMode.value) return 'pi pi-receipt'
return 'pi pi-plus-circle'
})
// Form state
const form = ref(getDefaultUnifiedFormState())
const receipt = ref(null)
const unifiedFormRef = ref(null)
// OCR state
const ocrData = ref(null)
// Files state
const selectedFiles = ref([])
const existingAttachments = ref([])
// Loading states
const saving = ref(false)
const submitting = ref(false)
const approving = ref(false)
const rejecting = ref(false)
const syncingSuppliers = ref(false)
// Supplier state
const supplierSource = ref(null)
const supplierWarning = ref({ show: false, cui: '', name: '' })
const showCreateSupplierDialog = ref(false)
const pendingSupplierData = ref({ name: '', fiscal_code: '', address: '' })
// Reject dialog
const showRejectDialog = ref(false)
const rejectReason = ref('')
// Computed
const partners = computed(() => store.partners)
const expenseTypes = computed(() => store.expenseTypes)
const validationErrors = computed(() => {
const errors = []
if (!form.value.amount || form.value.amount <= 0) {
errors.push('Suma totala este obligatorie')
}
if (!form.value.receipt_date) {
errors.push('Data este obligatorie')
}
if (selectedFiles.value.length === 0 && existingAttachments.value.length === 0 && isCreateMode.value) {
errors.push('Atasati cel putin o imagine a bonului')
}
return errors
})
// Status helpers
const getStatusLabel = (status) => {
const labels = {
draft: 'Ciorna',
pending_review: 'In Asteptare',
approved: 'Aprobat',
rejected: 'Respins',
}
return labels[status] || status
}
const getStatusSeverity = (status) => {
const severities = {
draft: 'secondary',
pending_review: 'warning',
approved: 'success',
rejected: 'danger',
}
return severities[status] || 'info'
}
// Lifecycle
onMounted(async () => {
// Load nomenclatures
await store.fetchAllNomenclatures()
// Sync suppliers if empty
if (store.partners.length === 0) {
await syncSuppliers()
}
// Load receipt if edit/view mode
if (isEditMode.value || isViewMode.value) {
await loadReceipt()
} else {
// Set company ID for new receipts
form.value.company_id = companyStore.selectedCompanyId || 1
}
})
// Load existing receipt
const loadReceipt = async () => {
try {
receipt.value = await store.fetchReceiptById(route.params.id)
// Convert to unified form format
form.value = {
receipt_type: receipt.value.receipt_type || 'bon_fiscal',
receipt_date: new Date(receipt.value.receipt_date),
receipt_number: receipt.value.receipt_number || '',
partner_name: receipt.value.partner_name || null,
cui: receipt.value.cui || '',
vendor_address: receipt.value.vendor_address || '',
amount: parseFloat(receipt.value.amount) || null,
tva: apiToUiTva(receipt.value.tva_breakdown),
payments: apiToUiPayments(receipt.value.payment_methods),
expense_type_code: receipt.value.expense_type_code || null,
description: receipt.value.description || '',
ocr_raw_text: receipt.value.ocr_raw_text || '',
items_count: receipt.value.items_count || null,
company_id: receipt.value.company_id,
}
existingAttachments.value = receipt.value.attachments || []
// Set supplier source if CUI matches a known supplier
if (form.value.cui) {
const result = await store.searchSupplier(form.value.cui)
if (result.found) {
supplierSource.value = result.source
}
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-a putut incarca bonul',
life: 5000,
})
router.push('/data-entry')
}
}
// OCR handlers
const onOCRResult = async (data) => {
ocrData.value = data
toast.add({
severity: 'success',
summary: 'OCR Procesare',
detail: 'Datele au fost extrase',
life: 3000,
})
// Auto-apply OCR data to form
if (data.receipt_type) form.value.receipt_type = data.receipt_type
if (data.receipt_date) form.value.receipt_date = new Date(data.receipt_date)
if (data.receipt_number) form.value.receipt_number = data.receipt_number
if (data.amount) form.value.amount = parseFloat(data.amount)
if (data.address) form.value.vendor_address = data.address
if (data.raw_text) form.value.ocr_raw_text = data.raw_text
if (data.items_count) form.value.items_count = data.items_count
// Apply TVA
if (data.tva_entries?.length > 0) {
form.value.tva = apiToUiTva(data.tva_entries)
}
// Apply payments
if (data.payment_methods?.length > 0) {
form.value.payments = apiToUiPayments(data.payment_methods)
}
// Search for supplier by CUI
if (data.cui) {
form.value.cui = data.cui
const result = await store.searchSupplier(data.cui)
if (result.found && result.supplier) {
form.value.partner_name = result.supplier.name
form.value.cui = result.supplier.fiscal_code || data.cui
form.value.vendor_address = result.supplier.address || data.address || ''
supplierSource.value = result.source
supplierWarning.value = { show: false, cui: '', name: '' }
toast.add({
severity: 'success',
summary: 'Furnizor gasit',
detail: `${result.supplier.name} (${result.source})`,
life: 3000,
})
} else {
// Supplier not found - show warning
form.value.partner_name = data.partner_name || ''
supplierSource.value = null
supplierWarning.value = {
show: true,
cui: data.cui,
name: data.partner_name || ''
}
}
} else if (data.partner_name) {
form.value.partner_name = data.partner_name
}
}
const onFileSelected = (file) => {
// Add OCR file to selected files
if (!selectedFiles.value.some(f => f.name === file.name)) {
selectedFiles.value = [file, ...selectedFiles.value]
}
}
const onOCRError = (message) => {
toast.add({
severity: 'error',
summary: 'Eroare OCR',
detail: message,
life: 5000,
})
}
const onPartnerSelected = (partner) => {
if (partner) {
supplierSource.value = partner.source || 'local'
supplierWarning.value = { show: false, cui: '', name: '' }
}
}
// File handlers
const onAddFiles = (files) => {
selectedFiles.value = [...selectedFiles.value, ...files]
}
const onRemoveFile = (index) => {
selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index)
}
const removeExistingAttachment = async (attachmentId) => {
try {
await store.deleteAttachment(attachmentId)
existingAttachments.value = existingAttachments.value.filter(a => a.id !== attachmentId)
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Atasamentul a fost sters',
life: 2000,
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-a putut sterge atasamentul',
life: 5000,
})
}
}
const downloadAttachment = async (attachment) => {
try {
await store.downloadAttachment(attachment.id, attachment.filename)
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-a putut descarca fisierul',
life: 5000,
})
}
}
// Supplier handlers
const syncSuppliers = async () => {
syncingSuppliers.value = true
try {
await store.syncSuppliers()
toast.add({
severity: 'success',
summary: 'Sincronizare',
detail: 'Furnizorii au fost actualizati',
life: 2000,
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Sincronizare esuata',
life: 5000,
})
} finally {
syncingSuppliers.value = false
}
}
const createLocalSupplierFromWarning = () => {
pendingSupplierData.value = {
name: supplierWarning.value.name || form.value.partner_name || '',
fiscal_code: supplierWarning.value.cui || form.value.cui || '',
address: form.value.vendor_address || '',
}
showCreateSupplierDialog.value = true
}
const createLocalSupplier = async () => {
try {
await store.createLocalSupplier(pendingSupplierData.value)
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Furnizor local creat',
life: 2000,
})
showCreateSupplierDialog.value = false
supplierSource.value = 'local'
supplierWarning.value = { show: false, cui: '', name: '' }
// Update form with created supplier
form.value.partner_name = pendingSupplierData.value.name
form.value.cui = pendingSupplierData.value.fiscal_code
form.value.vendor_address = pendingSupplierData.value.address
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-a putut crea furnizorul',
life: 5000,
})
}
}
// Save handlers
const validateForm = () => {
if (!form.value.receipt_date) {
toast.add({
severity: 'error',
summary: 'Validare',
detail: 'Data este obligatorie',
life: 3000,
})
return false
}
if (!form.value.amount || form.value.amount <= 0) {
toast.add({
severity: 'error',
summary: 'Validare',
detail: 'Suma este obligatorie',
life: 3000,
})
return false
}
return true
}
const saveReceipt = async () => {
if (!validateForm()) return
saving.value = true
try {
// Auto-create supplier if warning shown
if (supplierWarning.value.show && form.value.cui) {
try {
await store.createLocalSupplier({
name: form.value.partner_name || `Furnizor ${form.value.cui}`,
fiscal_code: form.value.cui,
address: form.value.vendor_address || null,
})
supplierWarning.value = { show: false, cui: '', name: '' }
supplierSource.value = 'local'
} catch (e) {
console.warn('Auto-create supplier failed:', e)
}
}
// Convert form to API format
const payload = unifiedFormToApiPayload(form.value)
let savedReceipt
if (isEditMode.value) {
savedReceipt = await store.updateReceipt(route.params.id, payload)
} else {
savedReceipt = await store.createReceipt(payload)
}
// Upload new files
for (const file of selectedFiles.value) {
try {
await store.uploadAttachment(savedReceipt.id, file)
} catch (e) {
toast.add({
severity: 'warn',
summary: 'Atentie',
detail: `Nu s-a putut incarca: ${file.name}`,
life: 5000,
})
}
}
toast.add({
severity: 'success',
summary: 'Succes',
detail: isEditMode.value ? 'Bonul a fost actualizat' : 'Bonul a fost creat',
life: 3000,
})
router.push(`/data-entry/${savedReceipt.id}`)
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut salva bonul',
life: 5000,
})
} finally {
saving.value = false
}
}
const submitForReview = async () => {
if (!validateForm()) return
submitting.value = true
try {
await saveReceipt()
const result = await store.submitReceipt(route.params.id)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost trimis spre aprobare',
life: 3000,
})
router.push('/data-entry')
} else {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: result.message,
life: 5000,
})
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut trimite bonul',
life: 5000,
})
} finally {
submitting.value = false
}
}
const submitReceipt = async () => {
submitting.value = true
try {
const result = await store.submitReceipt(route.params.id)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost trimis',
life: 3000,
})
await loadReceipt()
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message,
life: 5000,
})
} finally {
submitting.value = false
}
}
const approveReceipt = async () => {
approving.value = true
try {
const result = await store.approveReceipt(route.params.id)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost aprobat',
life: 3000,
})
await loadReceipt()
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message,
life: 5000,
})
} finally {
approving.value = false
}
}
const openRejectDialog = () => {
rejectReason.value = ''
showRejectDialog.value = true
}
const rejectReceipt = async () => {
rejecting.value = true
try {
const result = await store.rejectReceipt(route.params.id, rejectReason.value)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost respins',
life: 3000,
})
showRejectDialog.value = false
await loadReceipt()
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message,
life: 5000,
})
} finally {
rejecting.value = false
}
}
// Navigation
const goBack = () => {
router.push('/data-entry')
}
const goToEdit = () => {
router.push(`/data-entry/${route.params.id}/edit`)
}
const formatDateTime = (dateStr) => {
if (!dateStr) return ''
return new Date(dateStr).toLocaleString('ro-RO')
}
</script>
<style scoped>
.receipt-unified-view {
padding: 1rem;
max-width: 800px;
margin: 0 auto;
}
/* Header */
.view-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
background: var(--surface-card);
border-radius: 8px;
border: 1px solid var(--surface-border);
}
.header-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.view-title {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-color);
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-right {
display: flex;
gap: 0.5rem;
}
/* Messages */
.rejection-message {
margin-bottom: 1rem;
}
/* Validation */
.validation-banners {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Dialog */
.dialog-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.form-field label {
font-size: 0.85rem;
font-weight: 500;
color: var(--text-color-secondary);
}
/* Responsive */
@media (max-width: 640px) {
.receipt-unified-view {
padding: 0.5rem;
}
.view-header {
flex-direction: column;
gap: 0.75rem;
align-items: stretch;
}
.header-left {
justify-content: flex-start;
}
.header-right {
justify-content: flex-end;
flex-wrap: wrap;
}
}
/* Dark mode */
[data-theme="dark"] .view-header {
background: var(--surface-card);
}
</style>