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>
|
||||
Reference in New Issue
Block a user