Files
roa2web-service-auto/data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue
Marius Mutu 0851d01917 feat: Make TVA and payment method values editable, remove RON currency
- Make payment methods (CARD/NUMERAR) editable InputNumber fields
- Remove RON currency display from TOTAL, TVA, and payment fields
- Allow editing REJECTED receipts (to fix OCR errors before resubmit)
- Add "Editeaza" button for REJECTED receipts in view mode
- Fix null amount validation by converting to 0 before API call

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-18 19:13:30 +02:00

2940 lines
76 KiB
Vue

<template>
<div class="receipt-create-view">
<div class="roa-card">
<div class="roa-card-header">
<div>
<h2 class="roa-card-title">
<i :class="isViewMode ? 'pi pi-receipt' : 'pi pi-plus-circle'"></i>
{{ isViewMode ? `Bon #${receipt?.id}` : (isEditMode ? 'Editare Bon Fiscal' : 'Bon Fiscal Nou') }}
</h2>
<span v-if="isViewMode && receipt" :class="['status-badge', getStatusClass(receipt.status)]">
{{ getStatusLabel(receipt.status) }}
</span>
</div>
</div>
<!-- Rejection Reason Alert -->
<div v-if="receipt?.rejection_reason" class="rejection-alert">
<i class="pi pi-exclamation-triangle"></i>
<div>
<strong>Motiv respingere:</strong>
<p>{{ receipt.rejection_reason }}</p>
<small>Respins de {{ receipt.reviewed_by }} la {{ formatDateTime(receipt.reviewed_at) }}</small>
</div>
</div>
<form id="receipt-form" @submit.prevent="saveReceipt">
<div class="receipt-form-layout">
<!-- COLOANA STÂNGA: Upload & OCR / Attachments -->
<div class="form-column-left">
<!-- OCR Section - hidden in view mode -->
<div class="upload-section" v-if="!isViewMode">
<h3>
<i class="pi pi-camera"></i>
{{ isEditMode ? 'Re-scanare OCR (optional)' : 'Poza Bon' }}
</h3>
<!-- OCR Upload Zone -->
<OCRUploadZone
ref="ocrUploadZone"
@ocr-result="onOCRResult"
@file-selected="onOCRFileSelected"
@error="onOCRError"
/>
<!-- OCR Applied Banner (collapsed state) -->
<div
v-if="ocrData && ocrCollapsed"
class="ocr-applied-banner"
@click="ocrCollapsed = false"
>
<i class="pi pi-check-circle"></i>
<span>Date OCR aplicate</span>
<i class="pi pi-chevron-down"></i>
</div>
<!-- OCR Preview (expanded state) -->
<OCRPreview
v-if="ocrData && !ocrCollapsed"
:data="ocrData"
@apply="applyOCRData"
@dismiss="dismissOCRData"
@collapse="ocrCollapsed = true"
/>
</div>
<!-- View-only Attachments (view mode) -->
<div class="upload-section" v-if="isViewMode && existingAttachments.length">
<h3>
<i class="pi pi-images"></i>
Atasamente ({{ existingAttachments.length }})
</h3>
<div class="attachments-grid">
<div
v-for="att in existingAttachments"
:key="att.id"
class="attachment-item"
>
<template v-if="att.mime_type?.startsWith('image/')">
<Image
:src="attachmentBlobUrls[att.id] || ''"
:alt="att.filename"
preview
class="attachment-image"
/>
</template>
<template v-else>
<div class="pdf-attachment">
<i class="pi pi-file-pdf"></i>
<span>{{ att.filename }}</span>
</div>
</template>
<Button
icon="pi pi-download"
severity="secondary"
rounded
size="small"
class="download-btn"
@click="downloadAttachment(att)"
v-tooltip.top="'Descarca'"
/>
</div>
</div>
</div>
<!-- Empty state for view mode with no attachments -->
<div class="upload-section" v-if="isViewMode && !existingAttachments.length">
<h3>
<i class="pi pi-images"></i>
Atasamente
</h3>
<div class="empty-state">
<i class="pi pi-image"></i>
<p>Niciun atasament</p>
</div>
</div>
<!-- Standard Upload Section (for edit mode or additional files) -->
<div class="upload-section" v-if="isEditMode || selectedFiles.length > 0">
<h3 v-if="isEditMode">
<i class="pi pi-images"></i>
Atasamente
<!-- Simple add button for edit mode -->
<Button
icon="pi pi-plus"
label="Adauga"
severity="secondary"
size="small"
class="add-attachment-btn"
@click="triggerFileInput"
/>
<input
ref="editFileInput"
type="file"
accept="image/*,application/pdf"
multiple
class="hidden-file-input"
@change="onEditFileSelect"
/>
</h3>
<h3 v-else-if="selectedFiles.length > 0">
<i class="pi pi-paperclip"></i>
Fisiere Selectate
</h3>
<!-- Existing attachments (edit mode) -->
<div v-if="existingAttachments.length" class="image-preview-grid">
<div
v-for="att in existingAttachments"
:key="att.id"
class="image-preview-item"
>
<!-- Image with loaded blob URL -->
<img
v-if="att.mime_type?.startsWith('image/') && attachmentBlobUrls[att.id]"
:src="attachmentBlobUrls[att.id]"
:alt="att.filename"
/>
<!-- Placeholder when blob not yet loaded -->
<div
v-else-if="att.mime_type?.startsWith('image/') && !attachmentBlobUrls[att.id]"
class="image-placeholder"
>
<i class="pi pi-image" style="font-size: 2rem; color: #94a3b8;"></i>
<span>{{ att.filename }}</span>
</div>
<div v-else class="pdf-preview">
<i class="pi pi-file-pdf" style="font-size: 2rem;"></i>
<span>{{ att.filename }}</span>
</div>
<div class="attachment-actions">
<!-- OCR Rescan button for images -->
<Button
v-if="att.mime_type?.startsWith('image/')"
icon="pi pi-sync"
severity="info"
rounded
size="small"
:loading="ocrRescanningId === att.id"
@click="rescanAttachmentOCR(att)"
v-tooltip.top="'Rescanare OCR'"
/>
<Button
icon="pi pi-download"
severity="secondary"
rounded
size="small"
@click="downloadAttachment(att)"
v-tooltip.top="'Descarca'"
/>
<Button
icon="pi pi-times"
severity="danger"
rounded
size="small"
@click="removeExistingAttachment(att.id)"
v-tooltip.top="'Sterge'"
/>
</div>
</div>
</div>
<!-- Selected files preview (both create and edit mode) -->
<div v-if="selectedFiles.length" class="selected-files-list">
<div
v-for="(file, index) in selectedFiles"
:key="index"
class="selected-file-item"
>
<i :class="file.type.startsWith('image/') ? 'pi pi-image' : 'pi pi-file-pdf'"></i>
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<Button
icon="pi pi-times"
severity="danger"
rounded
size="small"
@click="removeSelectedFile(index)"
/>
</div>
</div>
<!-- New files pending upload (edit mode) -->
<div v-if="isEditMode && selectedFiles.length" class="selected-files-list">
<div class="pending-files-header">Fisiere noi de incarcat:</div>
<div
v-for="(file, index) in selectedFiles"
:key="index"
class="selected-file-item"
>
<i :class="file.type.startsWith('image/') ? 'pi pi-image' : 'pi pi-file-pdf'"></i>
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<Button
icon="pi pi-times"
severity="danger"
rounded
size="small"
@click="removeSelectedFile(index)"
/>
</div>
</div>
</div>
</div>
<!-- COLOANA DREAPTA: Formular Compact (flat layout, no sections) -->
<div class="form-column-right">
<!-- SECTION: Furnizor -->
<div class="form-group">
<div class="form-row">
<div class="form-field flex-2">
<label>Furnizor</label>
<AutoComplete
v-model="form.partner_name"
:suggestions="filteredPartners"
optionLabel="name"
field="name"
@complete="searchPartners"
@item-select="onPartnerSelect"
placeholder="Cauta furnizor..."
dropdown
:forceSelection="false"
:disabled="isReadOnly"
/>
<small v-if="supplierSource" class="p-text-success supplier-selected">
<i class="pi pi-check-circle"></i>
Validat ({{ supplierSource }})
</small>
</div>
<div class="form-field flex-1">
<label>CUI</label>
<InputText v-model="form.cui" placeholder="RO12345678" :disabled="isReadOnly" />
<small v-if="supplierWarning.show" class="p-text-warning supplier-warning">
<i class="pi pi-exclamation-triangle"></i>
Negasit
</small>
</div>
</div>
<!-- Adresa colapsata -->
<div class="address-collapsed" v-if="form.vendor_address" @click="showAddressExpanded = !showAddressExpanded">
<i :class="showAddressExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"></i>
<span class="address-preview">{{ showAddressExpanded ? 'Ascunde adresa' : form.vendor_address }}</span>
</div>
<div class="address-expanded" v-if="form.vendor_address && showAddressExpanded">
<InputText v-model="form.vendor_address" placeholder="Adresa furnizor" :disabled="isReadOnly" class="input-small" />
</div>
</div>
<!-- SECTION: Document (all on one line) -->
<div class="form-group">
<div class="form-row document-row-inline">
<div class="form-field">
<label>Tip</label>
<Dropdown
v-model="form.receipt_type"
:options="receiptTypeOptions"
optionLabel="label"
optionValue="value"
placeholder="Tip"
:disabled="isReadOnly"
class="dropdown-compact"
/>
</div>
<div class="form-field">
<label>Nr.</label>
<InputText v-model="form.receipt_number" placeholder="12345" style="max-width: 90px;" :disabled="isReadOnly" />
</div>
<div class="form-field">
<label>Data *</label>
<Calendar
v-model="form.receipt_date"
dateFormat="dd.mm.yy"
showIcon
required
:disabled="isReadOnly"
/>
</div>
<div class="form-field">
<div class="direction-header">
<label>Operatiune</label>
<Tag
v-if="directionAutoDetected"
severity="info"
value="Auto"
v-tooltip="directionAutoReason"
class="auto-tag"
/>
</div>
<Dropdown
v-model="form.direction"
:options="directionOptions"
optionLabel="label"
optionValue="value"
placeholder="Tip"
:disabled="isReadOnly"
@change="directionAutoDetected = false"
class="dropdown-compact"
/>
</div>
</div>
<!-- Warning when ÎNCASARE but no client data -->
<Message v-if="missingClientWarning" severity="warn" :closable="false" class="mt-1 message-compact">
<small>Incasare detectata dar lipsesc datele clientului.</small>
</Message>
</div>
<!-- SECTION: VALORI (compact inline layout) -->
<div class="form-group values-section">
<!-- Row 1: TOTAL + Mod Plata + Payment details -->
<div class="values-row-inline">
<div class="value-item">
<label>TOTAL *</label>
<InputNumber
v-model="form.amount"
:minFractionDigits="2"
:maxFractionDigits="2"
required
:disabled="isReadOnly"
class="input-compact"
/>
</div>
<div class="value-item">
<label>Mod Plata</label>
<Dropdown
v-model="form.payment_mode"
:options="paymentModeOptions"
optionLabel="label"
optionValue="value"
placeholder="Selecteaza"
showClear
:disabled="isReadOnly"
class="dropdown-payment"
/>
</div>
<!-- Payment methods from OCR (CARD, NUMERAR) - editable -->
<div class="value-item payment-method-item" v-for="(pm, idx) in form.payment_methods" :key="pm.method">
<label>{{ pm.method }}</label>
<InputNumber
v-model="form.payment_methods[idx].amount"
locale="en-US"
:minFractionDigits="2"
:maxFractionDigits="2"
:disabled="isReadOnly"
:inputStyle="{ width: '110px' }"
class="input-payment-method"
/>
</div>
</div>
<!-- Row 2: TVA entries inline (only if present) -->
<div class="values-row-inline tva-compact" v-if="form.tva_breakdown?.length > 0">
<div class="value-item tva-item" v-for="(entry, idx) in form.tva_breakdown" :key="'tva-'+idx">
<label>TVA {{ entry.code }} {{ entry.percent }}%</label>
<InputNumber
v-model="form.tva_breakdown[idx].amount"
:minFractionDigits="2"
:maxFractionDigits="2"
:disabled="isReadOnly"
:inputStyle="{ width: '110px' }"
class="input-tva"
/>
</div>
<div class="value-item tva-total-item">
<label>Total TVA</label>
<span class="tva-total-value">{{ formatTvaTotal() }}</span>
</div>
</div>
</div>
<!-- SECTION: Categorizare -->
<div class="form-group form-group-last">
<div class="form-row">
<div class="form-field flex-1">
<label>Tip Cheltuiala *</label>
<Dropdown
v-model="form.expense_type_code"
:options="expenseTypes"
optionLabel="name"
optionValue="code"
placeholder="Selecteaza tip cheltuiala"
required
:disabled="isReadOnly"
/>
</div>
</div>
<div class="form-row optional-fields">
<div class="form-field flex-2">
<label class="label-small">Descriere</label>
<Textarea
v-model="form.description"
rows="2"
placeholder="Descriere optionala..."
:disabled="isReadOnly"
/>
</div>
<div class="form-field items-count-field" v-if="form.items_count">
<label class="label-small label-muted">Nr. Art.</label>
<InputNumber
v-model="form.items_count"
:min="1"
placeholder="17"
class="items-count-input"
:disabled="isReadOnly"
/>
</div>
</div>
</div>
</div>
<!-- End form-column-right -->
</div>
<!-- End receipt-form-layout -->
<!-- Accounting Entries (view mode only) -->
<div v-if="isViewMode && receipt?.entries?.length" class="entries-section">
<h3>
<i class="pi pi-book"></i>
Note Contabile
</h3>
<div class="entries-table-container">
<table class="entries-table">
<thead>
<tr>
<th>Tip</th>
<th>Cont</th>
<th>Denumire Cont</th>
<th style="text-align: right;">Suma</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in receipt.entries" :key="entry.id">
<td>
<Tag
:value="entry.entry_type === 'debit' ? 'D' : 'C'"
:severity="entry.entry_type === 'debit' ? 'danger' : 'success'"
/>
</td>
<td>{{ entry.account_code }}</td>
<td>{{ entry.account_name || '-' }}</td>
<td :class="entry.entry_type" style="text-align: right;">
{{ formatAmount(entry.amount) }}
</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="3" style="text-align: right;"><strong>Total Debit:</strong></td>
<td class="debit" style="text-align: right;">
<strong>{{ formatAmount(totalDebit) }}</strong>
</td>
</tr>
<tr>
<td colspan="3" style="text-align: right;"><strong>Total Credit:</strong></td>
<td class="credit" style="text-align: right;">
<strong>{{ formatAmount(totalCredit) }}</strong>
</td>
</tr>
</tfoot>
</table>
<div v-if="!isBalanced" class="balance-warning">
<i class="pi pi-exclamation-triangle"></i>
Atentie: Notele contabile nu sunt echilibrate!
</div>
</div>
</div>
<!-- Spacer for floating buttons -->
<div class="floating-buttons-spacer"></div>
</form>
<!-- Pre-validation Banners -->
<div class="validation-banners" v-if="!isViewMode">
<!-- Missing required fields banner -->
<div class="validation-banner" v-if="missingRequiredFields.length > 0">
<i class="pi pi-exclamation-circle"></i>
<span>Campuri obligatorii necompletate: <strong>{{ missingRequiredFields.join(', ') }}</strong></span>
</div>
<!-- Cross-validation warning: amount vs payment methods -->
<div class="validation-banner warning" v-if="!validationState.amountMatchesPayments">
<i class="pi pi-exclamation-triangle"></i>
<span>Totalul nu corespunde cu suma metodelor de plata</span>
</div>
</div>
</div>
<!-- Action Buttons - Top Horizontal -->
<div class="action-buttons-top">
<Button
icon="pi pi-arrow-left"
label="Inapoi"
severity="secondary"
@click="$router.push('/')"
/>
<!-- View mode buttons -->
<template v-if="isViewMode">
<Button
v-if="receipt?.status === 'draft' || receipt?.status === 'rejected'"
icon="pi pi-pencil"
label="Editeaza"
@click="$router.push(`/receipt/${receipt.id}/edit`)"
/>
<Button
v-if="receipt?.status === 'draft'"
icon="pi pi-send"
label="Trimite"
severity="success"
@click="submitReceipt"
:loading="submitting"
/>
<Button
v-if="receipt?.status === 'rejected'"
icon="pi pi-refresh"
label="Re-trimite"
severity="warning"
@click="resubmitReceipt"
:loading="submitting"
/>
<!-- Pending review: Approve/Reject buttons -->
<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"
:loading="rejecting"
/>
<!-- Approved: Unapprove button -->
<Button
v-if="receipt?.status === 'approved'"
icon="pi pi-undo"
label="Anuleaza Validarea"
severity="warning"
@click="unapproveReceipt"
:loading="unapproving"
/>
</template>
<!-- Edit/Create mode buttons -->
<template v-else>
<Button
type="submit"
icon="pi pi-save"
label="Salveaza"
:loading="saving"
form="receipt-form"
/>
<Button
v-if="isEditMode && receipt?.status === 'draft'"
type="button"
icon="pi pi-send"
label="Trimite"
severity="success"
:loading="submitting"
@click="submitForReview"
/>
</template>
</div>
<!-- Create Supplier Dialog -->
<Dialog
v-model:visible="showCreateSupplierDialog"
header="Furnizor Negasit"
:modal="true"
:style="{ width: '450px' }"
>
<div class="dialog-content">
<p>
<i class="pi pi-exclamation-triangle" style="color: var(--orange-500);"></i>
Furnizorul cu CUI <strong>{{ pendingSupplierData?.fiscal_code }}</strong> nu a fost gasit in baza de date.
</p>
<p>Doriti sa creati un furnizor local cu datele extrase din bon?</p>
<div class="form-field" style="margin-top: 1rem;">
<label>Nume Furnizor</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="cancelCreateSupplier" />
<Button label="Creaza Furnizor" icon="pi pi-plus" @click="createLocalSupplier" />
</template>
</Dialog>
<!-- Reject Dialog -->
<Dialog
v-model:visible="showRejectDialog"
header="Respinge Bon"
:modal="true"
:style="{ width: '450px' }"
>
<div class="dialog-content">
<p>
<i class="pi pi-exclamation-triangle" style="color: var(--red-500);"></i>
Introduceti motivul respingerii bonului:
</p>
<div class="form-field" style="margin-top: 1rem;">
<label>Motiv respingere *</label>
<Textarea
v-model="rejectReason"
rows="3"
class="w-full"
placeholder="Explicati de ce bonul este respins (minim 5 caractere)..."
/>
</div>
</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>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useReceiptsStore } from '../../stores/receiptsStore'
import { useCompanyStore } from '../../stores/companies'
import { apiService } from '../../services/api'
import OCRUploadZone from '../../components/ocr/OCRUploadZone.vue'
import OCRPreview from '../../components/ocr/OCRPreview.vue'
import Dialog from 'primevue/dialog'
import Tag from 'primevue/tag'
import Message from 'primevue/message'
import AutoComplete from 'primevue/autocomplete'
import Image from 'primevue/image'
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 isReadOnly = computed(() => isViewMode.value)
const receiptId = computed(() => route.params.id)
const receipt = ref(null)
// Get selected company ID from store
const getSelectedCompanyId = () => {
return companyStore.selectedCompanyId || 1
}
const form = ref({
receipt_type: 'bon_fiscal',
direction: 'cheltuiala',
receipt_date: new Date(),
amount: null,
// partner_id removed - supplier data is text-only
partner_name: null,
cui: '', // Fiscal code from OCR
ocr_raw_text: '', // Raw OCR text for debugging
expense_type_code: null,
payment_mode: null, // NEW: casa/banca/avans_decontare
cash_register_id: null, // Legacy - keep for backwards compatibility
cash_register_name: null,
cash_register_account: null,
receipt_number: '',
description: '',
company_id: getSelectedCompanyId(),
// TVA info (multiple entries support)
tva_breakdown: [], // Array of {code, percent, amount}
tva_total: null,
items_count: null,
vendor_address: '',
payment_methods: [], // Array of {method, amount}
})
const selectedFiles = ref([])
const existingAttachments = ref([])
const attachmentBlobUrls = ref({}) // Map of attachment ID -> blob URL
const saving = ref(false)
const submitting = ref(false)
// OCR related refs
const ocrUploadZone = ref(null)
const ocrData = ref(null)
const ocrFile = ref(null)
const ocrCollapsed = ref(false)
const ocrRescanningId = ref(null) // ID of attachment being rescanned
// Edit mode file input ref
const editFileInput = ref(null)
// Supplier dialog refs
const showCreateSupplierDialog = ref(false)
const pendingSupplierData = ref(null)
const supplierWarning = ref({ show: false, cui: '', name: '' })
// Address collapsed state
const showAddressExpanded = ref(false)
// OCR indicator for payment mode
const paymentSetFromOCR = ref(false)
// Auto-detection state for direction (PLATĂ/ÎNCASARE)
const directionAutoDetected = ref(false)
const directionAutoReason = ref('') // e.g., "CUI furnizor = CUI firmă curentă"
const missingClientWarning = ref(false)
// AutoComplete support
const filteredPartners = ref([])
const supplierSource = ref(null) // 'local', 'synced', or null
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
})
// Pre-validation computed states for visual indicators
const validationState = computed(() => ({
hasAmount: form.value.amount && form.value.amount > 0,
hasDate: !!form.value.receipt_date,
hasExpenseType: !!form.value.expense_type_code,
hasPaymentMode: !!form.value.payment_mode,
hasAttachment: selectedFiles.value.length > 0 || existingAttachments.value.length > 0 || !!ocrFile.value,
// Cross-validation
amountMatchesPayments: !form.value.payment_methods?.length ||
Math.abs((form.value.amount || 0) - form.value.payment_methods.reduce((s, p) => s + (p.amount || 0), 0)) < 0.02
}))
const missingRequiredFields = computed(() => {
const missing = []
if (!validationState.value.hasAmount) missing.push('Suma')
if (!validationState.value.hasDate) missing.push('Data')
if (!validationState.value.hasExpenseType) missing.push('Tip cheltuiala')
if (!validationState.value.hasAttachment) missing.push('Atasament')
return missing
})
// Payment mode options
const paymentModeOptions = ref([
{ value: 'casa', label: 'Casa (numerar firma)' },
{ value: 'banca', label: 'Banca (virament/POS)' },
{ value: 'avans_decontare', label: 'Avans Decontare (decont angajat)' },
])
// Receipt type options (dropdown)
const receiptTypeOptions = ref([
{ value: 'bon_fiscal', label: 'Bon Fiscal' },
{ value: 'chitanta', label: 'Chitanta' },
])
// Operation type / direction options (dropdown)
const directionOptions = ref([
{ value: 'cheltuiala', label: 'Cheltuiala (Plata)' },
{ value: 'incasare', label: 'Incasare' },
])
// AutoComplete search function
const searchPartners = (event) => {
const query = event.query.toLowerCase()
filteredPartners.value = partners.value.filter(p =>
p.name.toLowerCase().includes(query) ||
(p.fiscal_code && p.fiscal_code.toLowerCase().includes(query))
)
}
onMounted(async () => {
await store.fetchAllNomenclatures()
if (isEditMode.value || isViewMode.value) {
await loadReceipt()
} else {
// For new receipts, ensure company_id is set from the current selected company
form.value.company_id = companyStore.selectedCompanyId || 1
}
})
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,
direction: receipt.value.direction,
receipt_date: new Date(receipt.value.receipt_date),
amount: parseFloat(receipt.value.amount),
// partner_id removed - supplier data is text-only
partner_name: receipt.value.partner_name,
cui: receipt.value.cui || '',
ocr_raw_text: receipt.value.ocr_raw_text || '',
expense_type_code: receipt.value.expense_type_code,
payment_mode: receipt.value.payment_mode || null, // NEW
cash_register_id: receipt.value.cash_register_id, // Legacy
cash_register_name: receipt.value.cash_register_name,
cash_register_account: receipt.value.cash_register_account,
receipt_number: receipt.value.receipt_number || '',
description: receipt.value.description || '',
company_id: receipt.value.company_id,
// 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 || [],
}
// 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',
summary: 'Eroare',
detail: 'Nu s-a putut incarca bonul',
life: 5000,
})
router.push('/')
}
}
// 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
// Add to selected files for upload
if (!selectedFiles.value.some(f => f.name === file.name)) {
selectedFiles.value = [file, ...selectedFiles.value]
}
}
const onOCRResult = (data) => {
ocrData.value = data
toast.add({
severity: 'success',
summary: 'OCR Procesare',
detail: 'Datele au fost extrase din imagine',
life: 3000,
})
}
const onOCRError = (message) => {
toast.add({
severity: 'error',
summary: 'Eroare OCR',
detail: message,
life: 5000,
})
}
// Rescan existing attachment through OCR
const rescanAttachmentOCR = async (attachment) => {
if (ocrRescanningId.value) return // Already processing
ocrRescanningId.value = attachment.id
try {
// Fetch the attachment blob
const response = await apiService.get(`/receipts/attachments/${attachment.id}/download`, {
responseType: 'blob',
})
// Create a File object from the blob
const file = new File([response.data], attachment.filename, { type: attachment.mime_type })
// Send to OCR
const formData = new FormData()
formData.append('file', file)
const ocrResponse = await apiService.post('/ocr/extract', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000,
})
if (ocrResponse.data.success) {
const resultData = {
...ocrResponse.data.data,
_ocr_message: ocrResponse.data.message
}
ocrData.value = resultData
ocrCollapsed.value = false
toast.add({
severity: 'success',
summary: 'OCR Procesare',
detail: 'Datele au fost re-extrase din atasament',
life: 3000,
})
} else {
toast.add({
severity: 'error',
summary: 'Eroare OCR',
detail: ocrResponse.data.message || 'Procesare OCR esuata',
life: 5000,
})
}
} catch (error) {
const message = error.response?.data?.detail || error.message || 'Eroare la procesarea OCR'
toast.add({
severity: 'error',
summary: 'Eroare OCR',
detail: message,
life: 5000,
})
} finally {
ocrRescanningId.value = null
}
}
const applyOCRData = async (data) => {
// Show warnings for problematic OCR data before applying
// Warning if amount not found
if (!data.amount || parseFloat(data.amount) <= 0) {
const paymentSum = data.payment_methods?.reduce((s, p) => s + parseFloat(p.amount || 0), 0) || 0
if (paymentSum > 0) {
toast.add({
severity: 'warn',
summary: 'Total nedetectat',
detail: `Totalul nu a fost extras. Suma din plati: ${paymentSum.toFixed(2)} LEI`,
life: 6000
})
} else {
toast.add({
severity: 'warn',
summary: 'Total nedetectat',
detail: 'Totalul nu a fost extras din OCR. Completati manual.',
life: 5000
})
}
}
// Warning if amount doesn't match payment methods sum
if (data.amount && data.payment_methods?.length > 0) {
const paymentSum = data.payment_methods.reduce((s, p) => s + parseFloat(p.amount || 0), 0)
const diff = Math.abs(parseFloat(data.amount) - paymentSum)
if (diff > 0.02) {
toast.add({
severity: 'warn',
summary: 'Diferenta detectata',
detail: `Total (${parseFloat(data.amount).toFixed(2)}) ≠ Plati (${paymentSum.toFixed(2)})`,
life: 5000
})
}
}
// Apply basic 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.amount) {
form.value.amount = parseFloat(data.amount)
}
if (data.receipt_number) {
form.value.receipt_number = data.receipt_number
}
// Save CUI from OCR
if (data.cui) {
form.value.cui = data.cui
}
// Save raw OCR text for debugging
if (data.raw_text) {
form.value.ocr_raw_text = data.raw_text
}
// Apply TVA entries
if (data.tva_entries?.length > 0) {
form.value.tva_breakdown = data.tva_entries.map(e => ({
code: e.code,
percent: e.percent,
amount: parseFloat(e.amount)
}))
}
if (data.tva_total) form.value.tva_total = parseFloat(data.tva_total)
if (data.items_count) form.value.items_count = data.items_count
if (data.address) form.value.vendor_address = data.address
// Apply payment methods
if (data.payment_methods?.length > 0) {
form.value.payment_methods = data.payment_methods.map(pm => ({
method: pm.method,
amount: parseFloat(pm.amount)
}))
}
// Auto-suggest payment_mode if OCR detected CARD
if (data.suggested_payment_mode) {
form.value.payment_mode = data.suggested_payment_mode
paymentSetFromOCR.value = true // Show OCR indicator
}
// AUTO-DETECT DIRECTION (PLATĂ/ÎNCASARE) based on CUI matching
const companyCui = companyStore.selectedCompany?.fiscal_code?.replace(/^RO/i, '')
const vendorCui = data.cui?.replace(/^RO/i, '')
const clientCui = data.client_cui?.replace(/^RO/i, '')
// Reset auto-detection state
directionAutoDetected.value = false
directionAutoReason.value = ''
missingClientWarning.value = false
if (vendorCui && companyCui && vendorCui === companyCui) {
// WE ARE THE VENDOR → ÎNCASARE (income)
form.value.direction = 'incasare'
directionAutoDetected.value = true
directionAutoReason.value = 'CUI furnizor = CUI firma curenta'
// Partner = CLIENT
if (data.client_name || data.client_cui) {
form.value.partner_name = data.client_name || ''
form.value.cui = data.client_cui || ''
form.value.vendor_address = data.client_address || ''
// Search for client in suppliers list
if (data.client_cui) {
const clientResult = await store.searchSupplier(data.client_cui)
if (clientResult.found && clientResult.supplier) {
form.value.partner_name = clientResult.supplier.name
form.value.cui = clientResult.supplier.fiscal_code || data.client_cui
form.value.vendor_address = clientResult.supplier.address || data.client_address || ''
supplierSource.value = clientResult.source
toast.add({
severity: 'success',
summary: 'Client gasit',
detail: `${clientResult.supplier.name} (${clientResult.source})`,
life: 3000,
})
}
}
toast.add({
severity: 'info',
summary: 'Incasare detectata',
detail: 'Firma curenta este furnizorul. Partenerul este clientul.',
life: 4000,
})
} else {
// NO CLIENT DATA - show warning
missingClientWarning.value = true
toast.add({
severity: 'warn',
summary: 'Lipsesc date client',
detail: 'Incasare detectata dar nu exista date client pe bon. Completeaza manual.',
life: 5000,
})
}
// Skip the normal supplier search below since we're using client as partner
toast.add({
severity: 'success',
summary: 'Date aplicate',
detail: 'Datele OCR au fost aplicate in formular',
life: 3000,
})
return
} else if (clientCui && companyCui && clientCui === companyCui) {
// WE ARE THE CLIENT → PLATĂ (expense) - standard purchase
form.value.direction = 'cheltuiala'
directionAutoDetected.value = true
directionAutoReason.value = 'CUI client = CUI firma curenta'
// Partner = VENDOR (continue to normal supplier search below)
} else if (clientCui && companyCui && clientCui !== companyCui) {
// Client CUI exists but doesn't match company CUI
// Check if it's a close match (possible OCR error - differs by 1-2 characters)
const similarity = calculateCuiSimilarity(clientCui, companyCui)
if (similarity >= 0.8) {
// Very similar - likely OCR error
toast.add({
severity: 'warn',
summary: 'CUI client similar',
detail: `CUI client (${clientCui}) este similar cu CUI firma (${companyCui}). Posibila eroare OCR.`,
life: 8000
})
} else if (clientCui.length >= 6) {
// Different CUI - show info
toast.add({
severity: 'info',
summary: 'CUI client diferit',
detail: `CUI client pe bon: ${clientCui}. CUI firma: ${companyCui}`,
life: 5000
})
}
}
// ELSE: Neither matches → default to CHELTUIALĂ, partner = vendor (normal flow)
// Auto-search supplier by CUI if available (vendor as partner)
if (data.cui) {
toast.add({
severity: 'info',
summary: 'Cautare furnizor',
detail: `Se cauta furnizor dupa CUI: ${data.cui}`,
life: 2000,
})
const result = await store.searchSupplier(data.cui)
if (result.found && result.supplier) {
// Build supplier object for AutoComplete
const supplierObj = {
name: result.supplier.name,
fiscal_code: result.supplier.fiscal_code,
address: result.supplier.address,
source: result.source
}
// Fill form fields (strings for saving) - form.partner_name is bound directly to AutoComplete
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 || form.value.vendor_address
// Set source for visual indicator
supplierSource.value = result.source
// Add supplier to store's partners list if not already there (for future suggestions)
const existsInPartners = store.partners.some(p => p.name === result.supplier.name)
if (!existsInPartners) {
store.partners.push(supplierObj)
}
toast.add({
severity: 'success',
summary: 'Furnizor gasit',
detail: `${result.supplier.name} (${result.source})`,
life: 3000,
})
} else {
// Not found - show non-blocking warning, allow continuing
supplierWarning.value = {
show: true,
cui: data.cui,
name: data.partner_name || ''
}
// Still set form values from OCR
form.value.partner_name = data.partner_name || ''
// CUI already set above
toast.add({
severity: 'warn',
summary: 'Furnizor negasit',
detail: `CUI ${data.cui} nu a fost gasit in nomenclator`,
life: 5000,
})
}
} else if (data.partner_name) {
// No CUI but have name - try name search in partners list
const matchingPartner = partners.value.find(p =>
p.name.toLowerCase().includes(data.partner_name.toLowerCase())
)
if (matchingPartner) {
// Fill form fields - form.partner_name is bound directly to AutoComplete
form.value.partner_name = matchingPartner.name
form.value.cui = matchingPartner.fiscal_code || ''
form.value.vendor_address = matchingPartner.address || form.value.vendor_address || ''
supplierSource.value = matchingPartner.source || 'local'
} else {
// Just set the name from OCR (no matching partner found)
form.value.partner_name = data.partner_name
}
}
// NOTE: OCRPreview rămâne vizibil pentru comparație side-by-side
// (NU mai colapsăm automat - utilizatorul poate compara datele)
toast.add({
severity: 'success',
summary: 'Date aplicate',
detail: 'Datele OCR au fost aplicate in formular',
life: 3000,
})
}
const dismissOCRData = () => {
ocrData.value = null
}
const createLocalSupplier = async () => {
if (!pendingSupplierData.value) return
try {
const supplier = await store.createLocalSupplier(pendingSupplierData.value)
// Auto-select the new supplier
form.value.partner_id = supplier.id
form.value.partner_name = supplier.name
toast.add({
severity: 'success',
summary: 'Furnizor creat',
detail: `${supplier.name} a fost adaugat`,
life: 3000,
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message,
life: 5000,
})
} finally {
showCreateSupplierDialog.value = false
pendingSupplierData.value = null
}
}
const cancelCreateSupplier = () => {
showCreateSupplierDialog.value = false
pendingSupplierData.value = null
}
// Helper function to calculate similarity between two CUI strings
// Returns a value between 0 and 1 (1 = identical)
const calculateCuiSimilarity = (cui1, cui2) => {
if (!cui1 || !cui2) return 0
// Normalize: remove RO/R0 prefix (R0 is common OCR error for RO) and keep only digits
const norm1 = cui1.replace(/^R[O0]/i, '').replace(/\D/g, '')
const norm2 = cui2.replace(/^R[O0]/i, '').replace(/\D/g, '')
if (norm1.length === 0 || norm2.length === 0) return 0
if (norm1 === norm2) return 1
// Calculate character-by-character similarity
const maxLen = Math.max(norm1.length, norm2.length)
const minLen = Math.min(norm1.length, norm2.length)
// Length difference penalty
const lengthSimilarity = minLen / maxLen
// Character match count
let matches = 0
for (let i = 0; i < minLen; i++) {
if (norm1[i] === norm2[i]) matches++
}
const charSimilarity = matches / maxLen
// Combined similarity (weighted average)
return (charSimilarity * 0.8 + lengthSimilarity * 0.2)
}
const onPartnerSelect = (event) => {
const partner = event.value
if (partner && typeof partner === 'object') {
form.value.partner_name = partner.name
form.value.cui = partner.fiscal_code || ''
form.value.vendor_address = partner.address || form.value.vendor_address || ''
supplierSource.value = partner.source || 'oracle'
}
}
const onCashRegisterChange = (event) => {
const cr = cashRegisters.value.find(c => c.id === event.value)
form.value.cash_register_name = cr?.name || null
form.value.cash_register_account = cr?.account_code || null
}
const onFileSelect = (event) => {
selectedFiles.value = [...selectedFiles.value, ...event.files]
}
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)
}
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: 3000,
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message,
life: 5000,
})
}
}
const formatFileSize = (bytes) => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const formatCurrency = (value) => {
if (value === null || value === undefined) return '0.00'
return parseFloat(value).toLocaleString('ro-RO', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const formatTvaTotal = () => {
if (!form.value.tva_breakdown?.length) return '0.00'
const total = form.value.tva_breakdown.reduce((sum, e) => sum + (e.amount || 0), 0)
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: 'Validat',
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
}
}
// Workflow action states
const approving = ref(false)
const rejecting = ref(false)
const unapproving = ref(false)
const showRejectDialog = ref(false)
const rejectReason = ref('')
const approveReceipt = async () => {
approving.value = true
try {
const result = await store.approveReceipt(receipt.value.id)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost validat',
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 valida bonul',
life: 5000,
})
} finally {
approving.value = false
}
}
const openRejectDialog = () => {
rejectReason.value = ''
showRejectDialog.value = true
}
const rejectReceipt = async () => {
if (!rejectReason.value || rejectReason.value.length < 5) {
toast.add({
severity: 'warn',
summary: 'Atentie',
detail: 'Motivul respingerii trebuie sa aiba minim 5 caractere',
life: 3000,
})
return
}
rejecting.value = true
try {
const result = await store.rejectReceipt(receipt.value.id, rejectReason.value)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost respins',
life: 3000,
})
showRejectDialog.value = false
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 respinge bonul',
life: 5000,
})
} finally {
rejecting.value = false
}
}
const unapproveReceipt = async () => {
unapproving.value = true
try {
const result = await store.unapproveReceipt(receipt.value.id)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Validarea a fost anulata',
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 anula validarea',
life: 5000,
})
} finally {
unapproving.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)
if (!isEditMode.value && selectedFiles.value.length === 0 && !ocrFile.value) {
toast.add({
severity: 'warn',
summary: 'Validare',
detail: 'Trebuie sa adaugi cel putin o poza a bonului',
life: 3000,
})
return false
}
if (!form.value.receipt_date) {
toast.add({
severity: 'warn',
summary: 'Validare',
detail: 'Data bonului este obligatorie',
life: 3000,
})
return false
}
if (!form.value.amount || form.value.amount <= 0) {
toast.add({
severity: 'warn',
summary: 'Validare',
detail: 'Suma trebuie sa fie mai mare decat 0',
life: 3000,
})
return false
}
if (!form.value.expense_type_code) {
toast.add({
severity: 'warn',
summary: 'Validare',
detail: 'Tipul cheltuielii este obligatoriu',
life: 3000,
})
return false
}
// Payment mode is validated at submit time, not at draft save
// (can save draft without payment mode, but submit requires it)
return true
}
const saveReceipt = async () => {
if (!validateForm()) return
saving.value = true
try {
// Clean up payment_methods and tva_breakdown - convert null amounts to 0
const cleanedPaymentMethods = form.value.payment_methods?.map(pm => ({
...pm,
amount: pm.amount ?? 0
})) || null
const cleanedTvaBreakdown = form.value.tva_breakdown?.map(entry => ({
...entry,
amount: entry.amount ?? 0
})) || null
const data = {
...form.value,
receipt_date: form.value.receipt_date.toISOString().split('T')[0],
payment_methods: cleanedPaymentMethods,
tva_breakdown: cleanedTvaBreakdown,
}
let savedReceipt
if (isEditMode.value) {
savedReceipt = await store.updateReceipt(receiptId.value, data)
} else {
savedReceipt = await store.createReceipt(data)
}
// Upload new files
for (const file of selectedFiles.value) {
try {
await store.uploadAttachment(savedReceipt.id, file)
} catch (error) {
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(`/receipt/${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 () => {
// First save any changes
if (!validateForm()) return
submitting.value = true
try {
// Save first
await saveReceipt()
// Then submit
const result = await store.submitReceipt(receiptId.value)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost trimis spre aprobare',
life: 3000,
})
router.push('/')
} 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
}
}
</script>
<style scoped>
/* Action Buttons - Bottom Right */
.action-buttons-top {
position: fixed;
bottom: 1.5rem;
right: 1.5rem;
display: flex;
flex-direction: row;
gap: 0.5rem;
z-index: 100;
}
.floating-buttons-spacer {
height: 20px;
}
/* Validation banners */
.validation-banners {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
margin-bottom: 1rem;
}
.validation-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-radius: 8px;
background: #fef3c7;
border: 1px solid #fbbf24;
color: #92400e;
font-size: 0.875rem;
}
.validation-banner.warning {
background: #fee2e2;
border-color: #f87171;
color: #991b1b;
}
.validation-banner i {
font-size: 1.1rem;
}
/* 2-column layout */
.receipt-form-layout {
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(380px, 1.5fr);
gap: 1.5rem;
align-items: start;
}
.form-column-left {
position: sticky;
top: 1rem;
}
.form-column-right {
min-width: 0;
}
.form-column-right h3 {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
margin-top: 0;
}
@media (max-width: 1024px) {
.receipt-form-layout {
grid-template-columns: 1fr;
}
.form-column-left {
position: static;
}
}
/* OCR Applied Banner (collapsed state) */
.ocr-applied-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
background: #dcfce7;
border: 1px solid #86efac;
border-radius: 8px;
cursor: pointer;
margin-top: 0.75rem;
transition: background 0.2s;
}
.ocr-applied-banner:hover {
background: #bbf7d0;
}
.ocr-applied-banner .pi-check-circle {
color: #22c55e;
font-size: 1.1rem;
}
.ocr-applied-banner span {
flex: 1;
font-weight: 500;
color: #166534;
font-size: 0.9rem;
}
.ocr-applied-banner .pi-chevron-down {
color: #166534;
font-size: 0.8rem;
}
.upload-section {
margin-bottom: 1rem;
}
.upload-section h3 {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
margin-top: 0;
font-size: 1rem;
}
/* Hidden file input */
.hidden-file-input {
display: none;
}
/* Add attachment button in header */
.add-attachment-btn {
margin-left: auto;
}
/* Pending files header */
.pending-files-header {
font-size: 0.85rem;
color: #64748b;
font-weight: 500;
margin-bottom: 0.5rem;
padding-bottom: 0.35rem;
border-bottom: 1px dashed #cbd5e1;
}
.radio-group {
display: flex;
gap: 1.5rem;
}
.radio-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pdf-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
background: #f5f5f5;
padding: 1rem;
}
.pdf-preview span {
font-size: 0.75rem;
margin-top: 0.5rem;
text-align: center;
word-break: break-word;
}
/* Selected files list */
.selected-files-list {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.selected-file-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.selected-file-item i {
color: #667eea;
font-size: 1.25rem;
}
.selected-file-item .file-name {
flex: 1;
font-weight: 500;
color: #1e293b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.selected-file-item .file-size {
font-size: 0.85rem;
color: #64748b;
}
/* Extra details section (TVA, items, address) */
.extra-details-section {
margin-top: 1rem;
padding: 0.75rem;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 8px;
}
.extra-details-section h3 {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
margin-top: 0;
color: #0284c7;
font-size: 0.95rem;
}
.tva-table {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tva-row {
display: flex;
align-items: center;
gap: 1rem;
}
.tva-row.total {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px dashed #0284c7;
}
.tva-label {
min-width: 150px;
font-weight: 500;
color: #334155;
}
.tva-input {
max-width: 150px;
}
.tva-value {
font-weight: 600;
color: #0284c7;
}
/* Supplier warning */
.supplier-warning {
display: flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.25rem;
color: #f59e0b;
}
/* Supplier selected indicator */
.supplier-selected {
display: flex;
align-items: center;
gap: 0.25rem;
margin-top: 0.25rem;
color: #22c55e;
font-weight: 500;
}
/* Field hint */
.field-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: #64748b;
}
/* Payment methods display */
.payment-methods-display {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Dialog content */
.dialog-content {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.dialog-content p {
margin: 0;
line-height: 1.6;
}
.dialog-content p:first-child {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.dialog-content .form-field {
margin-bottom: 0.5rem;
}
.dialog-content .form-field label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
color: #334155;
}
/* ========================================
COMPACT FORM SECTIONS (matching OCRPreview)
======================================== */
.form-section {
padding: 0.6rem 0;
border-bottom: 1px solid #e2e8f0;
}
.form-section:last-of-type {
border-bottom: none;
}
.form-section-title {
font-size: 0.7rem;
font-weight: 600;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.form-section-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* Compact form rows */
.form-row {
display: flex;
gap: 0.75rem;
align-items: flex-start;
flex-wrap: wrap;
}
.form-row .form-field {
margin-bottom: 0;
}
.form-field.flex-1 {
flex: 1;
min-width: 150px;
}
.form-field.flex-2 {
flex: 2;
min-width: 200px;
}
/* Inline radio groups */
.radio-group-inline {
display: flex;
gap: 1rem;
padding: 0.35rem 0;
}
.radio-group-inline .radio-item {
display: flex;
align-items: center;
gap: 0.35rem;
}
.radio-group-inline .radio-item label {
font-size: 0.9rem;
margin-bottom: 0;
}
/* Direction header with auto-detect indicator */
.direction-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.35rem;
}
.direction-header .field-label {
font-size: 0.85rem;
font-weight: 500;
color: #475569;
}
.auto-tag {
font-size: 0.65rem !important;
padding: 0.15rem 0.4rem !important;
cursor: help;
}
/* Amount input styling */
.amount-input {
max-width: 150px;
}
/* Payment field wrapper with OCR indicator */
.payment-field-wrapper {
display: flex;
align-items: center;
gap: 0.75rem;
}
.payment-field-wrapper .p-dropdown {
flex: 1;
}
.ocr-indicator {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: #22c55e;
font-weight: 500;
white-space: nowrap;
}
.ocr-indicator .pi-check-circle {
font-size: 0.85rem;
}
/* TVA edit table - compact */
.tva-edit-table {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.tva-edit-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.tva-label-compact {
min-width: 80px;
font-size: 0.85rem;
color: #475569;
}
.tva-input-compact {
max-width: 120px;
}
.tva-edit-row.tva-total-row {
margin-top: 0.35rem;
padding-top: 0.35rem;
border-top: 1px dashed #cbd5e1;
}
.tva-total-value {
font-weight: 600;
color: #0284c7;
font-size: 0.9rem;
}
/* Payment methods display in form */
.payment-methods-display {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.35rem;
}
/* ========================================
FLAT LAYOUT STYLES (with subtle borders)
======================================== */
/* Form groups with subtle borders */
.form-group {
padding: 0.75rem 0;
border-bottom: 1px solid #e2e8f0;
}
.form-group:first-child {
padding-top: 0;
}
.form-group-last {
border-bottom: none;
padding-bottom: 0;
}
/* Compact form row spacing */
.form-group > .form-row {
margin-bottom: 0.6rem;
}
.form-group > .form-row:last-child {
margin-bottom: 0;
}
/* Collapsed address - clickable */
.address-collapsed {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.3rem 0.5rem;
margin-top: 0.4rem;
background: #f8fafc;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.address-collapsed:hover {
background: #f1f5f9;
}
.address-collapsed i {
font-size: 0.7rem;
color: #64748b;
}
.address-preview {
font-size: 0.75rem;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 300px;
}
/* Expanded address */
.address-expanded {
margin-top: 0.4rem;
padding: 0.4rem;
background: #f8fafc;
border-radius: 4px;
}
.address-expanded .p-inputtext {
font-size: 0.85rem;
width: 100%;
}
/* Document row - all fields inline */
.document-row-inline {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
align-items: flex-end;
}
.document-row-inline .form-field {
flex: 0 0 auto;
}
/* ========================================
VALUES SECTION (compact inline)
======================================== */
.values-section {
background: #f8fafc;
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
.values-row-inline {
display: flex;
align-items: flex-end;
gap: 0.75rem;
flex-wrap: wrap;
}
.value-item {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.value-item label {
font-size: 0.75rem;
font-weight: 500;
color: #64748b;
text-transform: uppercase;
}
.input-compact {
width: 130px;
}
.dropdown-payment {
min-width: 160px;
}
.payment-method-item {
padding-left: 0.5rem;
}
.payment-method-value {
display: inline-block;
font-size: 0.95rem;
font-weight: 500;
color: #334155;
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 0.5rem 0.75rem;
border-radius: 6px;
min-width: 90px;
text-align: right;
}
/* TVA compact row */
.tva-compact {
margin-top: 0.5rem;
padding-top: 0.5rem;
border-top: 1px dashed #cbd5e1;
}
.tva-item {
flex: 0 0 auto;
}
.input-tva {
width: 110px !important;
}
:deep(.input-tva) {
width: 110px !important;
}
:deep(.input-tva .p-inputnumber-input) {
width: 110px !important;
}
.tva-total-item {
padding-left: 0.5rem;
}
.tva-total-value {
display: inline-block;
font-size: 0.95rem;
font-weight: 500;
color: #334155;
background: #f8fafc;
border: 1px solid #e2e8f0;
padding: 0.5rem 0.75rem;
border-radius: 6px;
min-width: 90px;
text-align: right;
}
/* Small labels for secondary fields */
.label-small {
font-size: 0.8rem !important;
color: #64748b !important;
}
/* Muted labels for optional fields */
.label-muted {
color: #94a3b8 !important;
}
/* Small inputs */
.input-small {
font-size: 0.85rem;
}
/* Compact dropdowns */
.dropdown-compact {
min-width: 130px;
}
/* Compact message */
.message-compact {
padding: 0.35rem 0.5rem;
font-size: 0.8rem;
}
/* Payment methods inline */
.payment-methods-inline {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
padding-top: 0.25rem;
}
.payment-tags-field {
display: flex;
flex-direction: column;
}
/* TVA row inline */
.tva-row-inline {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
align-items: flex-end;
padding: 0.5rem 0;
margin-top: 0.5rem;
background: #f8fafc;
border-radius: 4px;
padding: 0.5rem;
}
.tva-field {
flex: 0 0 auto;
}
.tva-input-inline {
max-width: 100px;
}
.tva-total-field {
display: flex;
flex-direction: column;
padding-left: 0.5rem;
}
.tva-total-inline {
font-weight: 500;
color: #334155;
font-size: 0.95rem;
padding: 0.5rem 0;
}
/* Optional fields row */
.optional-fields {
background: #f8fafc;
padding: 0.5rem;
border-radius: 4px;
margin-top: 0.4rem;
}
/* Items count field */
.items-count-field {
flex: 0 0 auto;
max-width: 80px;
}
.items-count-input {
max-width: 70px;
}
/* Direction header (dropdown version) */
.direction-header {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.direction-header label {
margin-bottom: 0;
}
/* Responsive adjustments */
@media (max-width: 768px) {
/* Furnizor/CUI row - full width each */
.form-row {
flex-direction: column;
gap: 0.75rem;
}
.form-field.flex-2,
.form-field.flex-1 {
width: 100%;
min-width: unset;
}
/* Document row (Tip/Nr/Data/Operatiune) - 2x2 grid */
.document-row-inline {
display: grid !important;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.document-row-inline .form-field {
width: 100%;
}
/* Values row (TOTAL/Mod Plata) - 2 columns */
.values-row-inline {
display: grid !important;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.values-row-inline .value-item {
min-width: unset;
}
/* TVA row - 2 columns */
.values-row-inline.tva-compact {
grid-template-columns: 1fr 1fr;
}
/* Optional fields row - stack on very small screens */
.optional-fields {
flex-direction: row;
flex-wrap: wrap;
}
.optional-fields .form-field.flex-2 {
flex: 2;
min-width: 180px;
}
.optional-fields .items-count-field {
flex: 0 0 80px;
min-width: 80px;
}
/* Dropdowns and inputs full width in their containers */
.form-field :deep(.p-dropdown),
.form-field :deep(.p-autocomplete),
.form-field :deep(.p-calendar) {
width: 100% !important;
}
/* Action buttons on mobile - not fixed, flow with content */
.action-buttons-top {
position: static;
width: 100%;
padding: 1rem;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
margin-top: 1rem;
display: flex;
flex-direction: row;
justify-content: flex-end;
gap: 0.5rem;
}
/* Smaller buttons on mobile */
.action-buttons-top .p-button {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
/* No extra space needed since buttons flow with content */
.floating-buttons-spacer {
display: none;
}
}
/* ========================================
VIEW MODE STYLES
======================================== */
/* Status Badge */
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
margin-top: 0.5rem;
}
.status-draft {
background: #f1f5f9;
color: #475569;
}
.status-pending {
background: #fef3c7;
color: #92400e;
}
.status-approved {
background: #dcfce7;
color: #166534;
}
.status-rejected {
background: #fee2e2;
color: #991b1b;
}
.status-synced {
background: #dbeafe;
color: #1e40af;
}
/* Rejection Alert */
.rejection-alert {
display: flex;
gap: 1rem;
padding: 1rem;
background: #fff3e0;
border-radius: 8px;
margin-bottom: 1rem;
}
.rejection-alert i {
font-size: 1.5rem;
color: #f57c00;
}
.rejection-alert p {
margin: 0.5rem 0;
}
.rejection-alert small {
color: #666;
}
/* Attachments Grid (view mode) */
.attachments-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.attachment-item {
border-radius: 8px;
overflow: hidden;
position: relative;
border: 1px solid #e2e8f0;
background: #f8fafc;
}
.attachment-image {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
}
.pdf-attachment {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
gap: 0.5rem;
}
.pdf-attachment i {
font-size: 2.5rem;
color: #dc2626;
}
.pdf-attachment span {
font-size: 0.85rem;
color: #475569;
text-align: center;
word-break: break-word;
}
.attachment-item .download-btn {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
}
/* Attachment actions (edit mode) */
.attachment-actions {
position: absolute;
bottom: 0.5rem;
right: 0.5rem;
display: flex;
gap: 0.35rem;
}
/* Image preview grid (edit mode) */
.image-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 0.75rem;
margin-bottom: 1rem;
}
.image-preview-item {
position: relative;
border-radius: 8px;
overflow: hidden;
border: 1px solid #e2e8f0;
background: #f8fafc;
}
.image-preview-item img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
}
.image-placeholder {
width: 100%;
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
background: #f1f5f9;
color: #94a3b8;
font-size: 0.75rem;
text-align: center;
padding: 0.5rem;
}
.image-placeholder span {
word-break: break-all;
max-width: 100%;
}
/* Empty State */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
color: #94a3b8;
text-align: center;
}
.empty-state i {
font-size: 2.5rem;
margin-bottom: 0.75rem;
}
.empty-state p {
margin: 0;
}
/* Accounting Entries Section */
.entries-section {
margin-top: 1.5rem;
padding: 1rem;
background: #f8fafc;
border-radius: 8px;
}
.entries-section h3 {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0 0 1rem 0;
color: #334155;
}
.entries-table-container {
overflow-x: auto;
}
.entries-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.entries-table th,
.entries-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
.entries-table th {
background: #f1f5f9;
font-weight: 600;
color: #475569;
}
.entries-table tbody tr:hover {
background: #f8fafc;
}
.entries-table .debit {
color: #dc2626;
}
.entries-table .credit {
color: #16a34a;
}
.entries-table tfoot td {
border-bottom: none;
padding-top: 1rem;
}
.balance-warning {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.75rem;
background: #fff3e0;
border-radius: 8px;
color: #f57c00;
}
</style>