feat(ocr): Add docTR OCR engine with metrics infrastructure

Add docTR as primary OCR engine with 2-tier sequential processing,
OCR metrics tracking, and simplified engine selection.

Features:
- docTR OCR engine with light+medium preprocessing tiers
- doctr_plus mode with early exit optimization (~65% fast path)
- OCR metrics dashboard with per-engine statistics
- User OCR preference persistence
- Parallel worker pool for OCR processing
- Cross-validation for extraction quality

Engine options: tesseract, doctr, doctr_plus (recommended), paddleocr

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-02 05:37:16 +02:00
parent 74f7aefc26
commit 495790411f
75 changed files with 23349 additions and 1311 deletions

View File

@@ -19,7 +19,8 @@ export const menuSections = [
title: 'Sistem',
items: [
{ to: '/reports/telegram', icon: 'pi pi-telegram', label: 'Telegram Bot' },
{ to: '/reports/cache-stats', icon: 'pi pi-chart-bar', label: 'Statistici Cache' }
{ to: '/reports/cache-stats', icon: 'pi pi-chart-bar', label: 'Statistici Cache' },
{ to: '/data-entry/ocr-metrics', icon: 'pi pi-eye', label: 'Statistici OCR' }
]
}
]

View File

@@ -263,6 +263,12 @@ const formatDate = (dateStr) => {
const getEngineClass = (engine) => {
if (!engine) return ''
// docTR engines
if (engine === 'doctr-light') return 'doctr-fast'
if (engine === 'doctr-medium') return 'doctr'
if (engine === 'doctr-adaptive') return 'doctr-adaptive'
if (engine.includes('doctr')) return 'doctr'
// PaddleOCR engines
if (engine === 'paddle-light') return 'fast'
if (engine === 'paddle-adaptive') return 'adaptive'
if (engine === 'adaptive-full') return 'full'
@@ -273,13 +279,23 @@ const getEngineClass = (engine) => {
const getEngineIcon = (engine) => {
if (!engine) return 'pi pi-cog'
if (engine === 'paddle-light') return 'pi pi-bolt' // Fast/lightning
if (engine === 'adaptive-full') return 'pi pi-cog' // Full pipeline
// docTR - use bolt for fast modes
if (engine === 'doctr-light') return 'pi pi-bolt'
if (engine.includes('doctr')) return 'pi pi-bolt'
// PaddleOCR
if (engine === 'paddle-light') return 'pi pi-bolt'
if (engine === 'adaptive-full') return 'pi pi-cog'
return 'pi pi-cog'
}
const getEngineLabel = (engine) => {
if (!engine) return ''
// docTR engines
if (engine === 'doctr-light') return 'docTR Fast'
if (engine === 'doctr-medium') return 'docTR Medium'
if (engine === 'doctr-adaptive') return 'docTR Adaptive'
if (engine.includes('doctr')) return 'docTR'
// PaddleOCR engines
if (engine === 'paddle-light') return 'Fast Mode (PaddleOCR)'
if (engine === 'paddle-adaptive') return 'Adaptive (Paddle dual)'
if (engine === 'adaptive-full') return 'Full Pipeline'
@@ -615,6 +631,22 @@ const formatProcessingTime = (ms) => {
color: #92400e;
}
/* docTR engine styles */
.ocr-engine-badge.doctr {
background: #ede9fe;
color: #5b21b6;
}
.ocr-engine-badge.doctr-fast {
background: #d1fae5;
color: #047857;
}
.ocr-engine-badge.doctr-adaptive {
background: #e0e7ff;
color: #3730a3;
}
.ocr-message-badge {
display: inline-flex;
align-items: center;

View File

@@ -60,7 +60,14 @@
optionValue="value"
placeholder="Motor OCR"
class="engine-selector dropdown-borderless"
/>
>
<template #option="{ option }">
<div class="engine-option">
<span class="engine-label">{{ option.label }}</span>
<span class="engine-desc">{{ option.desc }}</span>
</div>
</template>
</Dropdown>
<Button
label="Proceseaza OCR"
icon="pi pi-cog"
@@ -77,9 +84,10 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import Dropdown from 'primevue/dropdown'
import api from '@data-entry/services/api'
import { useOCRSettingsStore } from '@data-entry/stores/ocrSettingsStore'
const emit = defineEmits(['ocr-result', 'file-selected', 'error'])
@@ -89,20 +97,73 @@ const isDragging = ref(false)
const processing = ref(false)
const error = ref(null)
// OCR Engine selection
// OCR Settings Store - manages user preferences
const ocrStore = useOCRSettingsStore()
// OCR Engine selection (synced with store)
const selectedEngine = ref('auto')
const engineOptions = [
{ label: 'Auto (Recomandat)', value: 'auto' },
{ label: 'PaddleOCR', value: 'paddleocr' },
{ label: 'Tesseract', value: 'tesseract' }
]
// Engine config - labels and descriptions for dropdown
const engineConfig = {
'auto': {
label: 'Auto',
desc: 'docTR→Paddle→Tess · General'
},
'doctr': {
label: 'docTR',
desc: 'Rapid, bună acuratețe'
},
'paddleocr': {
label: 'PaddleOCR',
desc: 'Cea mai bună calitate'
},
'tesseract': {
label: 'Tesseract',
desc: 'Cel mai rapid, calitate redusă'
},
'hybrid': {
label: 'Hybrid',
desc: 'docTR+Tess paralel · Recomandat'
},
'hybrid-quality': {
label: 'Hybrid Calitate',
desc: 'Paddle→docTR→Tess · Acuratețe max'
},
}
// Compute engine options from store's available engines
const engineOptions = computed(() => {
return ocrStore.availableEngines.map(engine => ({
label: engineConfig[engine]?.label || engine,
desc: engineConfig[engine]?.desc || '',
value: engine
}))
})
// Load user's preferred engine on mount
onMounted(async () => {
await ocrStore.loadPreference()
selectedEngine.value = ocrStore.preferredEngine
console.log('[OCRUploadZone] Loaded user preference:', selectedEngine.value)
})
// Save preference when user changes engine
watch(selectedEngine, async (newEngine, oldEngine) => {
if (oldEngine && newEngine !== oldEngine && ocrStore.initialized) {
try {
await ocrStore.setPreference(newEngine)
console.log('[OCRUploadZone] Saved user preference:', newEngine)
} catch (err) {
console.error('[OCRUploadZone] Failed to save preference:', err)
}
}
})
// Job queue state
const jobId = ref(null)
const queuePosition = ref(null)
const estimatedWait = ref(null)
const jobStatus = ref(null)
let pollInterval = null
// Dynamic processing messages
const processingMessage = computed(() => {
@@ -223,26 +284,36 @@ const processOCR = async () => {
}
const pollJobStatus = async (id) => {
const maxAttempts = 120 // 2 minutes max (120 * 1s)
let attempts = 0
const LONG_POLL_TIMEOUT = 30 // seconds
const MAX_TOTAL_TIME = 120 // 2 minutes max
const startTime = Date.now()
const poll = async () => {
try {
const response = await api.get(`/ocr/jobs/${id}`)
const job = response.data
// Check if exceeded max total time
const elapsed = (Date.now() - startTime) / 1000
if (elapsed >= MAX_TOTAL_TIME) {
processing.value = false
error.value = 'Timeout - procesarea a durat prea mult'
emit('error', error.value)
return
}
try {
// Long-poll with 30s server timeout, 35s axios timeout
const response = await api.get(`/ocr/jobs/${id}/wait`, {
params: { timeout: LONG_POLL_TIMEOUT },
timeout: (LONG_POLL_TIMEOUT + 5) * 1000
})
const job = response.data
jobStatus.value = job.status
queuePosition.value = job.queue_position
estimatedWait.value = job.estimated_wait_seconds
console.log('📊 OCR Poll:', { status: job.status, position: job.queue_position })
console.log('📊 OCR Long-Poll:', { status: job.status, position: job.queue_position })
if (job.status === 'completed') {
// Success! Emit result
clearInterval(pollInterval)
pollInterval = null
processing.value = false
if (job.result) {
console.log('✅ OCR Complete:', job.result)
emit('ocr-result', {
@@ -257,47 +328,36 @@ const pollJobStatus = async (id) => {
}
if (job.status === 'failed') {
// Failed
clearInterval(pollInterval)
pollInterval = null
processing.value = false
error.value = job.error || 'OCR processing failed'
emit('error', error.value)
return
}
// Still pending/processing - continue polling
attempts++
if (attempts >= maxAttempts) {
clearInterval(pollInterval)
pollInterval = null
processing.value = false
error.value = 'Timeout - procesarea a durat prea mult'
emit('error', error.value)
// Still pending/processing - long-poll again
if (processing.value) {
await poll()
}
} catch (err) {
console.error('🔴 Poll Error:', err.message)
attempts++
// Don't stop on poll errors - network might be flaky
if (attempts >= maxAttempts) {
clearInterval(pollInterval)
pollInterval = null
processing.value = false
error.value = 'Eroare la verificarea starii job-ului'
emit('error', error.value)
// Handle timeout (normal for long-poll)
if (err.code === 'ECONNABORTED' || err.message?.includes('timeout')) {
console.log('⏱️ Long-poll timeout, retrying...')
if (processing.value) {
await poll()
}
return
}
// Real error
console.error('🔴 Poll Error:', err.message)
processing.value = false
error.value = 'Eroare la verificarea starii job-ului'
emit('error', error.value)
}
}
// Initial poll immediately
await poll()
// Continue polling every 1 second if still processing
if (processing.value) {
pollInterval = setInterval(poll, 1000)
}
}
const formatFileSize = (bytes) => {
@@ -313,10 +373,7 @@ const reset = () => {
queuePosition.value = null
estimatedWait.value = null
jobStatus.value = null
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
processing.value = false // Stop any ongoing long-poll
if (fileInput.value) {
fileInput.value.value = ''
}
@@ -415,7 +472,7 @@ defineExpose({ reset, processOCR })
/* Engine selector dropdown */
.engine-selector {
min-width: 150px;
min-width: 180px;
}
.engine-selector:deep(.p-dropdown-label) {
@@ -428,6 +485,25 @@ defineExpose({ reset, processOCR })
width: 2rem !important;
}
/* Engine dropdown option with description */
.engine-option {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 0;
}
.engine-label {
font-weight: 500;
font-size: 0.875rem;
color: #1e293b;
}
.engine-desc {
font-size: 0.75rem;
color: #64748b;
}
/* Processing state */
.processing-state {
display: flex;

View File

@@ -0,0 +1,173 @@
/**
* OCR Settings Store
*
* Manages user's OCR engine preference and metrics.
* - Auto-loads user's preferred engine on mount
* - Saves preference to backend on change
* - Provides OCR metrics for dashboard
*/
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import api from '@data-entry/services/api'
export const useOCRSettingsStore = defineStore('ocrSettings', () => {
// State
const preferredEngine = ref('doctr_plus')
// Available engines
// NOTE: This default list is overwritten by loadPreference() from backend
// Backend filters engines based on OCR_ENABLE_PADDLEOCR and OCR_ENABLE_TESSERACT
const availableEngines = ref([
'tesseract',
'doctr',
'doctr_plus', // Recommended: 2-tier sequential with early exit
'paddleocr',
])
const loading = ref(false)
const error = ref(null)
const initialized = ref(false)
// Metrics state
const metrics = ref({
summary: [],
stats: null,
history: [],
historyTotal: 0,
})
const metricsLoading = ref(false)
// Computed
const isLoading = computed(() => loading.value)
const hasError = computed(() => !!error.value)
// Actions
async function loadPreference() {
if (initialized.value) return
loading.value = true
error.value = null
try {
const response = await api.get('/settings/ocr-preference')
preferredEngine.value = response.data.preferred_engine
availableEngines.value = response.data.available_engines
initialized.value = true
console.log('[OCRSettings] Loaded preference:', preferredEngine.value)
} catch (err) {
console.error('[OCRSettings] Failed to load preference:', err)
error.value = err.message
// Use defaults on error
preferredEngine.value = 'doctr_plus'
} finally {
loading.value = false
}
}
async function setPreference(engine) {
loading.value = true
error.value = null
try {
const response = await api.post('/settings/ocr-preference', {
preferred_engine: engine
})
preferredEngine.value = response.data.preferred_engine
console.log('[OCRSettings] Saved preference:', preferredEngine.value)
} catch (err) {
console.error('[OCRSettings] Failed to save preference:', err)
error.value = err.message
throw err
} finally {
loading.value = false
}
}
async function loadMetricsSummary(days = 30) {
metricsLoading.value = true
try {
const response = await api.get('/metrics/ocr/summary', { params: { days } })
metrics.value.summary = response.data
console.log('[OCRSettings] Loaded metrics summary:', metrics.value.summary.length, 'engines')
} catch (err) {
console.error('[OCRSettings] Failed to load metrics summary:', err)
} finally {
metricsLoading.value = false
}
}
async function loadMetricsStats(days = 30) {
try {
const response = await api.get('/metrics/ocr/stats', { params: { days } })
metrics.value.stats = response.data
console.log('[OCRSettings] Loaded metrics stats:', metrics.value.stats)
} catch (err) {
console.error('[OCRSettings] Failed to load metrics stats:', err)
}
}
async function loadMetricsHistory(limit = 50, offset = 0) {
try {
const response = await api.get('/metrics/ocr/history', { params: { limit, offset } })
metrics.value.history = response.data.items
metrics.value.historyTotal = response.data.total
console.log('[OCRSettings] Loaded metrics history:', metrics.value.history.length, 'items')
} catch (err) {
console.error('[OCRSettings] Failed to load metrics history:', err)
}
}
async function loadAllMetrics(days = 30) {
metricsLoading.value = true
try {
await Promise.all([
loadMetricsSummary(days),
loadMetricsStats(days),
loadMetricsHistory(20),
])
} finally {
metricsLoading.value = false
}
}
// Reset state
function $reset() {
preferredEngine.value = 'doctr_plus'
availableEngines.value = [
'tesseract', 'doctr', 'doctr_plus', 'paddleocr',
]
loading.value = false
error.value = null
initialized.value = false
metrics.value = {
summary: [],
stats: null,
history: [],
historyTotal: 0,
}
}
return {
// State
preferredEngine,
availableEngines,
loading,
error,
initialized,
metrics,
metricsLoading,
// Computed
isLoading,
hasError,
// Actions
loadPreference,
setPreference,
loadMetricsSummary,
loadMetricsStats,
loadMetricsHistory,
loadAllMetrics,
$reset,
}
})

View File

@@ -399,7 +399,9 @@ export const useReceiptsStore = defineStore('receipts', {
this.partners.push({
id: response.data.id,
name: response.data.name,
code: response.data.fiscal_code,
fiscal_code: response.data.fiscal_code,
address: response.data.address,
source: 'local',
})
return response.data
} catch (error) {
@@ -407,6 +409,20 @@ export const useReceiptsStore = defineStore('receipts', {
}
},
async syncSuppliers() {
try {
// Use apiClient directly - nomenclature endpoints at /api/nomenclature
const response = await apiClient.post('/nomenclature/sync/suppliers')
console.log('[receiptsStore] Synced suppliers:', response.data)
// Refresh partners list after sync
await this.fetchPartners()
return response.data
} catch (error) {
console.error('[receiptsStore] Supplier sync failed:', error)
throw error
}
},
// ============ Stats ============
async fetchStats() {

File diff suppressed because it is too large Load Diff

View File

@@ -244,7 +244,20 @@
<div class="form-group">
<div class="form-row">
<div class="form-field flex-2">
<label>Furnizor</label>
<div class="label-with-action">
<label>Furnizor</label>
<Button
v-if="!isReadOnly"
icon="pi pi-sync"
size="small"
text
rounded
:loading="syncingSuppliers"
@click="resyncSuppliers"
v-tooltip.top="'Re-sincronizeaza furnizorii din Oracle'"
class="sync-btn"
/>
</div>
<AutoComplete
v-model="form.partner_name"
:suggestions="filteredPartners"
@@ -265,10 +278,22 @@
<div class="form-field flex-1">
<label>CUI</label>
<InputText v-model="form.cui" placeholder="RO12345678" :disabled="isReadOnly" />
<small v-if="supplierWarning.show" class="p-text-warning supplier-warning">
<i class="pi pi-exclamation-triangle"></i>
Negasit
</small>
<div v-if="supplierWarning.show" class="supplier-warning-box">
<small class="p-text-warning">
<i class="pi pi-exclamation-triangle"></i>
Negasit - se va crea automat la salvare
</small>
<Button
v-if="!isReadOnly"
label="Creaza acum"
icon="pi pi-plus"
size="small"
severity="warning"
text
@click="createLocalSupplierFromWarning"
class="supplier-create-btn"
/>
</div>
</div>
</div>
<!-- Adresa colapsata -->
@@ -406,14 +431,13 @@
<div class="form-group form-group-last">
<div class="form-row">
<div class="form-field flex-1">
<label>Tip Cheltuiala *</label>
<label>Tip Cheltuiala</label>
<Dropdown
v-model="form.expense_type_code"
:options="expenseTypes"
optionLabel="name"
optionValue="code"
placeholder="Selecteaza tip cheltuiala"
required
:disabled="isReadOnly"
class="dropdown-borderless"
/>
@@ -678,7 +702,7 @@
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useReceiptsStore } from '@data-entry/stores/receiptsStore'
@@ -771,6 +795,7 @@ const missingClientWarning = ref(false)
// AutoComplete support
const filteredPartners = ref([])
const supplierSource = ref(null) // 'local', 'synced', or null
const syncingSuppliers = ref(false)
const partners = computed(() => store.partners)
const expenseTypes = computed(() => store.expenseTypes)
@@ -812,7 +837,6 @@ const missingRequiredFields = computed(() => {
const missing = []
if (!validationState.value.hasAmount) missing.push('Suma')
if (!validationState.value.hasDate) missing.push('Data')
if (!validationState.value.hasExpenseType) missing.push('Tip cheltuiala')
if (!validationState.value.hasAttachment) missing.push('Atasament')
return missing
})
@@ -848,6 +872,11 @@ const searchPartners = (event) => {
onMounted(async () => {
await store.fetchAllNomenclatures()
// Sync suppliers from Oracle if list is empty (first use or no synced data)
if (store.partners.length === 0) {
await syncSuppliersIfNeeded()
}
if (isEditMode.value || isViewMode.value) {
await loadReceipt()
} else {
@@ -856,6 +885,76 @@ onMounted(async () => {
}
})
// Sync suppliers from Oracle if not already synced
const syncSuppliersIfNeeded = async () => {
try {
toast.add({
severity: 'info',
summary: 'Sincronizare furnizori',
detail: 'Se sincronizeaza furnizorii din Oracle...',
life: 3000,
})
const result = await store.syncSuppliers()
toast.add({
severity: 'success',
summary: 'Sincronizare completa',
detail: `${result.synced || store.partners.length} furnizori sincronizati`,
life: 3000,
})
} catch (error) {
console.warn('[ReceiptCreateView] Supplier sync failed:', error)
toast.add({
severity: 'warn',
summary: 'Sincronizare esuata',
detail: 'Nu s-au putut sincroniza furnizorii. Puteti continua cu furnizori locali.',
life: 5000,
})
}
}
// Watch for company changes - sync suppliers in background
watch(
() => companyStore.selectedCompany,
async (newCompany, oldCompany) => {
// Only trigger if company actually changed (not on initial load)
if (newCompany && oldCompany && newCompany.id_firma !== oldCompany.id_firma) {
console.log('[ReceiptCreateView] Company changed, syncing suppliers in background...')
// Background sync - don't await, don't block UI
store.syncSuppliers().then(result => {
console.log('[ReceiptCreateView] Background sync complete:', result)
}).catch(error => {
console.warn('[ReceiptCreateView] Background sync failed:', error)
})
}
}
)
// Manual re-sync suppliers from Oracle (button click)
const resyncSuppliers = async () => {
syncingSuppliers.value = true
try {
const result = await store.syncSuppliers()
toast.add({
severity: 'success',
summary: 'Sincronizare completa',
detail: `${result.synced || 0} furnizori noi din Oracle`,
life: 3000,
})
} catch (error) {
console.warn('[ReceiptCreateView] Manual supplier sync failed:', error)
toast.add({
severity: 'error',
summary: 'Sincronizare esuata',
detail: error.message || 'Nu s-au putut sincroniza furnizorii',
life: 5000,
})
} finally {
syncingSuppliers.value = false
}
}
const loadReceipt = async () => {
try {
receipt.value = await store.fetchReceiptById(receiptId.value)
@@ -954,6 +1053,7 @@ const onOCRFileSelected = (file) => {
}
const onOCRResult = (data) => {
console.log('[OCR Result] Received data, suggested_payment_mode:', data.suggested_payment_mode, 'payment_methods:', data.payment_methods)
ocrData.value = data
toast.add({
severity: 'success',
@@ -1142,9 +1242,11 @@ const applyOCRData = async (data) => {
}
// Auto-suggest payment_mode if OCR detected CARD
console.log('[OCR Apply] suggested_payment_mode:', data.suggested_payment_mode, 'payment_methods:', data.payment_methods)
if (data.suggested_payment_mode) {
form.value.payment_mode = data.suggested_payment_mode
paymentSetFromOCR.value = true // Show OCR indicator
console.log('[OCR Apply] Set payment_mode to:', data.suggested_payment_mode)
}
// AUTO-DETECT DIRECTION (PLATĂ/ÎNCASARE) based on CUI matching
@@ -1367,6 +1469,36 @@ const cancelCreateSupplier = () => {
pendingSupplierData.value = null
}
// Create local supplier immediately from warning (inline button)
const createLocalSupplierFromWarning = async () => {
if (!form.value.cui) return
try {
await store.createLocalSupplier({
name: form.value.partner_name || supplierWarning.value.name || `Furnizor ${form.value.cui}`,
fiscal_code: form.value.cui,
address: form.value.vendor_address || null
})
toast.add({
severity: 'success',
summary: 'Furnizor creat',
detail: `${form.value.partner_name || form.value.cui} a fost adaugat`,
life: 3000,
})
supplierWarning.value = { show: false, cui: '', name: '' }
supplierSource.value = 'local'
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut crea furnizorul',
life: 5000,
})
}
}
// Helper function to calculate similarity between two CUI strings
// Returns a value between 0 and 1 (1 = identical)
const calculateCuiSimilarity = (cui1, cui2) => {
@@ -1727,16 +1859,6 @@ const validateForm = () => {
return false
}
if (!form.value.expense_type_code) {
toast.add({
severity: 'warn',
summary: 'Validare',
detail: 'Tipul cheltuielii este obligatoriu',
life: 3000,
})
return false
}
// Payment mode is validated at submit time, not at draft save
// (can save draft without payment mode, but submit requires it)
@@ -1749,6 +1871,31 @@ const saveReceipt = async () => {
saving.value = true
try {
// Auto-create local supplier if CUI is present but not found in database
if (form.value.cui && supplierWarning.value.show) {
try {
await store.createLocalSupplier({
name: form.value.partner_name || `Furnizor ${form.value.cui}`,
fiscal_code: form.value.cui,
address: form.value.vendor_address || null
})
toast.add({
severity: 'info',
summary: 'Furnizor local creat',
detail: `${form.value.partner_name || form.value.cui} adaugat automat`,
life: 3000,
})
// Clear warning since supplier is now created
supplierWarning.value = { show: false, cui: '', name: '' }
supplierSource.value = 'local'
} catch (error) {
console.warn('[saveReceipt] Failed to auto-create local supplier:', error)
// Continue with save anyway - supplier creation is optional
}
}
// Clean up payment_methods and tva_breakdown - convert null amounts to 0
const cleanedPaymentMethods = form.value.payment_methods?.map(pm => ({
...pm,
@@ -1898,6 +2045,46 @@ const submitForReview = async () => {
font-size: 1.1rem;
}
/* Supplier warning box with inline create button */
.supplier-warning-box {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.supplier-warning-box small {
display: flex;
align-items: center;
gap: 0.25rem;
}
.supplier-create-btn {
padding: 0.25rem 0.5rem !important;
font-size: 0.75rem !important;
}
/* Label with action button (sync) */
.label-with-action {
display: flex;
align-items: center;
gap: 0.25rem;
}
.label-with-action label {
margin-bottom: 0;
}
.sync-btn {
width: 1.5rem !important;
height: 1.5rem !important;
padding: 0 !important;
}
.sync-btn .pi {
font-size: 0.75rem;
}
/* 2-column layout */
.receipt-form-layout {
display: grid;

View File

@@ -78,6 +78,12 @@ const routes = [
name: 'ReceiptEdit',
component: () => import('@data-entry/views/receipts/ReceiptCreateView.vue'),
meta: { requiresAuth: true, title: 'Editare Bon - ROA2WEB' }
},
{
path: 'ocr-metrics',
name: 'OCRMetrics',
component: () => import('@data-entry/views/OCRMetricsView.vue'),
meta: { requiresAuth: true, title: 'Metrici OCR - ROA2WEB' }
}
]
},