diff --git a/data-entry-app/frontend/src/components/ocr/OCRPreview.vue b/data-entry-app/frontend/src/components/ocr/OCRPreview.vue index c90c68b..1cac08d 100644 --- a/data-entry-app/frontend/src/components/ocr/OCRPreview.vue +++ b/data-entry-app/frontend/src/components/ocr/OCRPreview.vue @@ -61,13 +61,25 @@ - -
+ +
TOTAL
- {{ formatAmount(data.amount) }} LEI - +
+ {{ formatAmount(data.amount) }} LEI + +
+ +
+ +
{{ data.items_count }} articole @@ -75,25 +87,6 @@
- -
-
PLATA
-
-
- -
-
- - Sugestie: {{ getSuggestedPaymentLabel(data.suggested_payment_mode) }} -
-
-
-
TVA
@@ -299,30 +292,40 @@ const formatProcessingTime = (ms) => { padding: 0.75rem 1rem; } -/* Section-based layout (bon fiscal style) */ +/* Section-based layout (bon fiscal style) - clearer separation */ .ocr-section { - padding: 0.6rem 0; - border-bottom: 1px solid #d1fae5; + padding: 0.75rem; + margin-bottom: 0.5rem; + background: rgba(255, 255, 255, 0.5); + border-radius: 8px; + border: 1px solid #bbf7d0; } .ocr-section:last-of-type { - border-bottom: none; + margin-bottom: 0; } .ocr-section-title { font-size: 0.7rem; - font-weight: 600; + font-weight: 700; color: #166534; text-transform: uppercase; - letter-spacing: 0.05em; - margin-bottom: 0.35rem; - opacity: 0.8; + letter-spacing: 0.08em; + margin-bottom: 0.5rem; + padding-bottom: 0.35rem; + border-bottom: 1px dashed #86efac; } .ocr-section-content { color: #1e293b; } +/* TOTAL section - prominent styling */ +.ocr-section-total { + background: linear-gradient(135deg, rgba(220, 252, 231, 0.8) 0%, rgba(187, 247, 208, 0.6) 100%); + border: 2px solid #86efac; +} + /* FURNIZOR section */ .vendor-name { font-weight: 600; @@ -376,6 +379,13 @@ const formatProcessingTime = (ms) => { border: 2px solid #86efac; border-radius: 8px; padding: 0.75rem 1rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.total-main { display: flex; align-items: center; justify-content: center; @@ -388,6 +398,21 @@ const formatProcessingTime = (ms) => { color: #166534; } +/* Payment methods inside total box */ +.payment-methods-inline { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 0.5rem; + padding-top: 0.5rem; + border-top: 1px dashed #86efac; + width: 100%; +} + +.payment-tag { + font-size: 0.8rem; +} + .items-count { text-align: center; font-size: 0.8rem; @@ -395,29 +420,6 @@ const formatProcessingTime = (ms) => { margin-top: 0.35rem; } -/* PLATA section */ -.ocr-payment-tags { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; -} - -.suggested-payment-mode { - display: flex; - align-items: center; - gap: 0.5rem; - margin-top: 0.5rem; - padding: 0.4rem 0.6rem; - background: #fef3c7; - border-radius: 6px; - font-size: 0.8rem; - color: #92400e; -} - -.suggested-payment-mode .pi-lightbulb { - color: #f59e0b; -} - /* TVA section */ .ocr-tva-table { display: flex; diff --git a/data-entry-app/frontend/src/components/ocr/OCRUploadZone.vue b/data-entry-app/frontend/src/components/ocr/OCRUploadZone.vue index 3ee5401..e027932 100644 --- a/data-entry-app/frontend/src/components/ocr/OCRUploadZone.vue +++ b/data-entry-app/frontend/src/components/ocr/OCRUploadZone.vue @@ -26,19 +26,19 @@
- +

{{ selectedFile.name }}

{{ formatFileSize(selectedFile.size) }}

@@ -187,8 +183,8 @@ defineExpose({ reset, processOCR }) .upload-dropzone { border: 2px dashed #cbd5e1; - border-radius: 12px; - padding: 2rem; + border-radius: 10px; + padding: 1rem 1.25rem; text-align: center; cursor: pointer; transition: all 0.3s ease; @@ -215,63 +211,52 @@ defineExpose({ reset, processOCR }) display: none; } -/* Empty state */ +/* Empty state - compact */ .empty-state { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.5rem; -} - -.main-text { - font-size: 1rem; - color: #475569; - margin: 0.5rem 0; -} - -.sub-text { - font-size: 0.85rem; - color: #94a3b8; - margin: 0; -} - -.ocr-hint { - display: flex; - align-items: center; - gap: 0.5rem; - font-size: 0.85rem; - color: #667eea; - margin-top: 0.5rem; - padding: 0.5rem 1rem; - background: #eef2ff; - border-radius: 20px; -} - -/* File selected state */ -.file-selected-state { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; } +.main-text { + font-size: 0.9rem; + color: #475569; + margin: 0.25rem 0; +} + +.sub-text { + font-size: 0.8rem; + color: #94a3b8; + margin: 0; +} + +/* File selected state - compact */ +.file-selected-state { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.15rem; +} + .file-name { font-weight: 600; + font-size: 0.9rem; color: #1e293b; - margin: 0.5rem 0 0 0; + margin: 0.25rem 0 0 0; word-break: break-all; } .file-size { - font-size: 0.85rem; + font-size: 0.8rem; color: #64748b; margin: 0; } .file-actions { display: flex; - gap: 0.75rem; - margin-top: 1rem; + gap: 0.5rem; + margin-top: 0.5rem; } /* Processing state */ diff --git a/data-entry-app/frontend/src/router/index.js b/data-entry-app/frontend/src/router/index.js index 462f666..8b69abf 100644 --- a/data-entry-app/frontend/src/router/index.js +++ b/data-entry-app/frontend/src/router/index.js @@ -23,7 +23,7 @@ const routes = [ { path: '/receipt/:id', name: 'ReceiptDetail', - component: () => import('../views/receipts/ReceiptDetailView.vue'), + component: () => import('../views/receipts/ReceiptCreateView.vue'), meta: { title: 'Detalii Bon', requiresAuth: true } }, { diff --git a/data-entry-app/frontend/src/stores/receiptsStore.js b/data-entry-app/frontend/src/stores/receiptsStore.js index 00b90a7..21b71e8 100644 --- a/data-entry-app/frontend/src/stores/receiptsStore.js +++ b/data-entry-app/frontend/src/stores/receiptsStore.js @@ -238,6 +238,39 @@ export const useReceiptsStore = defineStore('receipts', { return `/api/receipts/attachments/${attachmentId}/download` }, + async fetchAttachmentBlob(attachmentId) { + try { + const response = await apiService.get(`/receipts/attachments/${attachmentId}/download`, { + responseType: 'blob', + }) + return URL.createObjectURL(response.data) + } catch (error) { + console.error('Failed to fetch attachment:', error) + return null + } + }, + + async downloadAttachment(attachmentId, filename) { + try { + const response = await apiService.get(`/receipts/attachments/${attachmentId}/download`, { + responseType: 'blob', + }) + // Create download link + const url = URL.createObjectURL(response.data) + const link = document.createElement('a') + link.href = url + link.download = filename || 'attachment' + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) + return true + } catch (error) { + console.error('Failed to download attachment:', error) + throw new Error(error.response?.data?.detail || 'Failed to download attachment') + } + }, + // ============ Accounting Entries ============ async fetchEntries(receiptId) { diff --git a/data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue b/data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue index 13ef0f3..566936d 100644 --- a/data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue +++ b/data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue @@ -2,23 +2,90 @@
-

- - {{ isEditMode ? 'Editare Bon Fiscal' : 'Bon Fiscal Nou' }} -

+
+

+ + {{ isViewMode ? `Bon #${receipt?.id}` : (isEditMode ? 'Editare Bon Fiscal' : 'Bon Fiscal Nou') }} +

+ + {{ getStatusLabel(receipt.status) }} + +
+
+ + +
+ +
+ Motiv respingere: +

{{ receipt.rejection_reason }}

+ Respins de {{ receipt.reviewed_by }} la {{ formatDateTime(receipt.reviewed_at) }} +
+
+ + +
+
-
+
- +
-
+ +

{{ isEditMode ? 'Re-scanare OCR (optional)' : 'Poza Bon' }} @@ -53,38 +120,85 @@ />

+ +
+

+ + Atasamente ({{ existingAttachments.length }}) +

+
+
+ + +
+
+
+ + +
+

+ + Atasamente +

+
+ +

Niciun atasament

+
+
+

Atasamente + +

Fisiere Selectate

- - - -
{{ att.filename }}
-
@@ -131,6 +255,27 @@ />
+ + +
+
Fisiere noi de incarcat:
+
+ + {{ file.name }} + {{ formatFileSize(file.size) }} +
+
@@ -153,6 +298,7 @@ placeholder="Cauta furnizor..." dropdown :forceSelection="false" + :disabled="isReadOnly" /> @@ -161,7 +307,7 @@
- + Negasit @@ -171,7 +317,7 @@
- +
@@ -189,6 +335,7 @@ v-model="form.receipt_type" value="bon_fiscal" inputId="type_bon" + :disabled="isReadOnly" />
@@ -197,6 +344,7 @@ v-model="form.receipt_type" value="chitanta" inputId="type_chitanta" + :disabled="isReadOnly" />
@@ -204,7 +352,7 @@
- +
@@ -213,6 +361,7 @@ dateFormat="dd.mm.yy" showIcon required + :disabled="isReadOnly" />
@@ -224,6 +373,7 @@ v-model="form.direction" value="cheltuiala" inputId="dir_cheltuiala" + :disabled="isReadOnly" /> @@ -232,6 +382,7 @@ v-model="form.direction" value="incasare" inputId="dir_incasare" + :disabled="isReadOnly" /> @@ -257,6 +408,7 @@ :maxFractionDigits="2" required class="amount-input" + :disabled="isReadOnly" />
@@ -268,6 +420,7 @@ optionValue="code" placeholder="Selecteaza tip" required + :disabled="isReadOnly" />
@@ -279,6 +432,7 @@ :min="1" placeholder="17" style="max-width: 100px;" + :disabled="isReadOnly" /> @@ -289,6 +443,7 @@ v-model="form.description" rows="2" placeholder="Descriere optionala..." + :disabled="isReadOnly" /> @@ -310,6 +465,7 @@ optionValue="value" placeholder="Selecteaza mod plata" showClear + :disabled="isReadOnly" /> @@ -348,6 +504,7 @@ locale="ro-RO" :minFractionDigits="2" class="tva-input-compact" + :disabled="isReadOnly" />
@@ -362,32 +519,121 @@
+ +
+

+ + Note Contabile +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
TipContDenumire ContSuma
+ + {{ entry.account_code }}{{ entry.account_name || '-' }} + {{ formatAmount(entry.amount) }} +
Total Debit: + {{ formatAmount(totalDebit) }} +
Total Credit: + {{ formatAmount(totalCredit) }} +
+ +
+ + Atentie: Notele contabile nu sunt echilibrate! +
+
+
+ + +
+ +

Notele contabile vor fi generate la trimiterea spre aprobare

+
+
@@ -441,6 +687,7 @@ import OCRPreview from '../../components/ocr/OCRPreview.vue' import Dialog from 'primevue/dialog' import Tag from 'primevue/tag' import AutoComplete from 'primevue/autocomplete' +import Image from 'primevue/image' const route = useRoute() const router = useRouter() @@ -448,7 +695,11 @@ const toast = useToast() const store = useReceiptsStore() const companyStore = useCompanyStore() -const isEditMode = computed(() => !!route.params.id) +// 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 isReadOnly = computed(() => isViewMode.value) const receiptId = computed(() => route.params.id) const receipt = ref(null) @@ -484,6 +735,7 @@ const form = ref({ const selectedFiles = ref([]) const existingAttachments = ref([]) +const attachmentBlobUrls = ref({}) // Map of attachment ID -> blob URL const saving = ref(false) const submitting = ref(false) @@ -493,6 +745,9 @@ const ocrData = ref(null) const ocrFile = ref(null) const ocrCollapsed = ref(false) +// Edit mode file input ref +const editFileInput = ref(null) + // Supplier dialog refs const showCreateSupplierDialog = ref(false) const pendingSupplierData = ref(null) @@ -509,6 +764,25 @@ const partners = computed(() => store.partners) const expenseTypes = computed(() => store.expenseTypes) const cashRegisters = computed(() => store.cashRegisters) +// Accounting entries computed properties (for view mode) +const totalDebit = computed(() => { + if (!receipt.value?.entries) return 0 + return receipt.value.entries + .filter(e => e.entry_type === 'debit') + .reduce((sum, e) => sum + parseFloat(e.amount), 0) +}) + +const totalCredit = computed(() => { + if (!receipt.value?.entries) return 0 + return receipt.value.entries + .filter(e => e.entry_type === 'credit') + .reduce((sum, e) => sum + parseFloat(e.amount), 0) +}) + +const isBalanced = computed(() => { + return Math.abs(totalDebit.value - totalCredit.value) < 0.01 +}) + // Payment mode options const paymentModeOptions = ref([ { value: 'casa', label: 'Casa (numerar firma)' }, @@ -528,7 +802,7 @@ const searchPartners = (event) => { onMounted(async () => { await store.fetchAllNomenclatures() - if (isEditMode.value) { + if (isEditMode.value || isViewMode.value) { await loadReceipt() } else { // For new receipts, ensure company_id is set from the current selected company @@ -540,6 +814,13 @@ const loadReceipt = async () => { try { receipt.value = await store.fetchReceiptById(receiptId.value) + // Parse TVA breakdown - ensure amounts are numbers + const parsedTvaBreakdown = (receipt.value.tva_breakdown || []).map(entry => ({ + code: entry.code, + percent: entry.percent, + amount: parseFloat(entry.amount) || 0 + })) + // Populate form form.value = { receipt_type: receipt.value.receipt_type, @@ -558,9 +839,9 @@ const loadReceipt = async () => { receipt_number: receipt.value.receipt_number || '', description: receipt.value.description || '', company_id: receipt.value.company_id, - // TVA info - tva_breakdown: receipt.value.tva_breakdown || [], - tva_total: receipt.value.tva_total || null, + // TVA info - parsed as numbers + tva_breakdown: parsedTvaBreakdown, + tva_total: receipt.value.tva_total ? parseFloat(receipt.value.tva_total) : null, items_count: receipt.value.items_count || null, vendor_address: receipt.value.vendor_address || '', payment_methods: receipt.value.payment_methods || [], @@ -569,6 +850,9 @@ const loadReceipt = async () => { // form.partner_name is bound directly to AutoComplete, no separate selectedPartner needed existingAttachments.value = receipt.value.attachments || [] + + // Load blob URLs for attachments (with auth) + await loadAttachmentBlobUrls() } catch (error) { toast.add({ severity: 'error', @@ -580,6 +864,40 @@ const loadReceipt = async () => { } } +// Load blob URLs for all attachments +const loadAttachmentBlobUrls = async () => { + for (const att of existingAttachments.value) { + try { + const blobUrl = await store.fetchAttachmentBlob(att.id) + if (blobUrl) { + attachmentBlobUrls.value[att.id] = blobUrl + } + } catch (error) { + console.error(`Failed to load blob for attachment ${att.id}:`, error) + } + } +} + +// Download attachment +const downloadAttachment = async (attachment) => { + try { + await store.downloadAttachment(attachment.id, attachment.filename) + toast.add({ + severity: 'success', + summary: 'Succes', + detail: 'Fisierul a fost descarcat', + life: 2000, + }) + } catch (error) { + toast.add({ + severity: 'error', + summary: 'Eroare', + detail: 'Nu s-a putut descarca fisierul', + life: 5000, + }) + } +} + // OCR handlers const onOCRFileSelected = (file) => { ocrFile.value = file @@ -807,6 +1125,20 @@ const onFileRemove = (event) => { selectedFiles.value = selectedFiles.value.filter(f => f.name !== event.file.name) } +// Edit mode file input handlers +const triggerFileInput = () => { + editFileInput.value?.click() +} + +const onEditFileSelect = (event) => { + const files = event.target?.files + if (files?.length > 0) { + selectedFiles.value = [...selectedFiles.value, ...Array.from(files)] + } + // Reset input value to allow selecting same file again + event.target.value = '' +} + const removeSelectedFile = (index) => { selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index) } @@ -848,6 +1180,106 @@ const formatTvaTotal = () => { return total.toLocaleString('ro-RO', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) } +// View mode helper functions +const formatAmount = (amount) => { + return new Intl.NumberFormat('ro-RO', { + style: 'currency', + currency: 'RON', + }).format(amount) +} + +const formatDateTime = (dateStr) => { + if (!dateStr) return '-' + return new Date(dateStr).toLocaleString('ro-RO') +} + +const getStatusClass = (status) => { + const classes = { + draft: 'status-draft', + pending_review: 'status-pending', + approved: 'status-approved', + rejected: 'status-rejected', + synced: 'status-synced', + } + return classes[status] || '' +} + +const getStatusLabel = (status) => { + const labels = { + draft: 'Ciorna', + pending_review: 'In asteptare', + approved: 'Aprobat', + rejected: 'Respins', + synced: 'Sincronizat', + } + return labels[status] || status +} + +// View mode workflow actions +const submitReceipt = async () => { + submitting.value = true + try { + const result = await store.submitReceipt(receipt.value.id) + if (result.success) { + toast.add({ + severity: 'success', + summary: 'Succes', + detail: 'Bonul a fost trimis spre aprobare', + life: 3000, + }) + await loadReceipt() + } 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 resubmitReceipt = async () => { + submitting.value = true + try { + const result = await store.resubmitReceipt(receipt.value.id) + if (result.success) { + toast.add({ + severity: 'success', + summary: 'Succes', + detail: 'Bonul a fost re-trimis spre aprobare', + life: 3000, + }) + await loadReceipt() + } 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 re-trimite bonul', + life: 5000, + }) + } finally { + submitting.value = false + } +} + const validateForm = () => { // Check if we have at least one file (for new receipts) // Also check ocrFile as a fallback (file selected for OCR processing) @@ -993,6 +1425,22 @@ const submitForReview = async () => { diff --git a/data-entry-app/frontend/src/views/receipts/ReceiptDetailView.vue b/data-entry-app/frontend/src/views/receipts/ReceiptDetailView.vue deleted file mode 100644 index ebae32c..0000000 --- a/data-entry-app/frontend/src/views/receipts/ReceiptDetailView.vue +++ /dev/null @@ -1,629 +0,0 @@ - - - - -