Files
roa2web-service-auto/src/modules/data-entry/components/receipts/AuxiliarySection.vue
Claude Agent b4a226409c 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>
2026-01-08 21:48:37 +00:00

322 lines
7.3 KiB
Vue

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