Files
roa2web-service-auto/src/modules/data-entry/components/ocr/OCRUploadZone.vue
Marius Mutu d507a81b0a 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>
2025-12-24 19:06:23 +02:00

282 lines
6.1 KiB
Vue

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