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:
@@ -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