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:
9
src/modules/data-entry/DataEntryLayout.vue
Normal file
9
src/modules/data-entry/DataEntryLayout.vue
Normal 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>
|
||||
125
src/modules/data-entry/components/ocr/OCRConfidenceIndicator.vue
Normal file
125
src/modules/data-entry/components/ocr/OCRConfidenceIndicator.vue
Normal 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>
|
||||
699
src/modules/data-entry/components/ocr/OCRPreview.vue
Normal file
699
src/modules/data-entry/components/ocr/OCRPreview.vue
Normal 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>
|
||||
281
src/modules/data-entry/components/ocr/OCRUploadZone.vue
Normal file
281
src/modules/data-entry/components/ocr/OCRUploadZone.vue
Normal 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) • 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>
|
||||
40
src/modules/data-entry/services/api.js
Normal file
40
src/modules/data-entry/services/api.js
Normal 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
|
||||
445
src/modules/data-entry/stores/receiptsStore.js
Normal file
445
src/modules/data-entry/stores/receiptsStore.js
Normal 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
|
||||
},
|
||||
},
|
||||
})
|
||||
20
src/modules/data-entry/stores/sharedStores.js
Normal file
20
src/modules/data-entry/stores/sharedStores.js
Normal 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)
|
||||
2939
src/modules/data-entry/views/receipts/ReceiptCreateView.vue
Normal file
2939
src/modules/data-entry/views/receipts/ReceiptCreateView.vue
Normal file
File diff suppressed because it is too large
Load Diff
1199
src/modules/data-entry/views/receipts/ReceiptsListView.vue
Normal file
1199
src/modules/data-entry/views/receipts/ReceiptsListView.vue
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user