- 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>
2940 lines
76 KiB
Vue
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>
|