feat(data-entry): Bulk Receipt Upload cu Mobile UX Android Nativ
## Funcționalități Principale ### Bulk Upload & Processing - Drag & drop pentru upload bonuri multiple oriunde pe pagină - Batch processing cu job queue și worker pool - Real-time updates via SSE (Server-Sent Events) cu fallback polling - Duplicate detection via SHA-256 file hash - Auto-retry pentru job-uri failed - Cancel individual jobs sau batch complet ### Mobile UX - Android Native Style - Top bar fixă cu hamburger, titlu centrat, acțiuni (search/filter) - Bottom navigation cu 4 tab-uri (Bonuri, Upload, Rapoarte, Setări) - FAB (Floating Action Button) cu hide/show on scroll - Filter chips orizontal scrollabile - Selecție multiplă prin long-press (500ms) - Select All + Bulk Delete cu confirmare - Layout Android pentru Create/Edit/View bon (Gmail compose style) ### Bug Fixes - Refresh individual via SSE în loc de refresh total pagină - Bonurile cu eroare OCR rămân vizibile pentru editare manuală - Afișare nume fișier original pentru toate bonurile - Upload stabil pe mobil (fix race condition File API) - Păstrare ordine bonuri la refresh (nu se reordonează) ### Backend - SSE endpoint pentru status updates real-time - Bulk delete endpoint cu partial success - Auto-cleanup bonuri failed după 7 zile - Batch model cu tracking complet ### Testing - E2E tests cu Playwright - Unit tests pentru bulk upload, auto-create, cleanup ## Commits Squashed: 43 user stories (US-001 → US-043) ## Branch: ralph/bulk-receipt-upload ## Timp dezvoltare: ~3 zile (Ralph autonomous) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
397
src/modules/data-entry/components/bulk/BatchGroupHeader.vue
Normal file
397
src/modules/data-entry/components/bulk/BatchGroupHeader.vue
Normal file
@@ -0,0 +1,397 @@
|
||||
<template>
|
||||
<div
|
||||
class="batch-group-header"
|
||||
:class="{ 'is-expanded': isExpanded, 'has-active-processing': hasActiveProcessing }"
|
||||
@click="$emit('toggle')"
|
||||
>
|
||||
<div class="batch-header-content">
|
||||
<i
|
||||
class="pi chevron-icon"
|
||||
:class="isExpanded ? 'pi-chevron-down' : 'pi-chevron-right'"
|
||||
></i>
|
||||
|
||||
<div class="batch-info">
|
||||
<span class="batch-label">
|
||||
<template v-if="batchId">
|
||||
Batch {{ shortBatchId }}
|
||||
</template>
|
||||
<template v-else>
|
||||
Alte bonuri
|
||||
</template>
|
||||
</span>
|
||||
|
||||
<span class="batch-separator">•</span>
|
||||
|
||||
<span class="batch-date" v-if="formattedDate">
|
||||
{{ formattedDate }}
|
||||
</span>
|
||||
<span class="batch-date" v-else>
|
||||
Creat manual
|
||||
</span>
|
||||
|
||||
<span class="batch-separator">•</span>
|
||||
|
||||
<span class="batch-count">
|
||||
{{ itemsCount }} {{ itemsCount === 1 ? 'fișier' : 'fișiere' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Processing status indicator -->
|
||||
<div v-if="hasActiveProcessing" class="batch-status-indicator processing">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
<span>{{ activeProcessingCount }} în procesare</span>
|
||||
<!-- US-021: Cancel All button - visible when there are pending/processing jobs -->
|
||||
<button
|
||||
v-if="batchId"
|
||||
class="cancel-all-btn"
|
||||
@click.stop="handleCancelAll"
|
||||
title="Anulează toate fișierele în așteptare"
|
||||
>
|
||||
<i class="pi pi-times"></i>
|
||||
<span class="cancel-btn-text">Anulează tot</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else-if="failedCount > 0" class="batch-status-indicator failed">
|
||||
<i class="pi pi-exclamation-circle"></i>
|
||||
<span>{{ failedCount }} cu erori</span>
|
||||
<!-- US-006: Retry All Failed button -->
|
||||
<button
|
||||
v-if="batchId && !retrying"
|
||||
class="retry-all-btn"
|
||||
@click.stop="handleRetryAll"
|
||||
title="Reîncercă toate erorile"
|
||||
>
|
||||
<i class="pi pi-refresh"></i>
|
||||
<span class="retry-btn-text">Reîncercă</span>
|
||||
</button>
|
||||
<span v-if="retrying" class="retry-loading">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="allCompleted" class="batch-status-indicator completed">
|
||||
<i class="pi pi-check-circle"></i>
|
||||
<span>Procesat</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
batchId: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
processingStartedAt: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
isExpanded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
// US-006: Whether retry is in progress
|
||||
retrying: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle', 'retry-all', 'cancel-all'])
|
||||
|
||||
/**
|
||||
* Handle retry all button click.
|
||||
* Stops propagation to prevent toggle, emits retry-all event.
|
||||
*/
|
||||
const handleRetryAll = () => {
|
||||
if (props.batchId) {
|
||||
emit('retry-all', props.batchId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cancel all button click.
|
||||
* Stops propagation to prevent toggle, emits cancel-all event with batch info.
|
||||
*
|
||||
* @emits cancel-all - Emits batch ID and counts for confirmation dialog
|
||||
*/
|
||||
const handleCancelAll = () => {
|
||||
if (props.batchId) {
|
||||
emit('cancel-all', {
|
||||
batchId: props.batchId,
|
||||
pendingProcessingCount: pendingProcessingCount.value,
|
||||
completedCount: completedJobsCount.value
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Compute short batch ID (first 8 chars) - format: B-XXXXXXXX
|
||||
const shortBatchId = computed(() => {
|
||||
if (!props.batchId) return ''
|
||||
// If batch_id is UUID-like, take first 8 chars
|
||||
const id = props.batchId.replace(/-/g, '').substring(0, 8).toUpperCase()
|
||||
return `B-${id}`
|
||||
})
|
||||
|
||||
// Format date as "DD Mon YYYY"
|
||||
const formattedDate = computed(() => {
|
||||
if (!props.processingStartedAt) return null
|
||||
|
||||
const date = new Date(props.processingStartedAt)
|
||||
const months = ['Ian', 'Feb', 'Mar', 'Apr', 'Mai', 'Iun', 'Iul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
|
||||
const day = date.getDate().toString().padStart(2, '0')
|
||||
const month = months[date.getMonth()]
|
||||
const year = date.getFullYear()
|
||||
|
||||
return `${day} ${month} ${year}`
|
||||
})
|
||||
|
||||
// Count items
|
||||
const itemsCount = computed(() => props.items.length)
|
||||
|
||||
// Check for active processing (pending or processing status)
|
||||
const activeProcessingCount = computed(() => {
|
||||
return props.items.filter(
|
||||
item => item.processing_status === 'pending' || item.processing_status === 'processing'
|
||||
).length
|
||||
})
|
||||
|
||||
const hasActiveProcessing = computed(() => activeProcessingCount.value > 0)
|
||||
|
||||
/**
|
||||
* US-021: Count of pending/processing items that CAN be cancelled.
|
||||
* This is the same as activeProcessingCount but with a clearer name for cancel context.
|
||||
*/
|
||||
const pendingProcessingCount = computed(() => activeProcessingCount.value)
|
||||
|
||||
/**
|
||||
* US-021: Count of completed items that will REMAIN after cancel.
|
||||
* These are receipts already processed successfully.
|
||||
*/
|
||||
const completedJobsCount = computed(() => {
|
||||
return props.items.filter(item => item.processing_status === 'completed').length
|
||||
})
|
||||
|
||||
// Failed items count
|
||||
const failedCount = computed(() => {
|
||||
return props.items.filter(item => item.processing_status === 'failed').length
|
||||
})
|
||||
|
||||
// Check if all completed
|
||||
const allCompleted = computed(() => {
|
||||
if (!props.batchId) return false // Manual items don't show completed status
|
||||
return props.items.every(item => item.processing_status === 'completed')
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.batch-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--surface-ground);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.batch-group-header:hover {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
.batch-group-header.is-expanded {
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
margin-bottom: 0;
|
||||
border-bottom-color: transparent;
|
||||
}
|
||||
|
||||
.batch-group-header.has-active-processing {
|
||||
border-left: 3px solid var(--blue-500);
|
||||
}
|
||||
|
||||
.batch-header-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.chevron-icon {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
transition: transform var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.batch-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.batch-label {
|
||||
font-weight: var(--font-semibold);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.batch-separator {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.batch-date {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.batch-count {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Status indicators */
|
||||
.batch-status-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.batch-status-indicator.processing {
|
||||
background: var(--blue-50);
|
||||
color: var(--blue-600);
|
||||
}
|
||||
|
||||
.batch-status-indicator.failed {
|
||||
background: var(--red-50);
|
||||
color: var(--red-600);
|
||||
}
|
||||
|
||||
.batch-status-indicator.completed {
|
||||
background: var(--green-50);
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
/* US-006: Retry All Failed button */
|
||||
.retry-all-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-left: var(--space-sm);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--red-300);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--red-700);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.retry-all-btn:hover {
|
||||
background: var(--red-100);
|
||||
border-color: var(--red-400);
|
||||
}
|
||||
|
||||
.retry-all-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.retry-loading {
|
||||
margin-left: var(--space-sm);
|
||||
}
|
||||
|
||||
/* US-021: Cancel All button */
|
||||
.cancel-all-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
margin-left: var(--space-sm);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-color-secondary);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.cancel-all-btn:hover {
|
||||
background: var(--red-50);
|
||||
border-color: var(--red-300);
|
||||
color: var(--red-700);
|
||||
}
|
||||
|
||||
.cancel-all-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.batch-group-header {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.batch-info {
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.batch-separator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.batch-date,
|
||||
.batch-count {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.batch-status-indicator {
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.batch-status-indicator span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.retry-all-btn .retry-btn-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.retry-all-btn {
|
||||
padding: var(--space-xs);
|
||||
margin-left: var(--space-xs);
|
||||
}
|
||||
|
||||
/* US-021: Hide cancel button text on mobile, show only icon */
|
||||
.cancel-all-btn .cancel-btn-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cancel-all-btn {
|
||||
padding: var(--space-xs);
|
||||
margin-left: var(--space-xs);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
393
src/modules/data-entry/components/bulk/DragDropOverlay.vue
Normal file
393
src/modules/data-entry/components/bulk/DragDropOverlay.vue
Normal file
@@ -0,0 +1,393 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="isVisible"
|
||||
class="drag-drop-overlay"
|
||||
@dragover.prevent
|
||||
@dragleave.prevent="onDragLeave"
|
||||
@drop.prevent="onDrop"
|
||||
>
|
||||
<div class="overlay-content">
|
||||
<i class="pi pi-cloud-upload overlay-icon"></i>
|
||||
<p class="overlay-text">
|
||||
Eliberează pentru a încărca {{ fileCount }} {{ fileCount === 1 ? 'fișier' : 'fișiere' }}
|
||||
</p>
|
||||
<p class="overlay-hint">PDF, PNG, JPG (max 10MB per fișier)</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import api from '@data-entry/services/api'
|
||||
|
||||
const emit = defineEmits(['upload-started', 'upload-complete', 'upload-error'])
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
// Drag state
|
||||
const dragCounter = ref(0)
|
||||
const fileCount = ref(0)
|
||||
|
||||
// Validation constants (matching backend)
|
||||
const MAX_FILE_SIZE_MB = 10
|
||||
const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
const ALLOWED_MIME_TYPES = ['application/pdf', 'image/png', 'image/jpeg']
|
||||
|
||||
// Computed visibility based on drag counter
|
||||
const isVisible = computed(() => dragCounter.value > 0)
|
||||
|
||||
/**
|
||||
* Check if a drag event contains files.
|
||||
* We check types array for 'Files' or 'application/x-moz-file' (Firefox).
|
||||
*/
|
||||
const hasFiles = (event) => {
|
||||
if (!event.dataTransfer) return false
|
||||
const types = event.dataTransfer.types
|
||||
return types && (types.includes('Files') || types.includes('application/x-moz-file'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dragenter on window.
|
||||
* Only activate overlay if dragging files (not text selection, etc.)
|
||||
*/
|
||||
const onWindowDragEnter = (event) => {
|
||||
if (!hasFiles(event)) return
|
||||
|
||||
dragCounter.value++
|
||||
|
||||
// Try to get file count from items (not always available during drag)
|
||||
if (event.dataTransfer?.items) {
|
||||
fileCount.value = event.dataTransfer.items.length
|
||||
} else {
|
||||
fileCount.value = 0 // Will show "fișiere" (plural)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dragover on window.
|
||||
* Prevent default to allow drop.
|
||||
*/
|
||||
const onWindowDragOver = (event) => {
|
||||
if (!hasFiles(event)) return
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dragleave on window.
|
||||
* Decrement counter; hide overlay when 0.
|
||||
*/
|
||||
const onWindowDragLeave = () => {
|
||||
dragCounter.value = Math.max(0, dragCounter.value - 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle dragleave on overlay itself (user drags outside window).
|
||||
*/
|
||||
const onDragLeave = (event) => {
|
||||
// Only hide if leaving to outside the document
|
||||
if (!event.relatedTarget || event.relatedTarget.nodeName === 'HTML') {
|
||||
dragCounter.value = 0
|
||||
fileCount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drop on overlay.
|
||||
*/
|
||||
const onDrop = async (event) => {
|
||||
// Reset drag state immediately
|
||||
dragCounter.value = 0
|
||||
fileCount.value = 0
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
await handleFiles(Array.from(files))
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle drop on window (catch drops that miss overlay).
|
||||
*/
|
||||
const onWindowDrop = async (event) => {
|
||||
// Only handle if overlay was visible
|
||||
if (dragCounter.value === 0) return
|
||||
|
||||
event.preventDefault()
|
||||
dragCounter.value = 0
|
||||
fileCount.value = 0
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
await handleFiles(Array.from(files))
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone files to memory immediately to avoid SnapshotState invalidation on mobile.
|
||||
* On Chrome Android/iOS, accessing File properties after the event handler returns
|
||||
* can fail because the browser invalidates the File object reference.
|
||||
* See: https://issues.chromium.org/40703873
|
||||
*
|
||||
* CRITICAL: Clone FIRST, then access properties from the cloned file.
|
||||
*/
|
||||
const cloneFilesToMemory = async (files) => {
|
||||
const clonedFiles = []
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
// Read file content into memory IMMEDIATELY, before any property access
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const clonedFile = new File([arrayBuffer], file.name, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified
|
||||
})
|
||||
clonedFiles.push(clonedFile)
|
||||
} catch (e) {
|
||||
console.warn('[DragDropOverlay] File clone failed:', e)
|
||||
// On clone failure, try to use original (will fail on mobile but works on desktop)
|
||||
clonedFiles.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
return clonedFiles
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and upload files via bulk upload API.
|
||||
*/
|
||||
const handleFiles = async (files) => {
|
||||
// CRITICAL FIX for US-037: Clone files to memory FIRST
|
||||
// This prevents page reload on mobile when accessing file properties
|
||||
const clonedFiles = await cloneFilesToMemory(files)
|
||||
|
||||
const validFiles = []
|
||||
const invalidFiles = []
|
||||
|
||||
// Validate each CLONED file (safe to access properties now)
|
||||
for (const file of clonedFiles) {
|
||||
// Check MIME type
|
||||
if (!ALLOWED_MIME_TYPES.includes(file.type)) {
|
||||
invalidFiles.push({
|
||||
name: file.name,
|
||||
reason: `Tip invalid (${file.type || 'necunoscut'})`
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check file size
|
||||
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||
invalidFiles.push({
|
||||
name: file.name,
|
||||
reason: `Prea mare (${formatFileSize(file.size)} > ${MAX_FILE_SIZE_MB}MB)`
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// File already cloned, just add to valid list
|
||||
validFiles.push(file)
|
||||
}
|
||||
|
||||
// Show warning toast for invalid files
|
||||
if (invalidFiles.length > 0) {
|
||||
const fileList = invalidFiles
|
||||
.slice(0, 3)
|
||||
.map(f => `• ${f.name}: ${f.reason}`)
|
||||
.join('\n')
|
||||
|
||||
const moreCount = invalidFiles.length - 3
|
||||
const detail = moreCount > 0
|
||||
? `${fileList}\n...și încă ${moreCount} fișiere`
|
||||
: fileList
|
||||
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: `${invalidFiles.length} fișier${invalidFiles.length > 1 ? 'e' : ''} ignorat${invalidFiles.length > 1 ? 'e' : ''}`,
|
||||
detail,
|
||||
life: 5000
|
||||
})
|
||||
}
|
||||
|
||||
// If no valid files, we're done
|
||||
if (validFiles.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Upload via bulk API
|
||||
try {
|
||||
emit('upload-started', validFiles.length)
|
||||
|
||||
const formData = new FormData()
|
||||
validFiles.forEach(file => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
const response = await api.post('/bulk/upload', formData)
|
||||
|
||||
// Show success toast
|
||||
const data = response.data
|
||||
let message = `${data.processed_files || data.total_files} fișier${(data.processed_files || data.total_files) > 1 ? 'e' : ''} încărcat${(data.processed_files || data.total_files) > 1 ? 'e' : ''}`
|
||||
|
||||
// Add duplicate info if present
|
||||
if (data.duplicate_files && data.duplicate_files > 0) {
|
||||
message += `, ${data.duplicate_files} duplicate ignorate`
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Upload reușit',
|
||||
detail: message,
|
||||
life: 4000
|
||||
})
|
||||
|
||||
// US-017: Include filenames mapped to job_ids for instant table display
|
||||
// Create job info array by pairing job_ids with original filenames
|
||||
// Note: Backend returns job_ids in the same order as files were submitted
|
||||
const jobs = data.job_ids.map((jobId, index) => ({
|
||||
job_id: jobId,
|
||||
filename: validFiles[index]?.name || `File ${index + 1}`
|
||||
}))
|
||||
|
||||
emit('upload-complete', {
|
||||
batchId: data.batch_id != null ? String(data.batch_id) : null,
|
||||
jobIds: data.job_ids,
|
||||
jobs: jobs, // US-017: Add job info with filenames
|
||||
totalFiles: data.total_files,
|
||||
processedFiles: data.processed_files,
|
||||
duplicates: data.duplicates
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('[DragDropOverlay] Upload failed:', error)
|
||||
|
||||
let errorMessage = 'Eroare la încărcare'
|
||||
|
||||
if (error.response?.data?.detail) {
|
||||
const detail = error.response.data.detail
|
||||
if (typeof detail === 'string') {
|
||||
errorMessage = detail
|
||||
} else if (detail.message) {
|
||||
errorMessage = detail.message
|
||||
// Show duplicate info if all files were duplicates
|
||||
if (detail.duplicates && detail.duplicates.length > 0) {
|
||||
const dupList = detail.duplicates
|
||||
.slice(0, 3)
|
||||
.map(d => `• ${d.filename}: ${d.message}`)
|
||||
.join('\n')
|
||||
errorMessage += '\n' + dupList
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Upload eșuat',
|
||||
detail: errorMessage,
|
||||
life: 6000
|
||||
})
|
||||
|
||||
emit('upload-error', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size for display.
|
||||
*/
|
||||
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'
|
||||
}
|
||||
|
||||
// Event listeners setup/cleanup
|
||||
onMounted(() => {
|
||||
window.addEventListener('dragenter', onWindowDragEnter)
|
||||
window.addEventListener('dragover', onWindowDragOver)
|
||||
window.addEventListener('dragleave', onWindowDragLeave)
|
||||
window.addEventListener('drop', onWindowDrop)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('dragenter', onWindowDragEnter)
|
||||
window.removeEventListener('dragover', onWindowDragOver)
|
||||
window.removeEventListener('dragleave', onWindowDragLeave)
|
||||
window.removeEventListener('drop', onWindowDrop)
|
||||
})
|
||||
|
||||
// Expose handleFiles so parent can call it programmatically (e.g., from a button click)
|
||||
defineExpose({
|
||||
handleFiles
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.drag-drop-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.overlay-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-xl);
|
||||
background: var(--surface-card);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 3px dashed var(--primary-500);
|
||||
box-shadow: var(--shadow-xl);
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.overlay-icon {
|
||||
font-size: 4rem;
|
||||
color: var(--primary-500);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.1);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay-text {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-color);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.overlay-hint {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Fade transition */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
202
src/modules/data-entry/components/bulk/ProcessingStatusCell.vue
Normal file
202
src/modules/data-entry/components/bulk/ProcessingStatusCell.vue
Normal file
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="processing-status-cell">
|
||||
<!-- Manual receipts (no batch_id) show dash -->
|
||||
<template v-if="!batchId">
|
||||
<span class="processing-status-dash">-</span>
|
||||
</template>
|
||||
|
||||
<!-- Pending status -->
|
||||
<template v-else-if="status === 'pending'">
|
||||
<span class="processing-status processing-pending">
|
||||
În așteptare
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Processing status with spinner -->
|
||||
<template v-else-if="status === 'processing'">
|
||||
<span class="processing-status processing-active">
|
||||
<i class="pi pi-spin pi-spinner"></i>
|
||||
Se procesează...
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Completed status -->
|
||||
<template v-else-if="status === 'completed'">
|
||||
<span class="processing-status processing-success">
|
||||
✓ Procesat
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- Failed status with error message below -->
|
||||
<template v-else-if="status === 'failed'">
|
||||
<div class="processing-failed-container">
|
||||
<span
|
||||
class="processing-status processing-failed"
|
||||
v-tooltip.top="{ value: errorMessage, showDelay: 200 }"
|
||||
@click.stop="handleErrorClick"
|
||||
>
|
||||
✗ Eroare
|
||||
</span>
|
||||
<!-- Truncated error message visible in list -->
|
||||
<span
|
||||
v-if="processingError"
|
||||
class="processing-error-message"
|
||||
v-tooltip.bottom="{ value: processingError, showDelay: 200 }"
|
||||
>
|
||||
{{ truncatedError }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Unknown/null status for batch items -->
|
||||
<template v-else>
|
||||
<span class="processing-status-dash">-</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
/**
|
||||
* Processing status value from receipt
|
||||
* Values: 'pending' | 'processing' | 'completed' | 'failed' | null
|
||||
*/
|
||||
status: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
/**
|
||||
* Batch ID - null means manual receipt (shows dash)
|
||||
*/
|
||||
batchId: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
/**
|
||||
* Error message to show in tooltip (for failed status)
|
||||
*/
|
||||
processingError: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['error-click'])
|
||||
|
||||
/**
|
||||
* Computed error message for tooltip
|
||||
* Falls back to generic message if no specific error provided
|
||||
*/
|
||||
const errorMessage = computed(() => {
|
||||
return props.processingError || 'Eroare la procesare'
|
||||
})
|
||||
|
||||
/**
|
||||
* Truncated error message for inline display
|
||||
* Shows first 50 characters with '...' if longer
|
||||
*/
|
||||
const truncatedError = computed(() => {
|
||||
if (!props.processingError) return ''
|
||||
const maxLength = 50
|
||||
if (props.processingError.length <= maxLength) {
|
||||
return props.processingError
|
||||
}
|
||||
return props.processingError.substring(0, maxLength) + '...'
|
||||
})
|
||||
|
||||
/**
|
||||
* Handle click on error status
|
||||
* Emits event for parent to handle (e.g., show modal with full error)
|
||||
*/
|
||||
const handleErrorClick = () => {
|
||||
emit('error-click', props.processingError)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.processing-status-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.processing-status-dash {
|
||||
color: var(--text-color-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.processing-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
white-space: nowrap;
|
||||
/* US-019: Smooth transition for status changes */
|
||||
transition: opacity 300ms ease, background-color 300ms ease, color 300ms ease;
|
||||
}
|
||||
|
||||
/* US-019: Respect prefers-reduced-motion for accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.processing-status {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Pending: gray/muted */
|
||||
.processing-pending {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* Processing: blue with spinner */
|
||||
.processing-active {
|
||||
background: var(--blue-50);
|
||||
color: var(--blue-600);
|
||||
border: 1px solid var(--blue-500);
|
||||
}
|
||||
|
||||
.processing-active .pi-spinner {
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
/* Completed: green */
|
||||
.processing-success {
|
||||
background: var(--green-50);
|
||||
color: var(--green-600);
|
||||
}
|
||||
|
||||
/* Failed: red, clickable */
|
||||
.processing-failed {
|
||||
background: var(--red-50);
|
||||
color: var(--red-600);
|
||||
border: 1px solid var(--red-500);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.processing-failed:hover {
|
||||
background: var(--red-100);
|
||||
}
|
||||
|
||||
/* Container for failed status + error message */
|
||||
.processing-failed-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Truncated error message displayed below status */
|
||||
.processing-error-message {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--red-600);
|
||||
max-width: 200px;
|
||||
line-height: var(--leading-tight);
|
||||
cursor: help;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user