feat: Add OCR integration for automatic receipt data extraction
Implement Tesseract-based OCR to automatically extract vendor name, date, total amount, and VAT from uploaded receipt images/PDFs, reducing manual data entry and improving accuracy. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<span
|
||||
class="confidence-indicator"
|
||||
:class="confidenceClass"
|
||||
:title="tooltipText"
|
||||
>
|
||||
<i :class="iconClass"></i>
|
||||
<span v-if="showPercentage" class="percentage">{{ percentageText }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
confidence: {
|
||||
type: Number,
|
||||
required: true,
|
||||
validator: (value) => value >= 0 && value <= 1
|
||||
},
|
||||
showPercentage: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'normal',
|
||||
validator: (value) => ['small', 'normal', 'large'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
const percentageText = computed(() => {
|
||||
return Math.round(props.confidence * 100) + '%'
|
||||
})
|
||||
|
||||
const confidenceClass = computed(() => {
|
||||
const classes = [`size-${props.size}`]
|
||||
|
||||
if (props.confidence >= 0.85) {
|
||||
classes.push('high')
|
||||
} else if (props.confidence >= 0.6) {
|
||||
classes.push('medium')
|
||||
} else {
|
||||
classes.push('low')
|
||||
}
|
||||
|
||||
return classes
|
||||
})
|
||||
|
||||
const iconClass = computed(() => {
|
||||
if (props.confidence >= 0.85) {
|
||||
return 'pi pi-check-circle'
|
||||
} else if (props.confidence >= 0.6) {
|
||||
return 'pi pi-exclamation-circle'
|
||||
} else {
|
||||
return 'pi pi-question-circle'
|
||||
}
|
||||
})
|
||||
|
||||
const tooltipText = computed(() => {
|
||||
const percent = Math.round(props.confidence * 100)
|
||||
if (props.confidence >= 0.85) {
|
||||
return `Incredere ridicata: ${percent}%`
|
||||
} else if (props.confidence >= 0.6) {
|
||||
return `Incredere medie: ${percent}% - verifica valoarea`
|
||||
} else {
|
||||
return `Incredere scazuta: ${percent}% - completeaza manual`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.confidence-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.size-small {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.1rem 0.35rem;
|
||||
}
|
||||
|
||||
.size-small i {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.size-normal i {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.size-large {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
}
|
||||
|
||||
.size-large i {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
/* Confidence levels */
|
||||
.high {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.medium {
|
||||
background: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
|
||||
.low {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.percentage {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
</style>
|
||||
279
data-entry-app/frontend/src/components/ocr/OCRPreview.vue
Normal file
279
data-entry-app/frontend/src/components/ocr/OCRPreview.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div class="ocr-preview">
|
||||
<div class="preview-header">
|
||||
<div class="header-left">
|
||||
<i class="pi pi-check-circle" style="color: #22c55e; font-size: 1.25rem;"></i>
|
||||
<span class="title">Date extrase din imagine</span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<span class="overall-confidence">
|
||||
Incredere generala:
|
||||
<OCRConfidenceIndicator
|
||||
:confidence="data.overall_confidence"
|
||||
:show-percentage="true"
|
||||
size="normal"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-content">
|
||||
<div class="preview-grid">
|
||||
<!-- Receipt Type -->
|
||||
<div class="preview-field" v-if="data.receipt_type">
|
||||
<label>Tip Document</label>
|
||||
<div class="field-value">
|
||||
<Tag
|
||||
:value="data.receipt_type === 'bon_fiscal' ? 'Bon Fiscal' : 'Chitanta'"
|
||||
:severity="data.receipt_type === 'bon_fiscal' ? 'info' : 'success'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Amount -->
|
||||
<div class="preview-field" v-if="data.amount">
|
||||
<label>
|
||||
Suma
|
||||
<OCRConfidenceIndicator :confidence="data.confidence_amount" size="small" />
|
||||
</label>
|
||||
<div class="field-value amount">
|
||||
{{ formatAmount(data.amount) }} RON
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Date -->
|
||||
<div class="preview-field" v-if="data.receipt_date">
|
||||
<label>
|
||||
Data
|
||||
<OCRConfidenceIndicator :confidence="data.confidence_date" size="small" />
|
||||
</label>
|
||||
<div class="field-value">
|
||||
{{ formatDate(data.receipt_date) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Receipt Number -->
|
||||
<div class="preview-field" v-if="data.receipt_number">
|
||||
<label>Numar Bon</label>
|
||||
<div class="field-value">
|
||||
{{ data.receipt_series ? data.receipt_series + ' ' : '' }}{{ data.receipt_number }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vendor -->
|
||||
<div class="preview-field full-width" v-if="data.partner_name">
|
||||
<label>
|
||||
Furnizor
|
||||
<OCRConfidenceIndicator :confidence="data.confidence_vendor" size="small" />
|
||||
</label>
|
||||
<div class="field-value">
|
||||
{{ data.partner_name }}
|
||||
<span v-if="data.cui" class="cui-badge">CUI: {{ data.cui }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Raw Text Toggle -->
|
||||
<div class="raw-text-section" v-if="data.raw_text">
|
||||
<Button
|
||||
:label="showRawText ? 'Ascunde text OCR' : 'Arata text OCR'"
|
||||
:icon="showRawText ? 'pi pi-eye-slash' : 'pi pi-eye'"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
@click="showRawText = !showRawText"
|
||||
/>
|
||||
<div v-if="showRawText" class="raw-text">
|
||||
<pre>{{ data.raw_text }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-actions">
|
||||
<Button
|
||||
label="Ignora"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
@click="$emit('dismiss')"
|
||||
/>
|
||||
<Button
|
||||
label="Aplica datele in formular"
|
||||
icon="pi pi-check"
|
||||
@click="$emit('apply', data)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import OCRConfidenceIndicator from './OCRConfidenceIndicator.vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['apply', 'dismiss'])
|
||||
|
||||
const showRawText = ref(false)
|
||||
|
||||
const formatAmount = (amount) => {
|
||||
const num = parseFloat(amount)
|
||||
return num.toLocaleString('ro-RO', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
})
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('ro-RO', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ocr-preview {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 12px;
|
||||
margin: 1rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #dcfce7;
|
||||
border-bottom: 1px solid #86efac;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.overall-confidence {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.preview-content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.preview-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.preview-field.full-width {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.preview-field label {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.field-value.amount {
|
||||
font-size: 1.25rem;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.cui-badge {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
background: #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.raw-text-section {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px dashed #86efac;
|
||||
}
|
||||
|
||||
.raw-text {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
max-height: 200px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.raw-text pre {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.preview-header {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.preview-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.preview-actions :deep(.p-button) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
291
data-entry-app/frontend/src/components/ocr/OCRUploadZone.vue
Normal file
291
data-entry-app/frontend/src/components/ocr/OCRUploadZone.vue
Normal file
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<div class="ocr-upload-zone">
|
||||
<div
|
||||
class="upload-dropzone"
|
||||
:class="{ 'dragging': isDragging, 'processing': processing }"
|
||||
@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"
|
||||
/>
|
||||
|
||||
<div v-if="processing" class="processing-state">
|
||||
<ProgressSpinner
|
||||
style="width: 50px; height: 50px"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<p class="processing-text">Se proceseaza imaginea...</p>
|
||||
<p class="processing-subtext">Acest proces poate dura cateva secunde</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedFile" class="file-selected-state">
|
||||
<i class="pi pi-check-circle" style="font-size: 2.5rem; color: #22c55e;"></i>
|
||||
<p class="file-name">{{ selectedFile.name }}</p>
|
||||
<p class="file-size">{{ formatFileSize(selectedFile.size) }}</p>
|
||||
<div class="file-actions">
|
||||
<Button
|
||||
label="Schimba fisierul"
|
||||
icon="pi pi-refresh"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
@click.stop="triggerFileInput"
|
||||
/>
|
||||
<Button
|
||||
label="Proceseaza cu OCR"
|
||||
icon="pi pi-cog"
|
||||
size="small"
|
||||
@click.stop="processOCR"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<i class="pi pi-camera" style="font-size: 3rem; color: #667eea;"></i>
|
||||
<p class="main-text">
|
||||
<span v-if="isDragging">Elibereaza pentru a incarca</span>
|
||||
<span v-else>Trage poza bonului aici sau click pentru a selecta</span>
|
||||
</p>
|
||||
<p class="sub-text">
|
||||
Formate acceptate: JPG, PNG, PDF (max 10MB)
|
||||
</p>
|
||||
<p class="ocr-hint">
|
||||
<i class="pi pi-sparkles"></i>
|
||||
OCR va extrage automat datele din bon
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OCR Error Message -->
|
||||
<Message v-if="error" severity="error" :closable="true" @close="error = null">
|
||||
{{ error }}
|
||||
</Message>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import axios from 'axios'
|
||||
|
||||
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)
|
||||
|
||||
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 = (file) => {
|
||||
// Validate file type
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'application/pdf']
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
error.value = 'Tip de fisier invalid. Sunt acceptate doar: JPG, PNG, PDF'
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file size (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
error.value = 'Fisierul este prea mare. Dimensiunea maxima este 10MB.'
|
||||
return
|
||||
}
|
||||
|
||||
error.value = null
|
||||
selectedFile.value = file
|
||||
emit('file-selected', file)
|
||||
}
|
||||
|
||||
const processOCR = async () => {
|
||||
if (!selectedFile.value) return
|
||||
|
||||
processing.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile.value)
|
||||
|
||||
const response = await axios.post('/api/ocr/extract', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000, // 60 second timeout for OCR
|
||||
})
|
||||
|
||||
if (response.data.success) {
|
||||
emit('ocr-result', response.data.data)
|
||||
} else {
|
||||
error.value = response.data.message || 'OCR processing failed'
|
||||
emit('error', error.value)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err.response?.data?.detail || err.message || 'Eroare la procesarea OCR'
|
||||
error.value = message
|
||||
emit('error', message)
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Expose methods for parent components
|
||||
defineExpose({ reset, processOCR })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ocr-upload-zone {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.upload-dropzone {
|
||||
border: 2px dashed #cbd5e1;
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.upload-dropzone:hover {
|
||||
border-color: #667eea;
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.upload-dropzone.dragging {
|
||||
border-color: #667eea;
|
||||
background: #eef2ff;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.upload-dropzone.processing {
|
||||
cursor: default;
|
||||
background: #fefefe;
|
||||
}
|
||||
|
||||
.hidden-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.main-text {
|
||||
font-size: 1rem;
|
||||
color: #475569;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.sub-text {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ocr-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #667eea;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #eef2ff;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* File selected state */
|
||||
.file-selected-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
margin: 0.5rem 0 0 0;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Processing state */
|
||||
.processing-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.processing-text {
|
||||
font-size: 1rem;
|
||||
color: #475569;
|
||||
margin: 0.5rem 0 0 0;
|
||||
}
|
||||
|
||||
.processing-subtext {
|
||||
font-size: 0.85rem;
|
||||
color: #94a3b8;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -15,14 +15,43 @@
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveReceipt">
|
||||
<!-- Upload Section -->
|
||||
<div class="upload-section">
|
||||
<!-- OCR Upload Section (only for new receipts) -->
|
||||
<div class="upload-section" v-if="!isEditMode">
|
||||
<h3>
|
||||
<i class="pi pi-camera"></i>
|
||||
Poza Bon (obligatoriu)
|
||||
</h3>
|
||||
|
||||
<!-- OCR Upload Zone -->
|
||||
<OCRUploadZone
|
||||
ref="ocrUploadZone"
|
||||
@ocr-result="onOCRResult"
|
||||
@file-selected="onOCRFileSelected"
|
||||
@error="onOCRError"
|
||||
/>
|
||||
|
||||
<!-- OCR Preview (when results are available) -->
|
||||
<OCRPreview
|
||||
v-if="ocrData"
|
||||
:data="ocrData"
|
||||
@apply="applyOCRData"
|
||||
@dismiss="dismissOCRData"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Standard Upload Section (for edit mode or additional files) -->
|
||||
<div class="upload-section" v-if="isEditMode || selectedFiles.length > 0">
|
||||
<h3 v-if="isEditMode">
|
||||
<i class="pi pi-camera"></i>
|
||||
Poza Bon (obligatoriu)
|
||||
</h3>
|
||||
<h3 v-else-if="selectedFiles.length > 0">
|
||||
<i class="pi pi-paperclip"></i>
|
||||
Fisiere Selectate
|
||||
</h3>
|
||||
|
||||
<FileUpload
|
||||
v-if="isEditMode"
|
||||
ref="fileUpload"
|
||||
mode="advanced"
|
||||
:multiple="true"
|
||||
@@ -70,6 +99,26 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected files preview (create mode) -->
|
||||
<div v-if="!isEditMode && selectedFiles.length" class="selected-files-list">
|
||||
<div
|
||||
v-for="(file, index) in selectedFiles"
|
||||
:key="index"
|
||||
class="selected-file-item"
|
||||
>
|
||||
<i :class="file.type.startsWith('image/') ? 'pi pi-image' : 'pi pi-file-pdf'"></i>
|
||||
<span class="file-name">{{ file.name }}</span>
|
||||
<span class="file-size">{{ formatFileSize(file.size) }}</span>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
rounded
|
||||
size="small"
|
||||
@click="removeSelectedFile(index)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
@@ -235,10 +284,12 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useReceiptsStore } from '../../stores/receiptsStore'
|
||||
import OCRUploadZone from '../../components/ocr/OCRUploadZone.vue'
|
||||
import OCRPreview from '../../components/ocr/OCRPreview.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -270,6 +321,11 @@ const existingAttachments = ref([])
|
||||
const saving = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
// OCR related refs
|
||||
const ocrUploadZone = ref(null)
|
||||
const ocrData = ref(null)
|
||||
const ocrFile = ref(null)
|
||||
|
||||
const partners = computed(() => store.partners)
|
||||
const expenseTypes = computed(() => store.expenseTypes)
|
||||
const cashRegisters = computed(() => store.cashRegisters)
|
||||
@@ -315,6 +371,85 @@ const loadReceipt = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// OCR handlers
|
||||
const onOCRFileSelected = (file) => {
|
||||
ocrFile.value = file
|
||||
// Add to selected files for upload
|
||||
if (!selectedFiles.value.some(f => f.name === file.name)) {
|
||||
selectedFiles.value = [file, ...selectedFiles.value]
|
||||
}
|
||||
}
|
||||
|
||||
const onOCRResult = (data) => {
|
||||
ocrData.value = data
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'OCR Procesare',
|
||||
detail: 'Datele au fost extrase din imagine',
|
||||
life: 3000,
|
||||
})
|
||||
}
|
||||
|
||||
const onOCRError = (message) => {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare OCR',
|
||||
detail: message,
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
|
||||
const applyOCRData = (data) => {
|
||||
// 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.amount) {
|
||||
form.value.amount = parseFloat(data.amount)
|
||||
}
|
||||
|
||||
if (data.receipt_number) {
|
||||
form.value.receipt_number = data.receipt_number
|
||||
}
|
||||
|
||||
// Try to find matching partner by name or CUI
|
||||
if (data.partner_name || data.cui) {
|
||||
const matchingPartner = partners.value.find(p => {
|
||||
const nameMatch = data.partner_name &&
|
||||
p.name.toLowerCase().includes(data.partner_name.toLowerCase())
|
||||
const cuiMatch = data.cui && p.cui === data.cui
|
||||
return nameMatch || cuiMatch
|
||||
})
|
||||
|
||||
if (matchingPartner) {
|
||||
form.value.partner_id = matchingPartner.id
|
||||
form.value.partner_name = matchingPartner.name
|
||||
} else if (data.partner_name) {
|
||||
// Store the extracted name even if no match
|
||||
form.value.partner_name = data.partner_name
|
||||
}
|
||||
}
|
||||
|
||||
// Clear OCR preview
|
||||
ocrData.value = null
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Date aplicate',
|
||||
detail: 'Datele OCR au fost aplicate in formular',
|
||||
life: 3000,
|
||||
})
|
||||
}
|
||||
|
||||
const dismissOCRData = () => {
|
||||
ocrData.value = null
|
||||
}
|
||||
|
||||
const onPartnerChange = (event) => {
|
||||
const partner = partners.value.find(p => p.id === event.value)
|
||||
form.value.partner_name = partner?.name || null
|
||||
@@ -334,6 +469,10 @@ const onFileRemove = (event) => {
|
||||
selectedFiles.value = selectedFiles.value.filter(f => f.name !== event.file.name)
|
||||
}
|
||||
|
||||
const removeSelectedFile = (index) => {
|
||||
selectedFiles.value = selectedFiles.value.filter((_, i) => i !== index)
|
||||
}
|
||||
|
||||
const removeExistingAttachment = async (attachmentId) => {
|
||||
try {
|
||||
await store.deleteAttachment(attachmentId)
|
||||
@@ -354,7 +493,24 @@ const removeExistingAttachment = async (attachmentId) => {
|
||||
}
|
||||
}
|
||||
|
||||
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 validateForm = () => {
|
||||
// Check if we have at least one file (for new receipts)
|
||||
if (!isEditMode.value && selectedFiles.value.length === 0) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Validare',
|
||||
detail: 'Trebuie sa adaugi cel putin o poza a bonului',
|
||||
life: 3000,
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
if (!form.value.receipt_date) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
@@ -532,4 +688,41 @@ const submitForReview = async () => {
|
||||
text-align: center;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* Selected files list */
|
||||
.selected-files-list {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.selected-file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.selected-file-item i {
|
||||
color: #667eea;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.selected-file-item .file-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.selected-file-item .file-size {
|
||||
font-size: 0.85rem;
|
||||
color: #64748b;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user