feat: Implement unified Vue SPA with granular service control

Consolidate Reports and Data Entry apps into a single Vue.js SPA with:

Architecture:
- Module-based structure with lazy-loaded routes (@reports, @data-entry)
- Error boundaries per module to prevent cascade failures
- Dual API proxy in Vite for microservices (reports:8001, data-entry:8003)
- Pinia store factories for shared auth, company, and period stores
- Vite path aliases for clear module boundaries (@shared, @reports, @data-entry)

Service Management:
- Granular service control scripts (backend-reports.sh, backend-data-entry.sh, bot.sh, frontend.sh)
- 87% faster frontend restart: 7s vs 53s full restart
- 38% faster full startup: 33s vs 53s via parallel backend initialization
- Enhanced start-dev.sh with proper service timeouts (OCR: 30s, Vite: 15s, Bot: 10s)
- status.sh for comprehensive health checks

Features:
- Auto-select first company on login with period auto-load
- Hamburger menu with feature toggle support
- JWT token auto-injection via axios interceptors
- Unified header with company/period selectors
- IIS web.config for production deployment with multi-API routing

UX Improvements:
- Vue watchers for reactive company/period loading
- Lazy store initialization with graceful error handling
- Period persistence per user+company in localStorage
- Feature flags for optional modules

Deployment:
- Single IIS site serves unified frontend with API proxy rules
- Maintains separate backend processes for microservices
- Windows line ending fixes (.env CRLF → LF conversion)

Stats: 112 files changed, 38,342 insertions(+), 2,342 deletions(-)

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

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-24 19:06:23 +02:00
parent fed2e68fa2
commit d507a81b0a
112 changed files with 38382 additions and 2382 deletions

View File

@@ -0,0 +1,9 @@
<template>
<ErrorBoundary module-name="Introduceri Date">
<router-view />
</ErrorBoundary>
</template>
<script setup>
import ErrorBoundary from '@shared/components/ErrorBoundary.vue'
</script>

View File

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

View File

@@ -0,0 +1,699 @@
<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">
<OCRConfidenceIndicator
:confidence="data.overall_confidence"
:show-percentage="true"
size="normal"
/>
</span>
<Button
icon="pi pi-minus"
text
rounded
size="small"
@click="$emit('collapse')"
v-tooltip="'Minimizeaza'"
class="collapse-btn"
/>
</div>
</div>
<div class="preview-content">
<!-- SECTION: FURNIZOR -->
<div class="ocr-section" v-if="data.partner_name || data.cui || data.address">
<div class="ocr-section-title">FURNIZOR</div>
<div class="ocr-section-content">
<div class="vendor-name" v-if="data.partner_name">
{{ data.partner_name }}
<OCRConfidenceIndicator :confidence="data.confidence_vendor" size="small" />
</div>
<div class="vendor-cui" v-if="data.cui">CUI: {{ data.cui }}</div>
<div class="vendor-address" v-if="data.address">{{ data.address }}</div>
</div>
</div>
<!-- SECTION: CLIENT (always visible) -->
<div class="ocr-section">
<div class="ocr-section-title">CLIENT</div>
<div class="ocr-section-content">
<template v-if="data.client_name || data.client_cui || data.client_address">
<div class="client-name" v-if="data.client_name">
{{ data.client_name }}
<OCRConfidenceIndicator v-if="data.confidence_client" :confidence="data.confidence_client" size="small" />
</div>
<div class="client-cui" v-if="data.client_cui">CUI: {{ data.client_cui }}</div>
<div class="client-address" v-if="data.client_address">{{ data.client_address }}</div>
</template>
<div v-else class="no-data">-</div>
</div>
</div>
<!-- SECTION: DOCUMENT -->
<div class="ocr-section" v-if="data.receipt_type || data.receipt_number || data.receipt_date">
<div class="ocr-section-title">DOCUMENT</div>
<div class="ocr-section-content">
<div class="document-row">
<Tag
v-if="data.receipt_type"
:value="data.receipt_type === 'bon_fiscal' ? 'Bon Fiscal' : 'Chitanta'"
:severity="data.receipt_type === 'bon_fiscal' ? 'info' : 'success'"
/>
<span v-if="data.receipt_number" class="doc-number">
Nr: {{ data.receipt_series ? data.receipt_series + ' ' : '' }}{{ data.receipt_number }}
</span>
<span v-if="data.receipt_date" class="doc-date">
<i class="pi pi-calendar"></i>
{{ formatDate(data.receipt_date) }}
<OCRConfidenceIndicator :confidence="data.confidence_date" size="small" />
</span>
</div>
</div>
</div>
<!-- SECTION: TOTAL + PLATA + TVA (unified flat layout) -->
<div class="ocr-section" v-if="data.amount || data.tva_entries?.length > 0 || paymentSum > 0">
<div class="ocr-values-table">
<!-- TOTAL row - when extracted -->
<div class="value-row" v-if="data.amount">
<span class="value-label">TOTAL</span>
<span class="value-amount">
{{ formatAmount(data.amount) }} LEI
<OCRConfidenceIndicator :confidence="data.confidence_amount" size="small" class="confidence-inline" />
</span>
</div>
<!-- TOTAL row - warning when not found but can be calculated from payments -->
<div class="value-row warning-row" v-else-if="paymentSum > 0">
<span class="value-label">
<i class="pi pi-exclamation-triangle warning-icon"></i>
TOTAL (calculat)
</span>
<span class="value-amount calculated">
{{ formatAmount(paymentSum) }} LEI
<span class="hint">(din plati)</span>
</span>
</div>
<!-- Validation warning: total payment sum -->
<div class="validation-warning" v-if="totalMismatchPayment">
<i class="pi pi-exclamation-triangle"></i>
Total ({{ formatAmount(data.amount) }}) Suma plati ({{ formatAmount(paymentSum) }})
</div>
<!-- Validation info: TVA-implied total mismatch -->
<div class="validation-info" v-if="totalMismatchTva">
<i class="pi pi-info-circle"></i>
Total din TVA: {{ formatAmount(tvaImpliedTotal) }} LEI
</div>
<!-- Payment methods as plain text rows -->
<div class="value-row" v-for="(pm, idx) in data.payment_methods" :key="'pm-'+idx">
<span class="value-label">{{ pm.method }}</span>
<span class="value-amount">{{ formatAmount(pm.amount) }} LEI</span>
</div>
<!-- TVA entries -->
<div class="value-row" v-for="(entry, idx) in data.tva_entries" :key="'tva-'+idx">
<span class="value-label">TVA {{ entry.code }} ({{ entry.percent }}%)</span>
<span class="value-amount">{{ formatAmount(entry.amount) }} LEI</span>
</div>
<!-- TVA Total -->
<div class="value-row total-row" v-if="computedTvaTotal > 0">
<span class="value-label">Total TVA</span>
<span class="value-amount">{{ formatAmount(computedTvaTotal) }} LEI</span>
</div>
<!-- Items count -->
<div v-if="data.items_count" class="items-count-inline">
{{ data.items_count }} articole
</div>
</div>
</div>
<!-- Raw Text Toggle -->
<div class="raw-text-section" v-if="data.raw_text">
<div class="raw-text-header">
<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"
/>
<span v-if="data.ocr_engine" class="ocr-engine-badge" :class="getEngineClass(data.ocr_engine)">
<i :class="getEngineIcon(data.ocr_engine)"></i>
{{ getEngineLabel(data.ocr_engine) }}
</span>
<span v-if="data._ocr_message" class="ocr-message-badge" :class="getMessageClass(data._ocr_message)">
{{ data._ocr_message }}
</span>
<span v-if="data.processing_time_ms" class="ocr-time-badge">
<i class="pi pi-clock"></i>
{{ formatProcessingTime(data.processing_time_ms) }}
</span>
</div>
<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, computed } from 'vue'
import OCRConfidenceIndicator from './OCRConfidenceIndicator.vue'
import Tag from 'primevue/tag'
import Button from 'primevue/button'
const props = defineProps({
data: {
type: Object,
required: true
}
})
defineEmits(['apply', 'dismiss', 'collapse'])
const showRawText = ref(false)
// Computed TVA total from entries
const computedTvaTotal = computed(() => {
if (props.data.tva_total) return parseFloat(props.data.tva_total)
if (!props.data.tva_entries?.length) return 0
return props.data.tva_entries.reduce((sum, e) => sum + parseFloat(e.amount || 0), 0)
})
// Cross-validation computed properties
const paymentSum = computed(() => {
if (!props.data.payment_methods?.length) return 0
return props.data.payment_methods.reduce((sum, pm) => sum + parseFloat(pm.amount || 0), 0)
})
const tvaImpliedTotal = computed(() => {
// Calculate total from TVA: total = tva * (100 + rate) / rate
if (!props.data.tva_entries?.length) return 0
const mainEntry = props.data.tva_entries[0]
const rate = mainEntry.percent || 19
const tvaAmount = parseFloat(mainEntry.amount || 0)
if (tvaAmount === 0 || rate === 0) return 0
return tvaAmount * (100 + rate) / rate
})
const totalMismatchPayment = computed(() => {
if (!props.data.amount || paymentSum.value === 0) return false
const total = parseFloat(props.data.amount)
return Math.abs(total - paymentSum.value) > 0.02 // Tolerance 2 bani
})
const totalMismatchTva = computed(() => {
if (!props.data.amount || tvaImpliedTotal.value === 0) return false
const total = parseFloat(props.data.amount)
return Math.abs(total - tvaImpliedTotal.value) > 0.50 // Tolerance 50 bani (TVA calc has rounding)
})
const getSuggestedPaymentLabel = (mode) => {
const labels = {
'casa': 'Casa (numerar firma)',
'banca': 'Banca (virament/POS)',
'avans_decontare': 'Avans Decontare'
}
return labels[mode] || mode
}
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'
})
}
const getEngineClass = (engine) => {
if (!engine) return ''
if (engine === 'paddle-light') return 'fast'
if (engine === 'paddle-adaptive') return 'adaptive'
if (engine === 'adaptive-full') return 'full'
if (engine.includes('paddle')) return 'paddleocr'
if (engine.includes('tesseract')) return 'tesseract'
return ''
}
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
return 'pi pi-cog'
}
const getEngineLabel = (engine) => {
if (!engine) return ''
if (engine === 'paddle-light') return 'Fast Mode (PaddleOCR)'
if (engine === 'paddle-adaptive') return 'Adaptive (Paddle dual)'
if (engine === 'adaptive-full') return 'Full Pipeline'
if (engine.includes('paddle')) return 'PaddleOCR'
if (engine.includes('tesseract')) return 'Tesseract'
return engine
}
const getMessageClass = (message) => {
if (!message) return ''
if (message.includes('fast mode')) return 'fast-mode'
if (message.includes('full pipeline')) return 'full-pipeline'
return ''
}
const formatProcessingTime = (ms) => {
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
</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;
}
.header-right {
display: flex;
align-items: center;
gap: 0.75rem;
}
.overall-confidence {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: #166534;
}
.collapse-btn {
color: #166534 !important;
}
.preview-content {
padding: 0.75rem 1rem;
}
/* Section-based layout (bon fiscal style) - clearer separation */
.ocr-section {
padding: 0.75rem;
margin-bottom: 0.5rem;
background: rgba(255, 255, 255, 0.5);
border-radius: 8px;
border: 1px solid #bbf7d0;
}
.ocr-section:last-of-type {
margin-bottom: 0;
}
.ocr-section-title {
font-size: 0.7rem;
font-weight: 700;
color: #166534;
text-transform: uppercase;
letter-spacing: 0.08em;
margin-bottom: 0.5rem;
padding-bottom: 0.35rem;
border-bottom: 1px dashed #86efac;
}
.ocr-section-content {
color: #1e293b;
}
/* FURNIZOR section */
.vendor-name {
font-weight: 600;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.vendor-cui {
font-size: 0.85rem;
color: #475569;
margin-top: 0.15rem;
}
.vendor-address {
font-size: 0.8rem;
color: #64748b;
margin-top: 0.15rem;
}
/* CLIENT section */
.client-name {
font-weight: 600;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.client-cui {
font-size: 0.85rem;
color: #475569;
margin-top: 0.15rem;
}
.client-address {
font-size: 0.8rem;
color: #64748b;
margin-top: 0.15rem;
}
/* Placeholder when no data extracted */
.no-data {
color: #9ca3af;
font-style: italic;
}
/* DOCUMENT section */
.document-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.doc-number {
font-weight: 500;
color: #334155;
}
.doc-date {
display: flex;
align-items: center;
gap: 0.35rem;
color: #475569;
font-size: 0.9rem;
}
.doc-date .pi-calendar {
font-size: 0.85rem;
color: #64748b;
}
/* Unified values table (TOTAL + Payment + TVA) */
.ocr-values-table {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.value-row {
display: flex;
align-items: center;
font-size: 0.9rem;
padding: 0.25rem 0;
}
.value-label {
font-weight: 500;
color: #374151;
}
.value-amount {
font-weight: 600;
margin-left: auto;
color: #1f2937;
display: flex;
align-items: center;
gap: 0.5rem;
}
.value-row.total-row {
margin-top: 0.35rem;
padding-top: 0.5rem;
border-top: 1px dashed #86efac;
}
/* Items count - subtle, at bottom of values section */
.items-count-inline {
font-size: 0.8rem;
color: #64748b;
text-align: right;
padding-top: 0.35rem;
}
/* Confidence indicator inline */
.confidence-inline {
margin-left: 0.25rem;
}
/* Warning row for calculated total */
.value-row.warning-row {
background: #fef3c7;
border-radius: 6px;
padding: 0.5rem 0.75rem;
margin: 0.25rem 0;
}
.value-row.warning-row .value-label {
display: flex;
align-items: center;
gap: 0.5rem;
color: #92400e;
}
.warning-icon {
color: #f59e0b;
font-size: 0.9rem;
}
.value-amount.calculated {
color: #92400e;
}
.value-amount .hint {
font-size: 0.75rem;
font-weight: 400;
color: #a3a3a3;
}
/* Validation warnings */
.validation-warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
margin: 0.25rem 0;
background: #fee2e2;
border-radius: 6px;
font-size: 0.8rem;
color: #991b1b;
}
.validation-warning i {
color: #dc2626;
font-size: 0.9rem;
}
/* Validation info */
.validation-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
margin: 0.25rem 0;
background: #dbeafe;
border-radius: 6px;
font-size: 0.8rem;
color: #1e40af;
}
.validation-info i {
color: #2563eb;
font-size: 0.9rem;
}
.raw-text-section {
margin-top: 1rem;
padding-top: 1rem;
border-top: 1px dashed #86efac;
}
.raw-text-header {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
}
.ocr-engine-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
}
.ocr-engine-badge.paddleocr {
background: #dbeafe;
color: #1e40af;
}
.ocr-engine-badge.tesseract {
background: #fef3c7;
color: #92400e;
}
.ocr-engine-badge.fast {
background: #dcfce7;
color: #166534;
}
.ocr-engine-badge.adaptive {
background: #dbeafe;
color: #1e40af;
}
.ocr-engine-badge.full {
background: #fef3c7;
color: #92400e;
}
.ocr-message-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
background: #f1f5f9;
color: #475569;
}
.ocr-message-badge.fast-mode {
background: #dcfce7;
color: #166534;
}
.ocr-message-badge.full-pipeline {
background: #fef3c7;
color: #92400e;
}
.ocr-time-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
background: #e0e7ff;
color: #3730a3;
}
.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>

View File

@@ -0,0 +1,281 @@
<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: 1.75rem; 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"
icon="pi pi-refresh"
severity="secondary"
size="small"
@click.stop="triggerFileInput"
/>
<Button
label="Proceseaza 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: 2rem; 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">
JPG, PNG, PDF (max 10MB) &bull; OCR extrage automat datele
</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 api from '@data-entry/services/api'
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 api.post('/ocr/extract', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000, // 60 second timeout for OCR
})
if (response.data.success) {
// Include the OCR message in the data for debugging
const resultData = {
...response.data.data,
_ocr_message: response.data.message
}
emit('ocr-result', resultData)
} 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: 10px;
padding: 1rem 1.25rem;
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 - compact */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.main-text {
font-size: 0.9rem;
color: #475569;
margin: 0.25rem 0;
}
.sub-text {
font-size: 0.8rem;
color: #94a3b8;
margin: 0;
}
/* File selected state - compact */
.file-selected-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.file-name {
font-weight: 600;
font-size: 0.9rem;
color: #1e293b;
margin: 0.25rem 0 0 0;
word-break: break-all;
}
.file-size {
font-size: 0.8rem;
color: #64748b;
margin: 0;
}
.file-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
}
/* 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>

View File

@@ -0,0 +1,40 @@
import axios from 'axios'
const api = axios.create({
baseURL: '/api/data-entry',
headers: { 'Content-Type': 'application/json' }
})
// Request interceptor for auth token and company header
api.interceptors.request.use((config) => {
const token = localStorage.getItem('access_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
// Add selected company header if available
const user = JSON.parse(localStorage.getItem('user') || '{}')
const selectedCompanyId = localStorage.getItem('selectedCompanyId') || user.companies?.[0]?.id
if (selectedCompanyId) {
config.headers['X-Selected-Company'] = selectedCompanyId
}
return config
})
// Response interceptor for error handling
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expired or invalid - redirect to login
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('user')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api

View File

@@ -0,0 +1,445 @@
import { defineStore } from 'pinia'
import apiClient from '@data-entry/services/api'
// Create receipts-specific API wrapper
const api = {
get: (url, config) => apiClient.get(`/receipts${url}`, config),
post: (url, data, config) => apiClient.post(`/receipts${url}`, data, config),
put: (url, data, config) => apiClient.put(`/receipts${url}`, data, config),
delete: (url, config) => apiClient.delete(`/receipts${url}`, config),
}
export const useReceiptsStore = defineStore('receipts', {
state: () => ({
receipts: [],
currentReceipt: null,
pendingReceipts: [],
stats: null,
loading: false,
error: null,
pagination: {
page: 1,
pageSize: 20,
total: 0,
pages: 1,
},
filters: {
status: null,
search: '',
direction: null,
dateFrom: null,
dateTo: null,
},
// Nomenclatures
partners: [],
accounts: [],
cashRegisters: [],
expenseTypes: [],
}),
getters: {
hasReceipts: (state) => state.receipts.length > 0,
hasPendingReceipts: (state) => state.pendingReceipts.length > 0,
pendingCount: (state) => state.pendingReceipts.length,
},
actions: {
// ============ Receipts CRUD ============
async fetchReceipts() {
this.loading = true
this.error = null
try {
const params = {
page: this.pagination.page,
page_size: this.pagination.pageSize,
}
if (this.filters.status) {
params.status = this.filters.status
}
if (this.filters.search) {
params.search = this.filters.search
}
if (this.filters.direction) {
params.direction = this.filters.direction
}
if (this.filters.dateFrom) {
params.date_from = this.filters.dateFrom
}
if (this.filters.dateTo) {
params.date_to = this.filters.dateTo
}
const response = await api.get('/', { params })
this.receipts = response.data.items
this.pagination.total = response.data.total
this.pagination.pages = response.data.pages
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to fetch receipts'
throw error
} finally {
this.loading = false
}
},
async fetchReceiptById(id) {
this.loading = true
this.error = null
try {
const response = await api.get(`/${id}`)
this.currentReceipt = response.data
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to fetch receipt'
throw error
} finally {
this.loading = false
}
},
async createReceipt(data) {
this.loading = true
this.error = null
try {
const response = await api.post('/', data)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to create receipt'
throw error
} finally {
this.loading = false
}
},
async updateReceipt(id, data) {
this.loading = true
this.error = null
try {
const response = await api.put(`/${id}`, data)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to update receipt'
throw error
} finally {
this.loading = false
}
},
async deleteReceipt(id) {
this.loading = true
this.error = null
try {
await api.delete(`/${id}`)
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to delete receipt'
throw error
} finally {
this.loading = false
}
},
// ============ Workflow Actions ============
async submitReceipt(id) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/submit`)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to submit receipt'
throw error
} finally {
this.loading = false
}
},
async approveReceipt(id) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/approve`)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to approve receipt'
throw error
} finally {
this.loading = false
}
},
async rejectReceipt(id, reason) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/reject`, { reason })
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to reject receipt'
throw error
} finally {
this.loading = false
}
},
async resubmitReceipt(id) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/resubmit`)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to resubmit receipt'
throw error
} finally {
this.loading = false
}
},
async unapproveReceipt(id) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/unapprove`)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to unapprove receipt'
throw error
} finally {
this.loading = false
}
},
// ============ Pending Receipts ============
async fetchPendingReceipts() {
this.loading = true
this.error = null
try {
const response = await api.get('/pending')
this.pendingReceipts = response.data
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to fetch pending receipts'
throw error
} finally {
this.loading = false
}
},
// ============ Attachments ============
async uploadAttachment(receiptId, file) {
const formData = new FormData()
formData.append('file', file)
try {
const response = await api.post(`/${receiptId}/attachments`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to upload attachment')
}
},
async deleteAttachment(attachmentId) {
try {
await api.delete(`/attachments/${attachmentId}`)
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to delete attachment')
}
},
getAttachmentUrl(attachmentId) {
return `/api/receipts/attachments/${attachmentId}/download`
},
async fetchAttachmentBlob(attachmentId) {
try {
const response = await api.get(`/receipts/attachments/${attachmentId}/download`, {
responseType: 'blob',
})
return URL.createObjectURL(response.data)
} catch (error) {
console.error('Failed to fetch attachment:', error)
return null
}
},
async downloadAttachment(attachmentId, filename) {
try {
const response = await api.get(`/receipts/attachments/${attachmentId}/download`, {
responseType: 'blob',
})
// Create download link
const url = URL.createObjectURL(response.data)
const link = document.createElement('a')
link.href = url
link.download = filename || 'attachment'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
return true
} catch (error) {
console.error('Failed to download attachment:', error)
throw new Error(error.response?.data?.detail || 'Failed to download attachment')
}
},
// ============ Accounting Entries ============
async fetchEntries(receiptId) {
try {
const response = await api.get(`/${receiptId}/entries`)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to fetch entries')
}
},
async updateEntries(receiptId, entries) {
try {
const response = await api.put(`/${receiptId}/entries`, { entries })
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to update entries')
}
},
async regenerateEntries(receiptId) {
try {
const response = await api.post(`/${receiptId}/entries/regenerate`)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to regenerate entries')
}
},
// ============ Nomenclatures ============
async fetchPartners(search = '') {
try {
const response = await api.get('/nomenclature/partners', {
params: { search },
})
this.partners = response.data
return response.data
} catch (error) {
console.error('Failed to fetch partners:', error)
return []
}
},
async fetchAccounts(prefix = '') {
try {
const response = await api.get('/nomenclature/accounts', {
params: { prefix },
})
this.accounts = response.data
return response.data
} catch (error) {
console.error('Failed to fetch accounts:', error)
return []
}
},
async fetchCashRegisters() {
try {
const response = await api.get('/nomenclature/cash-registers')
this.cashRegisters = response.data
return response.data
} catch (error) {
console.error('Failed to fetch cash registers:', error)
return []
}
},
async fetchExpenseTypes() {
try {
const response = await api.get('/nomenclature/expense-types')
this.expenseTypes = response.data
return response.data
} catch (error) {
console.error('Failed to fetch expense types:', error)
return []
}
},
async fetchAllNomenclatures() {
await Promise.all([
this.fetchPartners(),
this.fetchCashRegisters(),
this.fetchExpenseTypes(),
])
},
async searchSupplier(fiscalCode) {
try {
const response = await api.get('/nomenclature/suppliers/search', {
params: { fiscal_code: fiscalCode },
})
return response.data
} catch (error) {
console.error('Supplier search failed:', error)
return { found: false, source: 'error' }
}
},
async createLocalSupplier(data) {
try {
const response = await api.post('/nomenclature/suppliers/local', data)
// Add to local partners list
this.partners.push({
id: response.data.id,
name: response.data.name,
code: response.data.fiscal_code,
})
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to create supplier')
}
},
// ============ Stats ============
async fetchStats() {
try {
const response = await api.get('/stats')
this.stats = response.data
return response.data
} catch (error) {
console.error('Failed to fetch stats:', error)
return null
}
},
// ============ Filters & Pagination ============
setFilters(filters) {
this.filters = { ...this.filters, ...filters }
this.pagination.page = 1
},
clearFilters() {
this.filters = {
status: null,
search: '',
direction: null,
dateFrom: null,
dateTo: null,
}
this.pagination.page = 1
},
setPage(page) {
this.pagination.page = page
},
clearCurrentReceipt() {
this.currentReceipt = null
},
},
})

View File

@@ -0,0 +1,20 @@
/**
* Data Entry Module - Shared Store Instances
*
* This file instantiates the shared stores (auth, companies, accountingPeriod)
* with the Data Entry module's API service.
*/
import { createAuthStore } from '@shared/stores/auth'
import { createCompaniesStore } from '@shared/stores/companies'
import { createAccountingPeriodStore } from '@shared/stores/accountingPeriod'
import api from '@data-entry/services/api'
// Create auth store
export const useAuthStore = createAuthStore(api)
// Create companies store (needs auth store reference)
export const useCompanyStore = createCompaniesStore(api, useAuthStore)
// Create accounting period store
export const useAccountingPeriodStore = createAccountingPeriodStore(api)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff