feat(data-entry): Add unified receipt form with OCR confidence tracking
New unified receipt creation system with: - UnifiedReceiptForm component with inline OCR preview and confidence indicators - Compact upload zone with drag-drop and camera support - TVA and Payment fields with dynamic add/remove - Supplier dual-field with autocomplete and OCR hint - Receipt form sections with collapsible auxiliary data Backend OCR improvements: - Add confidence_tva and confidence_payment to extraction results - Update TVA extraction to return confidence scores - Include TVA (15%) and payment (10%) in overall_confidence calculation Also includes: - CSS design system rules documentation - Port check helper function for service scripts - Expanded design tokens documentation in CLAUDE.md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
321
src/modules/data-entry/components/receipts/AuxiliarySection.vue
Normal file
321
src/modules/data-entry/components/receipts/AuxiliarySection.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div class="auxiliary-section">
|
||||
<!-- Expense Type -->
|
||||
<div class="aux-field">
|
||||
<label>Tip Cheltuiala</label>
|
||||
<Dropdown
|
||||
:modelValue="expenseType"
|
||||
@update:modelValue="$emit('update:expenseType', $event)"
|
||||
:options="expenseTypes"
|
||||
optionLabel="name"
|
||||
optionValue="code"
|
||||
placeholder="Selecteaza tip cheltuiala"
|
||||
:disabled="disabled"
|
||||
class="expense-dropdown dropdown-borderless"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div class="aux-field">
|
||||
<label>Descriere</label>
|
||||
<Textarea
|
||||
:modelValue="description"
|
||||
@update:modelValue="$emit('update:description', $event)"
|
||||
rows="2"
|
||||
placeholder="Descriere optionala..."
|
||||
:disabled="disabled"
|
||||
class="description-textarea"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Attachments -->
|
||||
<div class="aux-field attachments-field">
|
||||
<div class="attachments-header">
|
||||
<label>Atasamente</label>
|
||||
<Button
|
||||
v-if="!disabled"
|
||||
icon="pi pi-plus"
|
||||
label="Adauga"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
@click="triggerFileInput"
|
||||
/>
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
multiple
|
||||
class="hidden-input"
|
||||
@change="onFilesSelected"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Existing attachments -->
|
||||
<div v-if="attachments.length || newFiles.length" class="attachments-grid">
|
||||
<!-- Existing attachments -->
|
||||
<div
|
||||
v-for="att in attachments"
|
||||
:key="att.id"
|
||||
class="attachment-item"
|
||||
>
|
||||
<div class="attachment-preview">
|
||||
<i :class="att.mime_type?.startsWith('image/') ? 'pi pi-image' : 'pi pi-file-pdf'"></i>
|
||||
<span class="attachment-name">{{ truncateFilename(att.filename) }}</span>
|
||||
</div>
|
||||
<div class="attachment-actions">
|
||||
<Button
|
||||
icon="pi pi-download"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
@click="$emit('download-attachment', att)"
|
||||
/>
|
||||
<Button
|
||||
v-if="!disabled"
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
@click="$emit('remove-attachment', att.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New files (pending upload) -->
|
||||
<div
|
||||
v-for="(file, idx) in newFiles"
|
||||
:key="'new-' + idx"
|
||||
class="attachment-item new-file"
|
||||
>
|
||||
<div class="attachment-preview">
|
||||
<i :class="file.type?.startsWith('image/') ? 'pi pi-image' : 'pi pi-file-pdf'"></i>
|
||||
<span class="attachment-name">{{ truncateFilename(file.name) }}</span>
|
||||
<span class="file-size">({{ formatFileSize(file.size) }})</span>
|
||||
</div>
|
||||
<div class="attachment-actions">
|
||||
<Button
|
||||
v-if="!disabled"
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="danger"
|
||||
text
|
||||
rounded
|
||||
@click="$emit('remove-file', idx)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="attachments-empty">
|
||||
<i class="pi pi-image"></i>
|
||||
<span>Niciun atasament</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const props = defineProps({
|
||||
expenseType: { type: String, default: null },
|
||||
description: { type: String, default: '' },
|
||||
expenseTypes: { type: Array, default: () => [] },
|
||||
attachments: { type: Array, default: () => [] },
|
||||
newFiles: { type: Array, default: () => [] },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:expenseType',
|
||||
'update:description',
|
||||
'add-files',
|
||||
'remove-file',
|
||||
'remove-attachment',
|
||||
'download-attachment',
|
||||
])
|
||||
|
||||
const fileInputRef = ref(null)
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
const onFilesSelected = async (event) => {
|
||||
const files = Array.from(event.target?.files || [])
|
||||
if (files.length === 0) return
|
||||
|
||||
// Clone files to avoid Android SnapshotState issue
|
||||
const clonedFiles = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
return new File([arrayBuffer], file.name, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
})
|
||||
} catch (e) {
|
||||
console.warn('File clone failed:', e)
|
||||
return file
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
emit('add-files', clonedFiles)
|
||||
|
||||
// Reset input
|
||||
if (fileInputRef.value) {
|
||||
fileInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const truncateFilename = (name, maxLen = 20) => {
|
||||
if (!name || name.length <= maxLen) return name
|
||||
const ext = name.split('.').pop()
|
||||
const base = name.substring(0, name.length - ext.length - 1)
|
||||
const truncatedBase = base.substring(0, maxLen - ext.length - 4)
|
||||
return `${truncatedBase}...${ext}`
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auxiliary-section {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.aux-field {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.aux-field:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.aux-field label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.expense-dropdown {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.description-textarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Attachments */
|
||||
.attachments-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.attachments-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.attachment-item.new-file {
|
||||
background: var(--blue-50);
|
||||
border-color: var(--blue-200);
|
||||
}
|
||||
|
||||
.attachment-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.attachment-preview i {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.attachment-name {
|
||||
color: var(--text-color);
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.attachment-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.attachments-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
color: var(--text-color-secondary);
|
||||
font-size: 0.85rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.attachments-empty i {
|
||||
font-size: 1.25rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.attachments-grid {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.attachment-item {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
[data-theme="dark"] .attachment-item.new-file {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: var(--blue-700);
|
||||
}
|
||||
</style>
|
||||
511
src/modules/data-entry/components/receipts/CompactUploadZone.vue
Normal file
511
src/modules/data-entry/components/receipts/CompactUploadZone.vue
Normal file
@@ -0,0 +1,511 @@
|
||||
<template>
|
||||
<div class="compact-upload-zone">
|
||||
<div
|
||||
class="upload-strip"
|
||||
:class="{ 'dragging': isDragging, 'processing': processing, 'has-file': selectedFile }"
|
||||
@dragover.prevent="onDragOver"
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
@click="triggerFileInput"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
class="hidden-input"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
|
||||
<!-- Processing state -->
|
||||
<div v-if="processing" class="strip-content processing-state">
|
||||
<ProgressSpinner style="width: 24px; height: 24px" strokeWidth="4" />
|
||||
<span class="processing-text">{{ processingMessage }}</span>
|
||||
<span class="processing-subtext" v-if="processingSubtext">{{ processingSubtext }}</span>
|
||||
</div>
|
||||
|
||||
<!-- File selected state -->
|
||||
<div v-else-if="selectedFile" class="strip-content file-state">
|
||||
<i class="pi pi-check-circle" style="color: var(--green-500);"></i>
|
||||
<span class="file-name">{{ selectedFile.name }}</span>
|
||||
<span class="file-size">({{ formatFileSize(selectedFile.size) }})</span>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="strip-content empty-state">
|
||||
<i class="pi pi-camera"></i>
|
||||
<span v-if="isDragging">Elibereaza pentru a incarca</span>
|
||||
<span v-else>Trage poza bonului aici sau click pentru a selecta</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action bar (inline with file strip) -->
|
||||
<div v-if="selectedFile && !processing" class="action-bar">
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click.stop="triggerFileInput"
|
||||
v-tooltip.top="'Schimba fisier'"
|
||||
/>
|
||||
<Dropdown
|
||||
v-model="selectedEngine"
|
||||
:options="engineOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="OCR"
|
||||
class="engine-selector dropdown-borderless"
|
||||
@click.stop
|
||||
/>
|
||||
<Button
|
||||
label="OCR"
|
||||
icon="pi pi-play"
|
||||
size="small"
|
||||
@click.stop="processOCR"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<Message v-if="error" severity="error" :closable="true" @close="error = null" class="error-message">
|
||||
{{ error }}
|
||||
</Message>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Button from 'primevue/button'
|
||||
import api from '@data-entry/services/api'
|
||||
import { useOCRSettingsStore } from '@data-entry/stores/ocrSettingsStore'
|
||||
|
||||
const emit = defineEmits(['ocr-result', 'file-selected', 'error'])
|
||||
|
||||
const fileInput = ref(null)
|
||||
const selectedFile = ref(null)
|
||||
const isDragging = ref(false)
|
||||
const processing = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
// OCR Settings Store
|
||||
const ocrStore = useOCRSettingsStore()
|
||||
const selectedEngine = ref('auto')
|
||||
|
||||
// Engine config
|
||||
const engineConfig = {
|
||||
'auto': { label: 'Auto', desc: 'docTR→Paddle→Tess' },
|
||||
'doctr': { label: 'docTR', desc: 'Rapid, buna acuratete' },
|
||||
'doctr_plus': { label: 'docTR Plus', desc: '2 treceri (recomandat)' },
|
||||
'paddleocr': { label: 'PaddleOCR', desc: 'Cea mai buna calitate' },
|
||||
'tesseract': { label: 'Tesseract', desc: 'Cel mai rapid' },
|
||||
'hybrid': { label: 'Hybrid', desc: 'docTR+Tess paralel' },
|
||||
'hybrid-quality': { label: 'Hybrid Calitate', desc: 'Acuratete max' },
|
||||
}
|
||||
|
||||
const engineOptions = computed(() => {
|
||||
return ocrStore.availableEngines.map(engine => ({
|
||||
label: engineConfig[engine]?.label || engine,
|
||||
desc: engineConfig[engine]?.desc || '',
|
||||
value: engine
|
||||
}))
|
||||
})
|
||||
|
||||
// Job queue state
|
||||
const jobId = ref(null)
|
||||
const queuePosition = ref(null)
|
||||
const estimatedWait = ref(null)
|
||||
const jobStatus = ref(null)
|
||||
|
||||
const processingMessage = computed(() => {
|
||||
if (jobStatus.value === 'pending' && queuePosition.value > 0) {
|
||||
return `In coada (${queuePosition.value})`
|
||||
}
|
||||
if (jobStatus.value === 'processing') {
|
||||
return 'Se proceseaza...'
|
||||
}
|
||||
return 'Se trimite...'
|
||||
})
|
||||
|
||||
const processingSubtext = computed(() => {
|
||||
if (jobStatus.value === 'pending' && estimatedWait.value > 0) {
|
||||
return `~${estimatedWait.value}s`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
// Load user preference on mount
|
||||
onMounted(async () => {
|
||||
await ocrStore.loadPreference()
|
||||
selectedEngine.value = ocrStore.preferredEngine
|
||||
})
|
||||
|
||||
// Save preference when changed
|
||||
watch(selectedEngine, async (newEngine, oldEngine) => {
|
||||
if (oldEngine && newEngine !== oldEngine && ocrStore.initialized) {
|
||||
try {
|
||||
await ocrStore.setPreference(newEngine)
|
||||
} catch (err) {
|
||||
console.error('[CompactUploadZone] Failed to save preference:', err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const onDragOver = () => { isDragging.value = true }
|
||||
const onDragLeave = () => { isDragging.value = false }
|
||||
|
||||
const onDrop = (event) => {
|
||||
isDragging.value = false
|
||||
const files = event.dataTransfer?.files
|
||||
if (files?.length > 0) handleFile(files[0])
|
||||
}
|
||||
|
||||
const triggerFileInput = () => { fileInput.value?.click() }
|
||||
|
||||
const onFileSelected = (event) => {
|
||||
const files = event.target?.files
|
||||
if (files?.length > 0) handleFile(files[0])
|
||||
}
|
||||
|
||||
const handleFile = async (file) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
error.value = 'Tip de fisier invalid. Acceptate: JPG, PNG, PDF'
|
||||
return
|
||||
}
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
error.value = 'Fisier prea mare. Max 10MB.'
|
||||
return
|
||||
}
|
||||
|
||||
error.value = null
|
||||
|
||||
// Clone file to avoid Android SnapshotState issue
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const clonedFile = new File([arrayBuffer], file.name, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
})
|
||||
selectedFile.value = clonedFile
|
||||
emit('file-selected', clonedFile)
|
||||
} catch (e) {
|
||||
console.warn('File clone failed:', e)
|
||||
selectedFile.value = file
|
||||
emit('file-selected', file)
|
||||
}
|
||||
}
|
||||
|
||||
const processOCR = async () => {
|
||||
if (!selectedFile.value) return
|
||||
|
||||
processing.value = true
|
||||
error.value = null
|
||||
jobId.value = null
|
||||
queuePosition.value = null
|
||||
estimatedWait.value = null
|
||||
jobStatus.value = 'submitting'
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile.value)
|
||||
|
||||
// Submit job
|
||||
const submitResponse = await api.post(`/ocr/extract?engine=${selectedEngine.value}`, formData, {
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
const job = submitResponse.data
|
||||
jobId.value = job.job_id
|
||||
queuePosition.value = job.queue_position
|
||||
estimatedWait.value = job.estimated_wait_seconds
|
||||
jobStatus.value = job.status
|
||||
|
||||
// Poll for result
|
||||
await pollJobStatus(job.job_id)
|
||||
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.detail || err.message || 'Eroare OCR'
|
||||
error.value = message
|
||||
emit('error', message)
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const pollJobStatus = async (id) => {
|
||||
const LONG_POLL_TIMEOUT = 30
|
||||
const MAX_TOTAL_TIME = 120
|
||||
const MIN_POLL_INTERVAL = 500
|
||||
const startTime = Date.now()
|
||||
|
||||
const poll = async () => {
|
||||
const elapsed = (Date.now() - startTime) / 1000
|
||||
if (elapsed >= MAX_TOTAL_TIME) {
|
||||
processing.value = false
|
||||
error.value = 'Timeout - procesare prea lunga'
|
||||
emit('error', error.value)
|
||||
return
|
||||
}
|
||||
|
||||
const pollStartTime = Date.now()
|
||||
|
||||
try {
|
||||
const response = await api.get(`/ocr/jobs/${id}/wait`, {
|
||||
params: { timeout: LONG_POLL_TIMEOUT, _t: Date.now() },
|
||||
timeout: (LONG_POLL_TIMEOUT + 5) * 1000,
|
||||
headers: { 'Cache-Control': 'no-cache' }
|
||||
})
|
||||
|
||||
const job = response.data
|
||||
jobStatus.value = job.status
|
||||
queuePosition.value = job.queue_position
|
||||
estimatedWait.value = job.estimated_wait_seconds
|
||||
|
||||
if (job.status === 'completed') {
|
||||
processing.value = false
|
||||
if (job.result) {
|
||||
emit('ocr-result', {
|
||||
...job.result,
|
||||
_processing_time_ms: job.processing_time_ms
|
||||
})
|
||||
} else {
|
||||
error.value = 'OCR completat dar fara rezultat'
|
||||
emit('error', error.value)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (job.status === 'failed') {
|
||||
processing.value = false
|
||||
error.value = job.error || 'Procesare OCR esuata'
|
||||
emit('error', error.value)
|
||||
return
|
||||
}
|
||||
|
||||
// Still pending/processing
|
||||
if (processing.value) {
|
||||
const pollDuration = Date.now() - pollStartTime
|
||||
if (pollDuration < MIN_POLL_INTERVAL) {
|
||||
await new Promise(resolve => setTimeout(resolve, MIN_POLL_INTERVAL - pollDuration))
|
||||
}
|
||||
await poll()
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (err.code === 'ECONNABORTED' || err.message?.includes('timeout')) {
|
||||
if (processing.value) await poll()
|
||||
return
|
||||
}
|
||||
|
||||
const elapsed = (Date.now() - startTime) / 1000
|
||||
if (elapsed < MAX_TOTAL_TIME && processing.value) {
|
||||
await new Promise(resolve => setTimeout(resolve, MIN_POLL_INTERVAL))
|
||||
await poll()
|
||||
return
|
||||
}
|
||||
|
||||
processing.value = false
|
||||
error.value = 'Eroare verificare job'
|
||||
emit('error', error.value)
|
||||
}
|
||||
}
|
||||
|
||||
await poll()
|
||||
}
|
||||
|
||||
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 reset = () => {
|
||||
selectedFile.value = null
|
||||
error.value = null
|
||||
jobId.value = null
|
||||
queuePosition.value = null
|
||||
estimatedWait.value = null
|
||||
jobStatus.value = null
|
||||
processing.value = false
|
||||
if (fileInput.value) fileInput.value.value = ''
|
||||
}
|
||||
|
||||
defineExpose({ reset, processOCR })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.compact-upload-zone {
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.upload-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
padding: 2px var(--space-sm); /* reduced vertical padding */
|
||||
border: 1px dashed var(--surface-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface-ground);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.upload-strip:hover {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.upload-strip.dragging {
|
||||
border-color: var(--primary-500);
|
||||
background: var(--primary-50);
|
||||
}
|
||||
|
||||
.upload-strip.processing {
|
||||
cursor: default;
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
.upload-strip.has-file {
|
||||
border-style: solid;
|
||||
border-color: var(--green-300);
|
||||
background: var(--green-50);
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.strip-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-sm); /* bigger - 0.875rem */
|
||||
}
|
||||
|
||||
.strip-content i {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
color: var(--primary-500);
|
||||
}
|
||||
|
||||
.file-state {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: var(--font-medium);
|
||||
max-width: 200px; /* wider */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.processing-state {
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.processing-text {
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.processing-subtext {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Action bar - single line */
|
||||
.action-bar {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--space-sm);
|
||||
margin-top: 2px; /* minimal margin */
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-bar :deep(.p-button) {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--text-xs);
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.action-bar :deep(.p-button .p-button-icon) {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.engine-selector {
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.engine-selector :deep(.p-dropdown) {
|
||||
padding: 0 var(--space-xs);
|
||||
font-size: var(--text-xs);
|
||||
min-height: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.engine-selector :deep(.p-dropdown-label) {
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.engine-selector :deep(.p-dropdown-trigger) {
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
.engine-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.engine-label {
|
||||
font-weight: var(--font-medium);
|
||||
font-size: var(--text-xs); /* 12px - uniform */
|
||||
}
|
||||
|
||||
.engine-desc {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.error-message :deep(.p-message) {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--text-xs); /* 12px - uniform */
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 480px) {
|
||||
.strip-content span:not(.file-name) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.strip-content .file-name {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.engine-selector {
|
||||
min-width: 90px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,193 @@
|
||||
<template>
|
||||
<div class="pay-section">
|
||||
<!-- Payment fields - NO HEADER -->
|
||||
<div class="pay-grid">
|
||||
<!-- CARD -->
|
||||
<div class="pay-item" :class="{ active: modelValue.CARD > 0 }">
|
||||
<label class="pay-label">Card</label>
|
||||
<InputNumber
|
||||
v-model="modelValue.CARD"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="2"
|
||||
:disabled="disabled"
|
||||
placeholder="0.00"
|
||||
class="pay-input"
|
||||
@input="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- NUMERAR -->
|
||||
<div class="pay-item" :class="{ active: modelValue.NUMERAR > 0 }">
|
||||
<label class="pay-label">Cash</label>
|
||||
<InputNumber
|
||||
v-model="modelValue.NUMERAR"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="2"
|
||||
:disabled="disabled"
|
||||
placeholder="0.00"
|
||||
class="pay-input"
|
||||
@input="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ALTE (simple label + input, no dropdown) -->
|
||||
<div class="pay-item" :class="{ active: modelValue.ALTE?.amount > 0 }">
|
||||
<label class="pay-label">Alte</label>
|
||||
<InputNumber
|
||||
v-model="modelValue.ALTE.amount"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="2"
|
||||
:disabled="disabled"
|
||||
placeholder="0.00"
|
||||
class="pay-input"
|
||||
@input="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mismatch warning message -->
|
||||
<div v-if="showMismatch" class="mismatch-msg">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
Plati ({{ formatAmount(computedTotal) }}) ≠ Total ({{ formatAmount(total) }})
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, required: true },
|
||||
total: { type: Number, default: null },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const computedTotal = computed(() => {
|
||||
return (props.modelValue.CARD || 0) +
|
||||
(props.modelValue.NUMERAR || 0) +
|
||||
(props.modelValue.ALTE?.amount || 0)
|
||||
})
|
||||
|
||||
const showMismatch = computed(() => {
|
||||
if (!props.total || props.total === 0) return false
|
||||
if (computedTotal.value === 0) return false
|
||||
return Math.abs(computedTotal.value - props.total) > 0.02
|
||||
})
|
||||
|
||||
const formatAmount = (amount) => {
|
||||
return parseFloat(amount || 0).toLocaleString('ro-RO', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
}
|
||||
|
||||
const emitUpdate = () => {
|
||||
emit('update:modelValue', { ...props.modelValue })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* === PAYMENT SECTION - simple grid === */
|
||||
.pay-section {
|
||||
padding: var(--space-sm) 0;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
/* Payment grid - horizontal */
|
||||
.pay-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
.pay-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.pay-item.active .pay-input :deep(.p-inputnumber-input) {
|
||||
background: color-mix(in srgb, var(--blue-500) 8%, var(--surface-ground));
|
||||
}
|
||||
|
||||
/* Label above input */
|
||||
.pay-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Payment input */
|
||||
.pay-input {
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.pay-input :deep(.p-inputnumber-input) {
|
||||
width: 100%;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--text-sm);
|
||||
text-align: right;
|
||||
height: 28px;
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.pay-input :deep(.p-inputnumber-input:focus) {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 1px var(--primary-color);
|
||||
}
|
||||
|
||||
/* Mismatch warning message */
|
||||
.mismatch-msg {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-top: var(--space-sm);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: color-mix(in srgb, var(--yellow-500) 15%, var(--surface-card));
|
||||
border: 1px solid var(--yellow-500);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--yellow-600);
|
||||
}
|
||||
|
||||
.mismatch-msg i {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Responsive: stack on mobile */
|
||||
@media (max-width: 400px) {
|
||||
.pay-grid {
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
.pay-item {
|
||||
width: 100%;
|
||||
}
|
||||
.pay-input {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- NON-SCOPED: Force dark mode styling -->
|
||||
<style>
|
||||
[data-theme="dark"] .pay-section .p-inputnumber-input {
|
||||
background: var(--surface-ground) !important;
|
||||
color: var(--text-color) !important;
|
||||
border-color: var(--surface-border) !important;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .pay-section .p-inputnumber-input {
|
||||
background: var(--surface-ground) !important;
|
||||
color: var(--text-color) !important;
|
||||
border-color: var(--surface-border) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,247 @@
|
||||
<template>
|
||||
<div class="payment-fixed-fields">
|
||||
<div class="payment-grid">
|
||||
<!-- CARD -->
|
||||
<div class="payment-field" :class="{ 'has-value': modelValue.CARD > 0 }">
|
||||
<label class="payment-label">
|
||||
<i class="pi pi-credit-card"></i>
|
||||
CARD
|
||||
</label>
|
||||
<InputNumber
|
||||
v-model="modelValue.CARD"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="2"
|
||||
:disabled="disabled"
|
||||
placeholder="0.00"
|
||||
class="amount-input"
|
||||
@input="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- NUMERAR -->
|
||||
<div class="payment-field" :class="{ 'has-value': modelValue.NUMERAR > 0 }">
|
||||
<label class="payment-label">
|
||||
<i class="pi pi-wallet"></i>
|
||||
NUMERAR
|
||||
</label>
|
||||
<InputNumber
|
||||
v-model="modelValue.NUMERAR"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="2"
|
||||
:disabled="disabled"
|
||||
placeholder="0.00"
|
||||
class="amount-input"
|
||||
@input="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- ALTE (with type dropdown) -->
|
||||
<div class="payment-field payment-field-other" :class="{ 'has-value': modelValue.ALTE.amount > 0 }">
|
||||
<div class="payment-header">
|
||||
<label class="payment-label">
|
||||
<i class="pi pi-ticket"></i>
|
||||
ALTE
|
||||
</label>
|
||||
<Dropdown
|
||||
v-model="modelValue.ALTE.type"
|
||||
:options="otherPaymentTypes"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:disabled="disabled"
|
||||
placeholder="Tip"
|
||||
class="type-dropdown dropdown-borderless"
|
||||
@change="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
<InputNumber
|
||||
v-model="modelValue.ALTE.amount"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="2"
|
||||
:disabled="disabled"
|
||||
placeholder="0.00"
|
||||
class="amount-input"
|
||||
@input="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation warning: sum vs total -->
|
||||
<div class="validation-row" v-if="showMismatchWarning">
|
||||
<div class="warning-box">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
<span>
|
||||
Suma plati ({{ formatAmount(computedTotal) }}) {{ computedTotal > total ? '>' : '<' }} Total ({{ formatAmount(total) }})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
// Expected structure:
|
||||
// { CARD: 0, NUMERAR: 0, ALTE: { amount: 0, type: null } }
|
||||
},
|
||||
total: { type: Number, default: null }, // Total amount for validation
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const otherPaymentTypes = [
|
||||
{ value: 'tichete_masa', label: 'Tichete masa' },
|
||||
{ value: 'voucher', label: 'Voucher' },
|
||||
{ value: 'credit_magazin', label: 'Credit magazin' },
|
||||
]
|
||||
|
||||
const computedTotal = computed(() => {
|
||||
const pm = props.modelValue
|
||||
return (pm.CARD || 0) + (pm.NUMERAR || 0) + (pm.ALTE?.amount || 0)
|
||||
})
|
||||
|
||||
const showMismatchWarning = computed(() => {
|
||||
if (!props.total || props.total === 0) return false
|
||||
if (computedTotal.value === 0) return false
|
||||
return Math.abs(computedTotal.value - props.total) > 0.02 // 2 bani tolerance
|
||||
})
|
||||
|
||||
const formatAmount = (amount) => {
|
||||
return parseFloat(amount || 0).toLocaleString('ro-RO', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
}
|
||||
|
||||
const emitUpdate = () => {
|
||||
emit('update:modelValue', { ...props.modelValue })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.payment-fixed-fields {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.payment-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1.5fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.payment-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--surface-border);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.payment-field.has-value {
|
||||
background: var(--blue-50);
|
||||
border-color: var(--blue-200);
|
||||
}
|
||||
|
||||
.payment-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.payment-label i {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.payment-field.has-value .payment-label i {
|
||||
color: var(--blue-500);
|
||||
}
|
||||
|
||||
/* Other payment type with dropdown */
|
||||
.payment-field-other .payment-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.type-dropdown {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.type-dropdown :deep(.p-dropdown) {
|
||||
padding: 0.2rem 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.amount-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.amount-input :deep(.p-inputnumber-input) {
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Validation warning */
|
||||
.validation-row {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--yellow-100);
|
||||
border: 1px solid var(--yellow-300);
|
||||
border-radius: 6px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--yellow-800);
|
||||
}
|
||||
|
||||
.warning-box i {
|
||||
color: var(--yellow-500);
|
||||
}
|
||||
|
||||
/* Responsive - stack on mobile */
|
||||
@media (max-width: 640px) {
|
||||
.payment-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.payment-field-other {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.payment-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
[data-theme="dark"] .payment-field.has-value {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: var(--blue-700);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .warning-box {
|
||||
background: rgba(234, 179, 8, 0.1);
|
||||
border-color: var(--yellow-700);
|
||||
color: var(--yellow-200);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,148 @@
|
||||
<template>
|
||||
<div class="receipt-section" :class="[variant, { collapsed: isCollapsed }]">
|
||||
<div class="section-header" @click="toggleable && toggle()">
|
||||
<span class="section-title">{{ title }}</span>
|
||||
<div class="section-right">
|
||||
<slot name="header-right"></slot>
|
||||
<i
|
||||
v-if="toggleable"
|
||||
:class="isCollapsed ? 'pi pi-chevron-down' : 'pi pi-chevron-up'"
|
||||
class="toggle-icon"
|
||||
></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section-content" v-show="!isCollapsed">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
variant: {
|
||||
type: String,
|
||||
default: 'default',
|
||||
validator: (v) => ['default', 'highlight', 'muted'].includes(v)
|
||||
},
|
||||
toggleable: { type: Boolean, default: false },
|
||||
collapsed: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:collapsed'])
|
||||
|
||||
const isCollapsed = ref(props.collapsed)
|
||||
|
||||
watch(() => props.collapsed, (val) => {
|
||||
isCollapsed.value = val
|
||||
})
|
||||
|
||||
const toggle = () => {
|
||||
isCollapsed.value = !isCollapsed.value
|
||||
emit('update:collapsed', isCollapsed.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.receipt-section {
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.receipt-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.receipt-section.highlight {
|
||||
background: var(--green-50);
|
||||
border-color: var(--green-200);
|
||||
}
|
||||
|
||||
.receipt-section.muted {
|
||||
background: var(--surface-ground);
|
||||
border-color: var(--surface-border);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-bottom: 1px dashed var(--surface-border);
|
||||
background: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
|
||||
.receipt-section.highlight .section-header {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border-bottom-color: var(--green-300);
|
||||
}
|
||||
|
||||
.receipt-section.collapsed .section-header {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.receipt-section[class*="toggleable"] .section-header {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.receipt-section.highlight .section-title {
|
||||
color: var(--green-800);
|
||||
}
|
||||
|
||||
.section-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toggle-icon {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-color-secondary);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
/* Content */
|
||||
.section-content {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.section-content {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
[data-theme="dark"] .receipt-section {
|
||||
background: rgba(30, 41, 59, 0.6);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .receipt-section.highlight {
|
||||
background: rgba(34, 197, 94, 0.08);
|
||||
border-color: var(--green-800);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .section-header {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .receipt-section.highlight .section-header {
|
||||
background: rgba(34, 197, 94, 0.05);
|
||||
}
|
||||
</style>
|
||||
384
src/modules/data-entry/components/receipts/SupplierDualField.vue
Normal file
384
src/modules/data-entry/components/receipts/SupplierDualField.vue
Normal file
@@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<div class="supplier-dual-field">
|
||||
<div class="dual-grid">
|
||||
<!-- LEFT: OCR Data (read-only) -->
|
||||
<div class="ocr-side" :class="{ 'has-data': ocrName || ocrCui }">
|
||||
<div class="side-header">
|
||||
<span class="side-label">Din OCR</span>
|
||||
<OCRConfidenceIndicator
|
||||
v-if="ocrConfidence"
|
||||
:confidence="ocrConfidence"
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
<div class="side-content">
|
||||
<div class="ocr-name" v-if="ocrName">{{ ocrName }}</div>
|
||||
<div class="ocr-cui" v-if="ocrCui">CUI: {{ ocrCui }}</div>
|
||||
<div class="ocr-empty" v-if="!ocrName && !ocrCui">
|
||||
<i class="pi pi-info-circle"></i>
|
||||
Procesati o imagine pentru extragere automata
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: DB Validated (editable) -->
|
||||
<div class="db-side">
|
||||
<div class="side-header">
|
||||
<span class="side-label">Validat DB</span>
|
||||
<Button
|
||||
v-if="!disabled"
|
||||
icon="pi pi-sync"
|
||||
size="small"
|
||||
text
|
||||
rounded
|
||||
:loading="syncingSuppliers"
|
||||
@click="$emit('sync-suppliers')"
|
||||
v-tooltip.top="'Re-sincronizeaza furnizorii din Oracle'"
|
||||
class="sync-btn"
|
||||
/>
|
||||
</div>
|
||||
<div class="side-content">
|
||||
<div class="field-row">
|
||||
<AutoComplete
|
||||
v-model="localPartnerName"
|
||||
:suggestions="filteredPartners"
|
||||
optionLabel="name"
|
||||
field="name"
|
||||
@complete="searchPartners"
|
||||
@item-select="onPartnerSelect"
|
||||
placeholder="Cauta furnizor..."
|
||||
dropdown
|
||||
:forceSelection="false"
|
||||
:disabled="disabled"
|
||||
class="partner-autocomplete"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-row cui-row">
|
||||
<label>CUI</label>
|
||||
<InputText
|
||||
v-model="localCui"
|
||||
placeholder="RO12345678"
|
||||
:disabled="disabled"
|
||||
class="cui-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Supplier status badges -->
|
||||
<div class="supplier-status" v-if="supplierSource || showWarning">
|
||||
<div v-if="supplierSource" class="status-badge success">
|
||||
<i class="pi pi-check-circle"></i>
|
||||
<span>Validat ({{ supplierSource }})</span>
|
||||
</div>
|
||||
<div v-if="showWarning" class="status-badge warning">
|
||||
<i class="pi pi-exclamation-triangle"></i>
|
||||
<span>Negasit - se va crea automat</span>
|
||||
<Button
|
||||
v-if="!disabled"
|
||||
label="Creaza acum"
|
||||
icon="pi pi-plus"
|
||||
size="small"
|
||||
severity="warning"
|
||||
text
|
||||
@click="$emit('create-supplier')"
|
||||
class="create-btn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Address (collapsed by default) -->
|
||||
<div class="address-section" v-if="localAddress">
|
||||
<div class="address-toggle" @click="showAddress = !showAddress">
|
||||
<i :class="showAddress ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"></i>
|
||||
<span>{{ showAddress ? 'Ascunde adresa' : localAddress }}</span>
|
||||
</div>
|
||||
<div class="address-expanded" v-if="showAddress">
|
||||
<InputText
|
||||
v-model="localAddress"
|
||||
placeholder="Adresa furnizor"
|
||||
:disabled="disabled"
|
||||
class="address-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import AutoComplete from 'primevue/autocomplete'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Button from 'primevue/button'
|
||||
import OCRConfidenceIndicator from '@data-entry/components/ocr/OCRConfidenceIndicator.vue'
|
||||
|
||||
const props = defineProps({
|
||||
// OCR data (read-only display)
|
||||
ocrName: { type: String, default: '' },
|
||||
ocrCui: { type: String, default: '' },
|
||||
ocrConfidence: { type: Number, default: null },
|
||||
|
||||
// Form values (two-way bound)
|
||||
partnerName: { type: [String, Object], default: '' },
|
||||
cui: { type: String, default: '' },
|
||||
address: { type: String, default: '' },
|
||||
|
||||
// Supplier validation
|
||||
supplierSource: { type: String, default: null }, // 'oracle' | 'local' | null
|
||||
showWarning: { type: Boolean, default: false },
|
||||
|
||||
// Partners list for autocomplete
|
||||
partners: { type: Array, default: () => [] },
|
||||
|
||||
// State
|
||||
disabled: { type: Boolean, default: false },
|
||||
syncingSuppliers: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'update:partnerName',
|
||||
'update:cui',
|
||||
'update:address',
|
||||
'partner-selected',
|
||||
'sync-suppliers',
|
||||
'create-supplier',
|
||||
])
|
||||
|
||||
// Local refs with two-way binding
|
||||
const localPartnerName = computed({
|
||||
get: () => props.partnerName,
|
||||
set: (val) => emit('update:partnerName', val)
|
||||
})
|
||||
|
||||
const localCui = computed({
|
||||
get: () => props.cui,
|
||||
set: (val) => emit('update:cui', val)
|
||||
})
|
||||
|
||||
const localAddress = computed({
|
||||
get: () => props.address,
|
||||
set: (val) => emit('update:address', val)
|
||||
})
|
||||
|
||||
const showAddress = ref(false)
|
||||
const filteredPartners = ref([])
|
||||
|
||||
const searchPartners = (event) => {
|
||||
const query = event.query.toLowerCase()
|
||||
filteredPartners.value = props.partners.filter(p =>
|
||||
p.name.toLowerCase().includes(query) ||
|
||||
(p.fiscal_code && p.fiscal_code.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
const onPartnerSelect = (event) => {
|
||||
const partner = event.value
|
||||
if (partner) {
|
||||
emit('update:cui', partner.fiscal_code || '')
|
||||
if (partner.address) {
|
||||
emit('update:address', partner.address)
|
||||
}
|
||||
emit('partner-selected', partner)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.supplier-dual-field {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dual-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Side styles */
|
||||
.ocr-side,
|
||||
.db-side {
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.ocr-side {
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
|
||||
.ocr-side.has-data {
|
||||
background: var(--blue-50);
|
||||
border-color: var(--blue-200);
|
||||
}
|
||||
|
||||
.side-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.35rem;
|
||||
border-bottom: 1px dashed var(--surface-border);
|
||||
}
|
||||
|
||||
.side-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.sync-btn {
|
||||
padding: 0.25rem;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
/* OCR side content */
|
||||
.ocr-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.ocr-cui {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.ocr-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ocr-empty i {
|
||||
color: var(--blue-400);
|
||||
}
|
||||
|
||||
/* DB side content */
|
||||
.field-row {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.field-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.cui-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cui-row label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color-secondary);
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
.partner-autocomplete {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cui-input {
|
||||
flex: 1;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
/* Supplier status */
|
||||
.supplier-status {
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
color: var(--green-700);
|
||||
}
|
||||
|
||||
.status-badge.success i {
|
||||
color: var(--green-500);
|
||||
}
|
||||
|
||||
.status-badge.warning {
|
||||
color: var(--yellow-800);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.status-badge.warning i {
|
||||
color: var(--yellow-500);
|
||||
}
|
||||
|
||||
.create-btn {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Address section */
|
||||
.address-section {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.address-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.address-toggle:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.address-toggle i {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.address-toggle span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.address-expanded {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.address-input {
|
||||
width: 100%;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.dual-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
[data-theme="dark"] .ocr-side.has-data {
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
border-color: var(--blue-700);
|
||||
}
|
||||
</style>
|
||||
303
src/modules/data-entry/components/receipts/TvaCompactFields.vue
Normal file
303
src/modules/data-entry/components/receipts/TvaCompactFields.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<template>
|
||||
<div class="tva-row">
|
||||
<!-- TVA label like TOTAL -->
|
||||
<span class="row-label">TVA</span>
|
||||
<div class="tva-fields">
|
||||
<div
|
||||
v-for="(entry, index) in visibleEntries"
|
||||
:key="entry.code"
|
||||
class="tva-item"
|
||||
:class="{ active: modelValue[entry.code]?.amount > 0 }"
|
||||
>
|
||||
<!-- Percent as InputNumber (not dropdown) -->
|
||||
<InputNumber
|
||||
v-model="modelValue[entry.code].percent"
|
||||
suffix="%"
|
||||
:min="0"
|
||||
:max="100"
|
||||
placeholder="21"
|
||||
:disabled="disabled"
|
||||
class="tva-percent"
|
||||
@input="emitUpdate"
|
||||
/>
|
||||
<InputNumber
|
||||
v-model="modelValue[entry.code].amount"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="2"
|
||||
:disabled="disabled"
|
||||
placeholder="0.00"
|
||||
class="tva-amount"
|
||||
@input="emitUpdate"
|
||||
/>
|
||||
<!-- Remove button (if more than 1 visible) -->
|
||||
<button
|
||||
v-if="visibleEntries.length > 1 && !disabled"
|
||||
type="button"
|
||||
class="remove-btn"
|
||||
@click="removeEntry(index)"
|
||||
>
|
||||
<i class="pi pi-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Add button -->
|
||||
<button
|
||||
v-if="canAddMore && !disabled"
|
||||
type="button"
|
||||
class="add-btn"
|
||||
@click="addEntry"
|
||||
>
|
||||
<i class="pi pi-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Object, required: true },
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const ALL_CODES = ['A', 'B', 'C', 'D', 'E']
|
||||
|
||||
// Track which TVA codes are visible (default: just 'A')
|
||||
const visibleCodes = ref(['A'])
|
||||
|
||||
// Computed: entries to display
|
||||
const visibleEntries = computed(() => {
|
||||
return visibleCodes.value.map(code => ({
|
||||
code,
|
||||
...props.modelValue[code]
|
||||
}))
|
||||
})
|
||||
|
||||
// Computed: can add more rows?
|
||||
const canAddMore = computed(() => {
|
||||
return visibleCodes.value.length < ALL_CODES.length
|
||||
})
|
||||
|
||||
// Add next available code
|
||||
const addEntry = () => {
|
||||
const nextCode = ALL_CODES.find(code => !visibleCodes.value.includes(code))
|
||||
if (nextCode) {
|
||||
visibleCodes.value = [...visibleCodes.value, nextCode]
|
||||
}
|
||||
}
|
||||
|
||||
// Remove code at index
|
||||
const removeEntry = (index) => {
|
||||
if (visibleCodes.value.length > 1) {
|
||||
const codeToRemove = visibleCodes.value[index]
|
||||
visibleCodes.value = visibleCodes.value.filter((_, i) => i !== index)
|
||||
// Reset the removed code's values
|
||||
if (props.modelValue[codeToRemove]) {
|
||||
props.modelValue[codeToRemove].percent = null
|
||||
props.modelValue[codeToRemove].amount = 0
|
||||
emitUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Watch modelValue for OCR changes - auto-expand to show filled values
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
if (!newVal) return
|
||||
const codesWithValues = ALL_CODES.filter(
|
||||
code => newVal[code]?.amount > 0
|
||||
)
|
||||
if (codesWithValues.length > 0) {
|
||||
// Ensure all codes with values are visible
|
||||
const newVisibleCodes = [...new Set([...visibleCodes.value, ...codesWithValues])]
|
||||
if (newVisibleCodes.length !== visibleCodes.value.length) {
|
||||
visibleCodes.value = newVisibleCodes
|
||||
}
|
||||
}
|
||||
}, { deep: true, immediate: true })
|
||||
|
||||
const emitUpdate = () => {
|
||||
emit('update:modelValue', { ...props.modelValue })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* === TVA ROW - with label and green background === */
|
||||
.tva-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
background: var(--green-100); /* different from TOTAL which uses green-50 */
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
margin: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
.row-label {
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--text-xs); /* 12px - uniform for labels */
|
||||
color: var(--text-color);
|
||||
flex-shrink: 0;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.tva-fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm, 0.5rem);
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tva-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.tva-item.active .tva-amount :deep(.p-inputnumber-input) {
|
||||
background: color-mix(in srgb, var(--green-500) 8%, var(--surface-ground));
|
||||
}
|
||||
|
||||
/* TVA percent input (wider to see "21%") */
|
||||
.tva-percent {
|
||||
width: 70px; /* wider to show % suffix */
|
||||
}
|
||||
|
||||
.tva-percent :deep(.p-inputnumber-input) {
|
||||
width: 100%;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--text-sm); /* 14px - uniform for inputs */
|
||||
font-weight: var(--font-normal);
|
||||
text-align: right;
|
||||
height: 32px; /* uniform height */
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.tva-percent :deep(.p-inputnumber-input:focus) {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 1px var(--primary-color);
|
||||
}
|
||||
|
||||
/* TVA amount input (wider to see "1234.56") */
|
||||
.tva-amount {
|
||||
width: 90px; /* wider */
|
||||
}
|
||||
|
||||
.tva-amount :deep(.p-inputnumber-input) {
|
||||
width: 100%;
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
font-size: var(--text-sm); /* 14px - uniform for inputs */
|
||||
font-weight: var(--font-normal);
|
||||
text-align: right;
|
||||
height: 32px; /* uniform height */
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.tva-amount :deep(.p-inputnumber-input:focus) {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 1px var(--primary-color);
|
||||
}
|
||||
|
||||
/* Add/Remove buttons - icon only, compact */
|
||||
.add-btn, .remove-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px dashed var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
color: var(--text-color-secondary);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
color: white;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.remove-btn {
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.remove-btn:hover {
|
||||
background: var(--red-500);
|
||||
border-color: var(--red-500);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.add-btn i, .remove-btn i {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Responsive: stack on mobile */
|
||||
@media (max-width: 480px) {
|
||||
.tva-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.row-label {
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.tva-fields {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tva-item {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.tva-percent {
|
||||
flex: 0 0 70px;
|
||||
}
|
||||
|
||||
.tva-amount {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<!-- NON-SCOPED: Force dark mode styling -->
|
||||
<style>
|
||||
[data-theme="dark"] .tva-row {
|
||||
background: rgba(34, 197, 94, 0.15); /* slightly more visible than TOTAL */
|
||||
}
|
||||
|
||||
[data-theme="dark"] .tva-row .p-inputnumber-input {
|
||||
background: var(--surface-ground) !important;
|
||||
color: var(--text-color) !important;
|
||||
border-color: var(--surface-border) !important;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .tva-row {
|
||||
background: rgba(34, 197, 94, 0.15); /* slightly more visible than TOTAL */
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .tva-row .p-inputnumber-input {
|
||||
background: var(--surface-ground) !important;
|
||||
color: var(--text-color) !important;
|
||||
border-color: var(--surface-border) !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
294
src/modules/data-entry/components/receipts/TvaFixedFields.vue
Normal file
294
src/modules/data-entry/components/receipts/TvaFixedFields.vue
Normal file
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div class="tva-fixed-fields">
|
||||
<div class="tva-grid">
|
||||
<!-- TVA A -->
|
||||
<div class="tva-field" :class="{ 'has-value': modelValue.A.amount > 0 }">
|
||||
<div class="tva-header">
|
||||
<span class="tva-code">A</span>
|
||||
<Dropdown
|
||||
v-model="modelValue.A.percent"
|
||||
:options="percentOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:disabled="disabled"
|
||||
class="percent-dropdown dropdown-borderless"
|
||||
@change="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
<InputNumber
|
||||
v-model="modelValue.A.amount"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="2"
|
||||
:disabled="disabled"
|
||||
placeholder="0.00"
|
||||
class="amount-input"
|
||||
@input="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TVA B -->
|
||||
<div class="tva-field" :class="{ 'has-value': modelValue.B.amount > 0 }">
|
||||
<div class="tva-header">
|
||||
<span class="tva-code">B</span>
|
||||
<Dropdown
|
||||
v-model="modelValue.B.percent"
|
||||
:options="percentOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:disabled="disabled"
|
||||
class="percent-dropdown dropdown-borderless"
|
||||
@change="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
<InputNumber
|
||||
v-model="modelValue.B.amount"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="2"
|
||||
:disabled="disabled"
|
||||
placeholder="0.00"
|
||||
class="amount-input"
|
||||
@input="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TVA C -->
|
||||
<div class="tva-field" :class="{ 'has-value': modelValue.C.amount > 0 }">
|
||||
<div class="tva-header">
|
||||
<span class="tva-code">C</span>
|
||||
<Dropdown
|
||||
v-model="modelValue.C.percent"
|
||||
:options="percentOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:disabled="disabled"
|
||||
class="percent-dropdown dropdown-borderless"
|
||||
@change="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
<InputNumber
|
||||
v-model="modelValue.C.amount"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="2"
|
||||
:disabled="disabled"
|
||||
placeholder="0.00"
|
||||
class="amount-input"
|
||||
@input="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TVA D -->
|
||||
<div class="tva-field" :class="{ 'has-value': modelValue.D.amount > 0 }">
|
||||
<div class="tva-header">
|
||||
<span class="tva-code">D</span>
|
||||
<Dropdown
|
||||
v-model="modelValue.D.percent"
|
||||
:options="percentOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:disabled="disabled"
|
||||
class="percent-dropdown dropdown-borderless"
|
||||
@change="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
<InputNumber
|
||||
v-model="modelValue.D.amount"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="2"
|
||||
:disabled="disabled"
|
||||
placeholder="0.00"
|
||||
class="amount-input"
|
||||
@input="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TVA E (custom percent) -->
|
||||
<div class="tva-field" :class="{ 'has-value': modelValue.E.amount > 0 }">
|
||||
<div class="tva-header">
|
||||
<span class="tva-code">E</span>
|
||||
<Dropdown
|
||||
v-model="modelValue.E.percent"
|
||||
:options="percentOptionsWithCustom"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
:disabled="disabled"
|
||||
class="percent-dropdown dropdown-borderless"
|
||||
@change="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
<InputNumber
|
||||
v-model="modelValue.E.amount"
|
||||
:minFractionDigits="2"
|
||||
:maxFractionDigits="2"
|
||||
:disabled="disabled"
|
||||
placeholder="0.00"
|
||||
class="amount-input"
|
||||
@input="emitUpdate"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- TOTAL TVA -->
|
||||
<div class="tva-total">
|
||||
<span class="total-label">TOTAL TVA</span>
|
||||
<span class="total-value">{{ formatAmount(computedTotal) }} LEI</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Object,
|
||||
required: true,
|
||||
// Expected structure:
|
||||
// { A: { percent: 19, amount: 0 }, B: {...}, C: {...}, D: {...}, E: {...} }
|
||||
},
|
||||
disabled: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
// Standard VAT percentages in Romania
|
||||
const percentOptions = [
|
||||
{ value: 19, label: '19%' },
|
||||
{ value: 9, label: '9%' },
|
||||
{ value: 5, label: '5%' },
|
||||
{ value: 0, label: '0%' },
|
||||
]
|
||||
|
||||
// E field can have null (disabled) or custom values
|
||||
const percentOptionsWithCustom = [
|
||||
{ value: null, label: '--' },
|
||||
...percentOptions,
|
||||
]
|
||||
|
||||
const computedTotal = computed(() => {
|
||||
const tva = props.modelValue
|
||||
return (
|
||||
(tva.A?.amount || 0) +
|
||||
(tva.B?.amount || 0) +
|
||||
(tva.C?.amount || 0) +
|
||||
(tva.D?.amount || 0) +
|
||||
(tva.E?.amount || 0)
|
||||
)
|
||||
})
|
||||
|
||||
const formatAmount = (amount) => {
|
||||
return parseFloat(amount || 0).toLocaleString('ro-RO', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
}
|
||||
|
||||
const emitUpdate = () => {
|
||||
emit('update:modelValue', { ...props.modelValue })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tva-fixed-fields {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tva-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tva-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--surface-border);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tva-field.has-value {
|
||||
background: var(--green-50);
|
||||
border-color: var(--green-200);
|
||||
}
|
||||
|
||||
.tva-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tva-code {
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color);
|
||||
min-width: 20px;
|
||||
}
|
||||
|
||||
.percent-dropdown {
|
||||
flex: 1;
|
||||
max-width: 70px;
|
||||
}
|
||||
|
||||
.percent-dropdown :deep(.p-dropdown) {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.amount-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.amount-input :deep(.p-inputnumber-input) {
|
||||
padding: 0.35rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Total row spans full width */
|
||||
.tva-total {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
border-top: 2px dashed var(--surface-border);
|
||||
}
|
||||
|
||||
.total-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.total-value {
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
/* Responsive - 3 columns on wider screens */
|
||||
@media (min-width: 768px) {
|
||||
.tva-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Stack on mobile */
|
||||
@media (max-width: 480px) {
|
||||
.tva-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments */
|
||||
[data-theme="dark"] .tva-field.has-value {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
border-color: var(--green-700);
|
||||
}
|
||||
</style>
|
||||
1115
src/modules/data-entry/components/receipts/UnifiedReceiptForm.vue
Normal file
1115
src/modules/data-entry/components/receipts/UnifiedReceiptForm.vue
Normal file
File diff suppressed because it is too large
Load Diff
282
src/modules/data-entry/utils/receiptConversions.js
Normal file
282
src/modules/data-entry/utils/receiptConversions.js
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* Conversion utilities for Receipt form data
|
||||
*
|
||||
* These functions convert between the API format (dynamic arrays) and
|
||||
* the UI format (fixed fields for TVA and payments).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default fixed TVA structure for UI
|
||||
*/
|
||||
export const getDefaultTva = () => ({
|
||||
A: { percent: 19, amount: 0 },
|
||||
B: { percent: 9, amount: 0 },
|
||||
C: { percent: 5, amount: 0 },
|
||||
D: { percent: 0, amount: 0 },
|
||||
E: { percent: null, amount: 0 },
|
||||
})
|
||||
|
||||
/**
|
||||
* Default fixed payments structure for UI
|
||||
*/
|
||||
export const getDefaultPayments = () => ({
|
||||
CARD: 0,
|
||||
NUMERAR: 0,
|
||||
ALTE: { amount: 0, type: null },
|
||||
})
|
||||
|
||||
/**
|
||||
* Convert API tva_entries/tva_breakdown array → UI fixed fields
|
||||
*
|
||||
* @param {Array} entries - Array of {code, percent, amount}
|
||||
* @returns {Object} Fixed TVA fields {A, B, C, D, E}
|
||||
*/
|
||||
export const apiToUiTva = (entries) => {
|
||||
const ui = getDefaultTva()
|
||||
|
||||
if (!entries || !Array.isArray(entries)) {
|
||||
return ui
|
||||
}
|
||||
|
||||
entries.forEach(entry => {
|
||||
const code = entry.code?.toUpperCase()
|
||||
if (code && ui[code]) {
|
||||
ui[code] = {
|
||||
percent: entry.percent ?? ui[code].percent,
|
||||
amount: parseFloat(entry.amount) || 0
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return ui
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert UI fixed TVA fields → API tva_breakdown array
|
||||
* Only includes entries with amount > 0
|
||||
*
|
||||
* @param {Object} tvaUi - Fixed TVA fields {A, B, C, D, E}
|
||||
* @returns {Array} Array of {code, percent, amount} or null if empty
|
||||
*/
|
||||
export const uiToApiTva = (tvaUi) => {
|
||||
if (!tvaUi) return null
|
||||
|
||||
const entries = Object.entries(tvaUi)
|
||||
.filter(([_, v]) => v.amount && v.amount > 0)
|
||||
.map(([code, v]) => ({
|
||||
code,
|
||||
percent: v.percent ?? 0,
|
||||
amount: parseFloat(v.amount) || 0
|
||||
}))
|
||||
|
||||
return entries.length > 0 ? entries : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total TVA from UI fixed fields
|
||||
*
|
||||
* @param {Object} tvaUi - Fixed TVA fields {A, B, C, D, E}
|
||||
* @returns {number} Total TVA amount
|
||||
*/
|
||||
export const calculateTvaTotal = (tvaUi) => {
|
||||
if (!tvaUi) return 0
|
||||
|
||||
return Object.values(tvaUi)
|
||||
.reduce((sum, v) => sum + (parseFloat(v.amount) || 0), 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert API payment_methods array → UI fixed fields
|
||||
*
|
||||
* @param {Array} methods - Array of {method, amount}
|
||||
* @returns {Object} Fixed payment fields {CARD, NUMERAR, ALTE}
|
||||
*/
|
||||
export const apiToUiPayments = (methods) => {
|
||||
const ui = getDefaultPayments()
|
||||
|
||||
if (!methods || !Array.isArray(methods)) {
|
||||
return ui
|
||||
}
|
||||
|
||||
methods.forEach(pm => {
|
||||
const method = pm.method?.toUpperCase()
|
||||
const amount = parseFloat(pm.amount) || 0
|
||||
|
||||
if (method === 'CARD') {
|
||||
ui.CARD = amount
|
||||
} else if (method === 'NUMERAR' || method === 'CASH') {
|
||||
ui.NUMERAR = amount
|
||||
} else if (method) {
|
||||
// Other payment types go to ALTE
|
||||
ui.ALTE.amount += amount
|
||||
// Try to determine type from method name
|
||||
if (method.includes('TICH') || method.includes('MASA')) {
|
||||
ui.ALTE.type = 'tichete_masa'
|
||||
} else if (method.includes('VOUCHER')) {
|
||||
ui.ALTE.type = 'voucher'
|
||||
} else if (method.includes('CREDIT')) {
|
||||
ui.ALTE.type = 'credit_magazin'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return ui
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert UI fixed payment fields → API payment_methods array
|
||||
* Only includes entries with amount > 0
|
||||
*
|
||||
* @param {Object} paymentsUi - Fixed payment fields {CARD, NUMERAR, ALTE}
|
||||
* @returns {Array} Array of {method, amount} or null if empty
|
||||
*/
|
||||
export const uiToApiPayments = (paymentsUi) => {
|
||||
if (!paymentsUi) return null
|
||||
|
||||
const methods = []
|
||||
|
||||
if (paymentsUi.CARD && paymentsUi.CARD > 0) {
|
||||
methods.push({ method: 'CARD', amount: paymentsUi.CARD })
|
||||
}
|
||||
|
||||
if (paymentsUi.NUMERAR && paymentsUi.NUMERAR > 0) {
|
||||
methods.push({ method: 'NUMERAR', amount: paymentsUi.NUMERAR })
|
||||
}
|
||||
|
||||
if (paymentsUi.ALTE?.amount && paymentsUi.ALTE.amount > 0) {
|
||||
// Map type to method name
|
||||
let methodName = 'ALTE'
|
||||
if (paymentsUi.ALTE.type === 'tichete_masa') {
|
||||
methodName = 'TICHETE_MASA'
|
||||
} else if (paymentsUi.ALTE.type === 'voucher') {
|
||||
methodName = 'VOUCHER'
|
||||
} else if (paymentsUi.ALTE.type === 'credit_magazin') {
|
||||
methodName = 'CREDIT_MAGAZIN'
|
||||
}
|
||||
methods.push({ method: methodName, amount: paymentsUi.ALTE.amount })
|
||||
}
|
||||
|
||||
return methods.length > 0 ? methods : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total payments from UI fixed fields
|
||||
*
|
||||
* @param {Object} paymentsUi - Fixed payment fields {CARD, NUMERAR, ALTE}
|
||||
* @returns {number} Total payment amount
|
||||
*/
|
||||
export const calculatePaymentsTotal = (paymentsUi) => {
|
||||
if (!paymentsUi) return 0
|
||||
|
||||
return (
|
||||
(paymentsUi.CARD || 0) +
|
||||
(paymentsUi.NUMERAR || 0) +
|
||||
(paymentsUi.ALTE?.amount || 0)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default form state for unified receipt form
|
||||
*/
|
||||
export const getDefaultUnifiedFormState = () => ({
|
||||
receipt_type: 'bon_fiscal',
|
||||
receipt_date: new Date(),
|
||||
receipt_number: '',
|
||||
|
||||
// Supplier (DB validated)
|
||||
partner_name: null,
|
||||
cui: '',
|
||||
vendor_address: '',
|
||||
|
||||
// Total
|
||||
amount: null,
|
||||
|
||||
// TVA (5 fixed fields)
|
||||
tva: getDefaultTva(),
|
||||
|
||||
// Payments (3 fixed fields)
|
||||
payments: getDefaultPayments(),
|
||||
|
||||
// Auxiliary
|
||||
expense_type_code: null,
|
||||
description: '',
|
||||
|
||||
// Metadata
|
||||
ocr_raw_text: '',
|
||||
items_count: null,
|
||||
company_id: null,
|
||||
})
|
||||
|
||||
/**
|
||||
* Convert legacy form state to unified form state
|
||||
*
|
||||
* @param {Object} legacyForm - Old form format
|
||||
* @returns {Object} Unified form format
|
||||
*/
|
||||
export const legacyToUnifiedForm = (legacyForm) => {
|
||||
return {
|
||||
receipt_type: legacyForm.receipt_type || 'bon_fiscal',
|
||||
receipt_date: legacyForm.receipt_date instanceof Date
|
||||
? legacyForm.receipt_date
|
||||
: new Date(legacyForm.receipt_date),
|
||||
receipt_number: legacyForm.receipt_number || '',
|
||||
|
||||
partner_name: legacyForm.partner_name || null,
|
||||
cui: legacyForm.cui || '',
|
||||
vendor_address: legacyForm.vendor_address || '',
|
||||
|
||||
amount: parseFloat(legacyForm.amount) || null,
|
||||
|
||||
tva: apiToUiTva(legacyForm.tva_breakdown),
|
||||
payments: apiToUiPayments(legacyForm.payment_methods),
|
||||
|
||||
expense_type_code: legacyForm.expense_type_code || null,
|
||||
description: legacyForm.description || '',
|
||||
|
||||
ocr_raw_text: legacyForm.ocr_raw_text || '',
|
||||
items_count: legacyForm.items_count || null,
|
||||
company_id: legacyForm.company_id || null,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert unified form state to API payload
|
||||
*
|
||||
* @param {Object} unifiedForm - Unified form format
|
||||
* @returns {Object} API payload format
|
||||
*/
|
||||
export const unifiedFormToApiPayload = (unifiedForm) => {
|
||||
return {
|
||||
receipt_type: unifiedForm.receipt_type,
|
||||
direction: 'cheltuiala', // Always expense (no more income)
|
||||
receipt_date: unifiedForm.receipt_date instanceof Date
|
||||
? unifiedForm.receipt_date.toISOString().split('T')[0]
|
||||
: unifiedForm.receipt_date,
|
||||
receipt_number: unifiedForm.receipt_number || null,
|
||||
|
||||
partner_name: typeof unifiedForm.partner_name === 'string'
|
||||
? unifiedForm.partner_name
|
||||
: unifiedForm.partner_name?.name || null,
|
||||
cui: unifiedForm.cui || null,
|
||||
vendor_address: unifiedForm.vendor_address || null,
|
||||
|
||||
amount: unifiedForm.amount || 0,
|
||||
|
||||
tva_breakdown: uiToApiTva(unifiedForm.tva),
|
||||
tva_total: calculateTvaTotal(unifiedForm.tva) || null,
|
||||
payment_methods: uiToApiPayments(unifiedForm.payments),
|
||||
|
||||
expense_type_code: unifiedForm.expense_type_code || null,
|
||||
description: unifiedForm.description || null,
|
||||
|
||||
ocr_raw_text: unifiedForm.ocr_raw_text || null,
|
||||
items_count: unifiedForm.items_count || null,
|
||||
company_id: unifiedForm.company_id,
|
||||
|
||||
// Legacy fields (removed but kept for backwards compat)
|
||||
payment_mode: null,
|
||||
cash_register_id: null,
|
||||
cash_register_name: null,
|
||||
cash_register_account: null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,871 @@
|
||||
<template>
|
||||
<div class="receipt-unified-view">
|
||||
<!-- Header -->
|
||||
<div class="view-header">
|
||||
<div class="header-left">
|
||||
<Button
|
||||
icon="pi pi-arrow-left"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
@click="goBack"
|
||||
/>
|
||||
<h1 class="view-title">
|
||||
<i :class="modeIcon"></i>
|
||||
{{ modeTitle }}
|
||||
</h1>
|
||||
<Tag
|
||||
v-if="isViewMode && receipt"
|
||||
:value="getStatusLabel(receipt.status)"
|
||||
:severity="getStatusSeverity(receipt.status)"
|
||||
/>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<!-- Create/Edit mode actions -->
|
||||
<template v-if="!isViewMode">
|
||||
<Button
|
||||
icon="pi pi-save"
|
||||
label="Salveaza"
|
||||
:loading="saving"
|
||||
@click="saveReceipt"
|
||||
/>
|
||||
<Button
|
||||
v-if="isEditMode && receipt?.status === 'draft'"
|
||||
icon="pi pi-send"
|
||||
label="Trimite"
|
||||
severity="success"
|
||||
:loading="submitting"
|
||||
@click="submitForReview"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- View mode actions -->
|
||||
<template v-else>
|
||||
<Button
|
||||
v-if="receipt?.status === 'draft' || receipt?.status === 'rejected'"
|
||||
icon="pi pi-pencil"
|
||||
label="Editeaza"
|
||||
@click="goToEdit"
|
||||
/>
|
||||
<Button
|
||||
v-if="receipt?.status === 'draft'"
|
||||
icon="pi pi-send"
|
||||
label="Trimite"
|
||||
severity="success"
|
||||
@click="submitReceipt"
|
||||
:loading="submitting"
|
||||
/>
|
||||
<Button
|
||||
v-if="receipt?.status === 'pending_review'"
|
||||
icon="pi pi-check"
|
||||
label="Valideaza"
|
||||
severity="success"
|
||||
@click="approveReceipt"
|
||||
:loading="approving"
|
||||
/>
|
||||
<Button
|
||||
v-if="receipt?.status === 'pending_review'"
|
||||
icon="pi pi-times"
|
||||
label="Respinge"
|
||||
severity="danger"
|
||||
@click="openRejectDialog"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rejection Alert -->
|
||||
<Message
|
||||
v-if="receipt?.rejection_reason"
|
||||
severity="warn"
|
||||
:closable="false"
|
||||
class="rejection-message"
|
||||
>
|
||||
<strong>Motiv respingere:</strong> {{ receipt.rejection_reason }}
|
||||
<br>
|
||||
<small>Respins de {{ receipt.reviewed_by }} la {{ formatDateTime(receipt.reviewed_at) }}</small>
|
||||
</Message>
|
||||
|
||||
<!-- Unified Form -->
|
||||
<UnifiedReceiptForm
|
||||
ref="unifiedFormRef"
|
||||
v-model="form"
|
||||
:ocr-data="ocrData"
|
||||
:partners="partners"
|
||||
:expense-types="expenseTypes"
|
||||
:supplier-source="supplierSource"
|
||||
:supplier-warning="supplierWarning.show"
|
||||
:syncing-suppliers="syncingSuppliers"
|
||||
:existing-attachments="existingAttachments"
|
||||
:selected-files="selectedFiles"
|
||||
:readonly="isViewMode"
|
||||
@ocr-result="onOCRResult"
|
||||
@file-selected="onFileSelected"
|
||||
@ocr-error="onOCRError"
|
||||
@partner-selected="onPartnerSelected"
|
||||
@sync-suppliers="syncSuppliers"
|
||||
@create-supplier="createLocalSupplierFromWarning"
|
||||
@add-files="onAddFiles"
|
||||
@remove-file="onRemoveFile"
|
||||
@remove-attachment="removeExistingAttachment"
|
||||
@download-attachment="downloadAttachment"
|
||||
/>
|
||||
|
||||
<!-- Validation Banners -->
|
||||
<div class="validation-banners" v-if="!isViewMode && validationErrors.length > 0">
|
||||
<Message
|
||||
v-for="(error, idx) in validationErrors"
|
||||
:key="idx"
|
||||
severity="warn"
|
||||
:closable="false"
|
||||
>
|
||||
{{ error }}
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<!-- Reject Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="showRejectDialog"
|
||||
header="Respinge Bon"
|
||||
:modal="true"
|
||||
:style="{ width: '450px' }"
|
||||
>
|
||||
<div class="dialog-content">
|
||||
<p>Introduceti motivul respingerii:</p>
|
||||
<Textarea
|
||||
v-model="rejectReason"
|
||||
rows="3"
|
||||
class="w-full"
|
||||
placeholder="Motiv respingere (minim 5 caractere)..."
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button
|
||||
label="Anuleaza"
|
||||
severity="secondary"
|
||||
@click="showRejectDialog = false"
|
||||
/>
|
||||
<Button
|
||||
label="Respinge"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
@click="rejectReceipt"
|
||||
:loading="rejecting"
|
||||
:disabled="!rejectReason || rejectReason.length < 5"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- Create Supplier Dialog -->
|
||||
<Dialog
|
||||
v-model:visible="showCreateSupplierDialog"
|
||||
header="Creaza Furnizor Local"
|
||||
:modal="true"
|
||||
:style="{ width: '400px' }"
|
||||
>
|
||||
<div class="dialog-content">
|
||||
<p>Furnizorul nu a fost gasit. Creati un furnizor local?</p>
|
||||
<div class="form-field">
|
||||
<label>Nume</label>
|
||||
<InputText v-model="pendingSupplierData.name" class="w-full" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>CUI</label>
|
||||
<InputText v-model="pendingSupplierData.fiscal_code" class="w-full" disabled />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label>Adresa</label>
|
||||
<InputText v-model="pendingSupplierData.address" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="Anuleaza" severity="secondary" @click="showCreateSupplierDialog = false" />
|
||||
<Button label="Creaza" icon="pi pi-plus" @click="createLocalSupplier" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import Button from 'primevue/button'
|
||||
import Tag from 'primevue/tag'
|
||||
import Message from 'primevue/message'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import InputText from 'primevue/inputtext'
|
||||
|
||||
import { useReceiptsStore } from '@data-entry/stores/receiptsStore'
|
||||
import { useCompanyStore } from '@data-entry/stores/sharedStores'
|
||||
|
||||
import UnifiedReceiptForm from '@data-entry/components/receipts/UnifiedReceiptForm.vue'
|
||||
import {
|
||||
getDefaultUnifiedFormState,
|
||||
legacyToUnifiedForm,
|
||||
unifiedFormToApiPayload,
|
||||
apiToUiTva,
|
||||
apiToUiPayments,
|
||||
} from '@data-entry/utils/receiptConversions'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const store = useReceiptsStore()
|
||||
const companyStore = useCompanyStore()
|
||||
|
||||
// Mode detection
|
||||
const isViewMode = computed(() => !!route.params.id && !route.path.endsWith('/edit'))
|
||||
const isEditMode = computed(() => !!route.params.id && route.path.endsWith('/edit'))
|
||||
const isCreateMode = computed(() => !route.params.id)
|
||||
|
||||
const modeTitle = computed(() => {
|
||||
if (isViewMode.value) return `Bon #${receipt.value?.id || ''}`
|
||||
if (isEditMode.value) return 'Editare Bon'
|
||||
return 'Bon Fiscal Nou'
|
||||
})
|
||||
|
||||
const modeIcon = computed(() => {
|
||||
if (isViewMode.value) return 'pi pi-receipt'
|
||||
return 'pi pi-plus-circle'
|
||||
})
|
||||
|
||||
// Form state
|
||||
const form = ref(getDefaultUnifiedFormState())
|
||||
const receipt = ref(null)
|
||||
const unifiedFormRef = ref(null)
|
||||
|
||||
// OCR state
|
||||
const ocrData = ref(null)
|
||||
|
||||
// Files state
|
||||
const selectedFiles = ref([])
|
||||
const existingAttachments = ref([])
|
||||
|
||||
// Loading states
|
||||
const saving = ref(false)
|
||||
const submitting = ref(false)
|
||||
const approving = ref(false)
|
||||
const rejecting = ref(false)
|
||||
const syncingSuppliers = ref(false)
|
||||
|
||||
// Supplier state
|
||||
const supplierSource = ref(null)
|
||||
const supplierWarning = ref({ show: false, cui: '', name: '' })
|
||||
const showCreateSupplierDialog = ref(false)
|
||||
const pendingSupplierData = ref({ name: '', fiscal_code: '', address: '' })
|
||||
|
||||
// Reject dialog
|
||||
const showRejectDialog = ref(false)
|
||||
const rejectReason = ref('')
|
||||
|
||||
// Computed
|
||||
const partners = computed(() => store.partners)
|
||||
const expenseTypes = computed(() => store.expenseTypes)
|
||||
|
||||
const validationErrors = computed(() => {
|
||||
const errors = []
|
||||
if (!form.value.amount || form.value.amount <= 0) {
|
||||
errors.push('Suma totala este obligatorie')
|
||||
}
|
||||
if (!form.value.receipt_date) {
|
||||
errors.push('Data este obligatorie')
|
||||
}
|
||||
if (selectedFiles.value.length === 0 && existingAttachments.value.length === 0 && isCreateMode.value) {
|
||||
errors.push('Atasati cel putin o imagine a bonului')
|
||||
}
|
||||
return errors
|
||||
})
|
||||
|
||||
// Status helpers
|
||||
const getStatusLabel = (status) => {
|
||||
const labels = {
|
||||
draft: 'Ciorna',
|
||||
pending_review: 'In Asteptare',
|
||||
approved: 'Aprobat',
|
||||
rejected: 'Respins',
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
const getStatusSeverity = (status) => {
|
||||
const severities = {
|
||||
draft: 'secondary',
|
||||
pending_review: 'warning',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
}
|
||||
return severities[status] || 'info'
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// Load nomenclatures
|
||||
await store.fetchAllNomenclatures()
|
||||
|
||||
// Sync suppliers if empty
|
||||
if (store.partners.length === 0) {
|
||||
await syncSuppliers()
|
||||
}
|
||||
|
||||
// Load receipt if edit/view mode
|
||||
if (isEditMode.value || isViewMode.value) {
|
||||
await loadReceipt()
|
||||
} else {
|
||||
// Set company ID for new receipts
|
||||
form.value.company_id = companyStore.selectedCompanyId || 1
|
||||
}
|
||||
})
|
||||
|
||||
// Load existing receipt
|
||||
const loadReceipt = async () => {
|
||||
try {
|
||||
receipt.value = await store.fetchReceiptById(route.params.id)
|
||||
|
||||
// Convert to unified form format
|
||||
form.value = {
|
||||
receipt_type: receipt.value.receipt_type || 'bon_fiscal',
|
||||
receipt_date: new Date(receipt.value.receipt_date),
|
||||
receipt_number: receipt.value.receipt_number || '',
|
||||
partner_name: receipt.value.partner_name || null,
|
||||
cui: receipt.value.cui || '',
|
||||
vendor_address: receipt.value.vendor_address || '',
|
||||
amount: parseFloat(receipt.value.amount) || null,
|
||||
tva: apiToUiTva(receipt.value.tva_breakdown),
|
||||
payments: apiToUiPayments(receipt.value.payment_methods),
|
||||
expense_type_code: receipt.value.expense_type_code || null,
|
||||
description: receipt.value.description || '',
|
||||
ocr_raw_text: receipt.value.ocr_raw_text || '',
|
||||
items_count: receipt.value.items_count || null,
|
||||
company_id: receipt.value.company_id,
|
||||
}
|
||||
|
||||
existingAttachments.value = receipt.value.attachments || []
|
||||
|
||||
// Set supplier source if CUI matches a known supplier
|
||||
if (form.value.cui) {
|
||||
const result = await store.searchSupplier(form.value.cui)
|
||||
if (result.found) {
|
||||
supplierSource.value = result.source
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: 'Nu s-a putut incarca bonul',
|
||||
life: 5000,
|
||||
})
|
||||
router.push('/data-entry')
|
||||
}
|
||||
}
|
||||
|
||||
// OCR handlers
|
||||
const onOCRResult = async (data) => {
|
||||
ocrData.value = data
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'OCR Procesare',
|
||||
detail: 'Datele au fost extrase',
|
||||
life: 3000,
|
||||
})
|
||||
|
||||
// Auto-apply OCR data to form
|
||||
if (data.receipt_type) form.value.receipt_type = data.receipt_type
|
||||
if (data.receipt_date) form.value.receipt_date = new Date(data.receipt_date)
|
||||
if (data.receipt_number) form.value.receipt_number = data.receipt_number
|
||||
if (data.amount) form.value.amount = parseFloat(data.amount)
|
||||
if (data.address) form.value.vendor_address = data.address
|
||||
if (data.raw_text) form.value.ocr_raw_text = data.raw_text
|
||||
if (data.items_count) form.value.items_count = data.items_count
|
||||
|
||||
// Apply TVA
|
||||
if (data.tva_entries?.length > 0) {
|
||||
form.value.tva = apiToUiTva(data.tva_entries)
|
||||
}
|
||||
|
||||
// Apply payments
|
||||
if (data.payment_methods?.length > 0) {
|
||||
form.value.payments = apiToUiPayments(data.payment_methods)
|
||||
}
|
||||
|
||||
// Search for supplier by CUI
|
||||
if (data.cui) {
|
||||
form.value.cui = data.cui
|
||||
|
||||
const result = await store.searchSupplier(data.cui)
|
||||
if (result.found && result.supplier) {
|
||||
form.value.partner_name = result.supplier.name
|
||||
form.value.cui = result.supplier.fiscal_code || data.cui
|
||||
form.value.vendor_address = result.supplier.address || data.address || ''
|
||||
supplierSource.value = result.source
|
||||
supplierWarning.value = { show: false, cui: '', name: '' }
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Furnizor gasit',
|
||||
detail: `${result.supplier.name} (${result.source})`,
|
||||
life: 3000,
|
||||
})
|
||||
} else {
|
||||
// Supplier not found - show warning
|
||||
form.value.partner_name = data.partner_name || ''
|
||||
supplierSource.value = null
|
||||
supplierWarning.value = {
|
||||
show: true,
|
||||
cui: data.cui,
|
||||
name: data.partner_name || ''
|
||||
}
|
||||
}
|
||||
} else if (data.partner_name) {
|
||||
form.value.partner_name = data.partner_name
|
||||
}
|
||||
}
|
||||
|
||||
const onFileSelected = (file) => {
|
||||
// Add OCR file to selected files
|
||||
if (!selectedFiles.value.some(f => f.name === file.name)) {
|
||||
selectedFiles.value = [file, ...selectedFiles.value]
|
||||
}
|
||||
}
|
||||
|
||||
const onOCRError = (message) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare OCR',
|
||||
detail: message,
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
const onPartnerSelected = (partner) => {
|
||||
if (partner) {
|
||||
supplierSource.value = partner.source || 'local'
|
||||
supplierWarning.value = { show: false, cui: '', name: '' }
|
||||
}
|
||||
}
|
||||
|
||||
// File handlers
|
||||
const onAddFiles = (files) => {
|
||||
selectedFiles.value = [...selectedFiles.value, ...files]
|
||||
}
|
||||
|
||||
const onRemoveFile = (index) => {
|
||||
selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const removeExistingAttachment = async (attachmentId) => {
|
||||
try {
|
||||
await store.deleteAttachment(attachmentId)
|
||||
existingAttachments.value = existingAttachments.value.filter(a => a.id !== attachmentId)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: 'Atasamentul a fost sters',
|
||||
life: 2000,
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: 'Nu s-a putut sterge atasamentul',
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const downloadAttachment = async (attachment) => {
|
||||
try {
|
||||
await store.downloadAttachment(attachment.id, attachment.filename)
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: 'Nu s-a putut descarca fisierul',
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Supplier handlers
|
||||
const syncSuppliers = async () => {
|
||||
syncingSuppliers.value = true
|
||||
try {
|
||||
await store.syncSuppliers()
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Sincronizare',
|
||||
detail: 'Furnizorii au fost actualizati',
|
||||
life: 2000,
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: 'Sincronizare esuata',
|
||||
life: 5000,
|
||||
})
|
||||
} finally {
|
||||
syncingSuppliers.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createLocalSupplierFromWarning = () => {
|
||||
pendingSupplierData.value = {
|
||||
name: supplierWarning.value.name || form.value.partner_name || '',
|
||||
fiscal_code: supplierWarning.value.cui || form.value.cui || '',
|
||||
address: form.value.vendor_address || '',
|
||||
}
|
||||
showCreateSupplierDialog.value = true
|
||||
}
|
||||
|
||||
const createLocalSupplier = async () => {
|
||||
try {
|
||||
await store.createLocalSupplier(pendingSupplierData.value)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: 'Furnizor local creat',
|
||||
life: 2000,
|
||||
})
|
||||
showCreateSupplierDialog.value = false
|
||||
supplierSource.value = 'local'
|
||||
supplierWarning.value = { show: false, cui: '', name: '' }
|
||||
|
||||
// Update form with created supplier
|
||||
form.value.partner_name = pendingSupplierData.value.name
|
||||
form.value.cui = pendingSupplierData.value.fiscal_code
|
||||
form.value.vendor_address = pendingSupplierData.value.address
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: 'Nu s-a putut crea furnizorul',
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Save handlers
|
||||
const validateForm = () => {
|
||||
if (!form.value.receipt_date) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Validare',
|
||||
detail: 'Data este obligatorie',
|
||||
life: 3000,
|
||||
})
|
||||
return false
|
||||
}
|
||||
if (!form.value.amount || form.value.amount <= 0) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Validare',
|
||||
detail: 'Suma este obligatorie',
|
||||
life: 3000,
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const saveReceipt = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
// Auto-create supplier if warning shown
|
||||
if (supplierWarning.value.show && form.value.cui) {
|
||||
try {
|
||||
await store.createLocalSupplier({
|
||||
name: form.value.partner_name || `Furnizor ${form.value.cui}`,
|
||||
fiscal_code: form.value.cui,
|
||||
address: form.value.vendor_address || null,
|
||||
})
|
||||
supplierWarning.value = { show: false, cui: '', name: '' }
|
||||
supplierSource.value = 'local'
|
||||
} catch (e) {
|
||||
console.warn('Auto-create supplier failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert form to API format
|
||||
const payload = unifiedFormToApiPayload(form.value)
|
||||
|
||||
let savedReceipt
|
||||
|
||||
if (isEditMode.value) {
|
||||
savedReceipt = await store.updateReceipt(route.params.id, payload)
|
||||
} else {
|
||||
savedReceipt = await store.createReceipt(payload)
|
||||
}
|
||||
|
||||
// Upload new files
|
||||
for (const file of selectedFiles.value) {
|
||||
try {
|
||||
await store.uploadAttachment(savedReceipt.id, file)
|
||||
} catch (e) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Atentie',
|
||||
detail: `Nu s-a putut incarca: ${file.name}`,
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: isEditMode.value ? 'Bonul a fost actualizat' : 'Bonul a fost creat',
|
||||
life: 3000,
|
||||
})
|
||||
|
||||
router.push(`/data-entry/${savedReceipt.id}`)
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: error.message || 'Nu s-a putut salva bonul',
|
||||
life: 5000,
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitForReview = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
await saveReceipt()
|
||||
const result = await store.submitReceipt(route.params.id)
|
||||
|
||||
if (result.success) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: 'Bonul a fost trimis spre aprobare',
|
||||
life: 3000,
|
||||
})
|
||||
router.push('/data-entry')
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: result.message,
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: error.message || 'Nu s-a putut trimite bonul',
|
||||
life: 5000,
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const submitReceipt = async () => {
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await store.submitReceipt(route.params.id)
|
||||
if (result.success) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: 'Bonul a fost trimis',
|
||||
life: 3000,
|
||||
})
|
||||
await loadReceipt()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: error.message,
|
||||
life: 5000,
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const approveReceipt = async () => {
|
||||
approving.value = true
|
||||
try {
|
||||
const result = await store.approveReceipt(route.params.id)
|
||||
if (result.success) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: 'Bonul a fost aprobat',
|
||||
life: 3000,
|
||||
})
|
||||
await loadReceipt()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: error.message,
|
||||
life: 5000,
|
||||
})
|
||||
} finally {
|
||||
approving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openRejectDialog = () => {
|
||||
rejectReason.value = ''
|
||||
showRejectDialog.value = true
|
||||
}
|
||||
|
||||
const rejectReceipt = async () => {
|
||||
rejecting.value = true
|
||||
try {
|
||||
const result = await store.rejectReceipt(route.params.id, rejectReason.value)
|
||||
if (result.success) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: 'Bonul a fost respins',
|
||||
life: 3000,
|
||||
})
|
||||
showRejectDialog.value = false
|
||||
await loadReceipt()
|
||||
}
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: error.message,
|
||||
life: 5000,
|
||||
})
|
||||
} finally {
|
||||
rejecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation
|
||||
const goBack = () => {
|
||||
router.push('/data-entry')
|
||||
}
|
||||
|
||||
const goToEdit = () => {
|
||||
router.push(`/data-entry/${route.params.id}/edit`)
|
||||
}
|
||||
|
||||
const formatDateTime = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleString('ro-RO')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.receipt-unified-view {
|
||||
padding: 1rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.view-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--surface-card);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.view-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Messages */
|
||||
.rejection-message {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Validation */
|
||||
.validation-banners {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Dialog */
|
||||
.dialog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.form-field label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 640px) {
|
||||
.receipt-unified-view {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.view-header {
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
[data-theme="dark"] .view-header {
|
||||
background: var(--surface-card);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user