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>
|
||||
375
src/modules/data-entry/services/sseService.js
Normal file
375
src/modules/data-entry/services/sseService.js
Normal file
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* SSE Client Service
|
||||
*
|
||||
* Service for connecting to Server-Sent Events endpoint and receiving
|
||||
* real-time receipt status updates. Uses native EventSource API with
|
||||
* automatic reconnection.
|
||||
*
|
||||
* US-031: Frontend - SSE Client Service
|
||||
* US-033: Graceful Degradation - Falls back to polling when SSE fails,
|
||||
* retries SSE periodically, and switches back when available.
|
||||
*
|
||||
* @example
|
||||
* import { sseService } from '@data-entry/services/sseService'
|
||||
*
|
||||
* // Subscribe to status changes
|
||||
* sseService.onStatusChange((data) => {
|
||||
* console.log('Status changed:', data.receipt_id, data.status)
|
||||
* })
|
||||
*
|
||||
* // Connect (optionally filter by batch)
|
||||
* sseService.connect()
|
||||
* sseService.connect('batch-uuid-here')
|
||||
*
|
||||
* // Disconnect when done
|
||||
* sseService.disconnect()
|
||||
*/
|
||||
|
||||
/** @type {EventSource|null} */
|
||||
let eventSource = null
|
||||
|
||||
/** @type {((data: StatusChangeData) => void)|null} */
|
||||
let statusChangeCallback = null
|
||||
|
||||
/** @type {boolean} */
|
||||
let isConnected = false
|
||||
|
||||
// US-033: Graceful Degradation State
|
||||
/** @type {boolean} - True when SSE is active and working */
|
||||
let isSSEActive = false
|
||||
|
||||
/** @type {boolean} - True when polling fallback is active */
|
||||
let isPollingActive = false
|
||||
|
||||
/** @type {number|null} - Polling interval ID */
|
||||
let pollingIntervalId = null
|
||||
|
||||
/** @type {number|null} - SSE retry timeout ID */
|
||||
let sseRetryTimeoutId = null
|
||||
|
||||
/** @type {(() => Promise<void>)|null} - Polling callback for fetching data */
|
||||
let pollingCallback = null
|
||||
|
||||
/** @type {string|null} - Current batch ID for reconnection */
|
||||
let currentBatchId = null
|
||||
|
||||
/** @type {number} - Number of consecutive SSE failures */
|
||||
let consecutiveFailures = 0
|
||||
|
||||
/** @type {number} - Max failures before giving up SSE retries */
|
||||
const MAX_CONSECUTIVE_FAILURES = 5
|
||||
|
||||
// US-033: Timing constants
|
||||
const POLLING_INTERVAL_MS = 5000 // 5 seconds between polls
|
||||
const SSE_RETRY_INTERVAL_MS = 30000 // 30 seconds between SSE retry attempts
|
||||
|
||||
/**
|
||||
* @typedef {Object} StatusChangeData
|
||||
* @property {number} receipt_id - Receipt ID that changed
|
||||
* @property {string} status - Workflow status (DRAFT, PENDING_REVIEW, etc.)
|
||||
* @property {string|null} processing_status - Processing status (pending, processing, completed, failed)
|
||||
* @property {string|null} batch_id - Batch ID this receipt belongs to
|
||||
* @property {string} timestamp - ISO timestamp of the event
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build SSE endpoint URL with optional batch_id filter.
|
||||
*
|
||||
* @param {string|null} batchId - Optional batch ID to filter events
|
||||
* @returns {string} Full SSE endpoint URL
|
||||
*/
|
||||
function buildUrl(batchId) {
|
||||
const baseUrl = import.meta.env.BASE_URL + 'api/data-entry/receipts/sse/status'
|
||||
if (batchId) {
|
||||
return `${baseUrl}?batch_id=${encodeURIComponent(batchId)}`
|
||||
}
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to SSE endpoint for real-time status updates.
|
||||
*
|
||||
* If already connected, disconnects first before reconnecting.
|
||||
* EventSource handles automatic reconnection on network errors
|
||||
* (default retry is 3 seconds, as set by backend).
|
||||
*
|
||||
* US-033: Enhanced with graceful degradation - falls back to polling on failure.
|
||||
*
|
||||
* @param {string|null} [batchId=null] - Optional batch ID to filter events for
|
||||
*/
|
||||
function connect(batchId = null) {
|
||||
// Disconnect existing connection if any
|
||||
if (eventSource) {
|
||||
closeEventSource()
|
||||
}
|
||||
|
||||
// US-033: Store batch ID for reconnection attempts
|
||||
currentBatchId = batchId
|
||||
|
||||
const url = buildUrl(batchId)
|
||||
console.log(`[SSE] Connecting to ${url}`)
|
||||
|
||||
try {
|
||||
eventSource = new EventSource(url)
|
||||
isConnected = true
|
||||
|
||||
// Handle incoming messages
|
||||
eventSource.onmessage = (event) => {
|
||||
try {
|
||||
/** @type {StatusChangeData} */
|
||||
const data = JSON.parse(event.data)
|
||||
console.log('[SSE] Received status change:', data)
|
||||
|
||||
if (statusChangeCallback) {
|
||||
statusChangeCallback(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SSE] Failed to parse event data:', err, event.data)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle connection open
|
||||
eventSource.onopen = () => {
|
||||
console.log('[SSE] Connection opened')
|
||||
isConnected = true
|
||||
isSSEActive = true
|
||||
|
||||
// US-033: SSE is working, reset failure count
|
||||
consecutiveFailures = 0
|
||||
|
||||
// US-033: Stop polling if it was running - SSE is now active
|
||||
if (isPollingActive) {
|
||||
console.log('[SSE] SSE connection restored, stopping polling fallback')
|
||||
stopPolling()
|
||||
}
|
||||
|
||||
// US-033: Clear any pending retry timeout
|
||||
if (sseRetryTimeoutId) {
|
||||
clearTimeout(sseRetryTimeoutId)
|
||||
sseRetryTimeoutId = null
|
||||
}
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
eventSource.onerror = (error) => {
|
||||
// US-033: Check if connection is permanently closed
|
||||
if (eventSource?.readyState === EventSource.CLOSED) {
|
||||
console.log('[SSE] Connection closed')
|
||||
isConnected = false
|
||||
isSSEActive = false
|
||||
|
||||
// US-033: Increment failure count and activate fallback
|
||||
consecutiveFailures++
|
||||
console.log('SSE connection failed, falling back to polling')
|
||||
activateFallbackToPolling()
|
||||
} else if (eventSource?.readyState === EventSource.CONNECTING) {
|
||||
// EventSource is auto-reconnecting, this is normal behavior
|
||||
console.log('[SSE] Connection lost, reconnecting...')
|
||||
} else {
|
||||
console.error('[SSE] Connection error:', error)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// US-033: Handle any errors during EventSource creation
|
||||
console.error('[SSE] Failed to create EventSource:', err)
|
||||
console.log('SSE connection failed, falling back to polling')
|
||||
isConnected = false
|
||||
isSSEActive = false
|
||||
consecutiveFailures++
|
||||
activateFallbackToPolling()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the EventSource without full cleanup.
|
||||
* Used internally when reconnecting or switching states.
|
||||
*/
|
||||
function closeEventSource() {
|
||||
if (eventSource) {
|
||||
eventSource.close()
|
||||
eventSource = null
|
||||
isConnected = false
|
||||
isSSEActive = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* US-033: Activate fallback to polling when SSE fails.
|
||||
* Also schedules periodic SSE retry attempts.
|
||||
*/
|
||||
function activateFallbackToPolling() {
|
||||
// Start polling if we have a callback and aren't already polling
|
||||
if (pollingCallback && !isPollingActive) {
|
||||
startPolling()
|
||||
}
|
||||
|
||||
// US-033: Schedule SSE retry if we haven't exceeded max failures
|
||||
if (consecutiveFailures < MAX_CONSECUTIVE_FAILURES) {
|
||||
scheduleSSERetry()
|
||||
} else {
|
||||
console.log(`[SSE] Max consecutive failures (${MAX_CONSECUTIVE_FAILURES}) reached, stopping SSE retries`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* US-033: Start polling as fallback mechanism.
|
||||
* Calls the polling callback at regular intervals.
|
||||
*/
|
||||
function startPolling() {
|
||||
if (isPollingActive || !pollingCallback) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[SSE] Starting polling fallback (interval: ${POLLING_INTERVAL_MS}ms)`)
|
||||
isPollingActive = true
|
||||
|
||||
// Execute immediately, then at intervals
|
||||
pollingCallback()
|
||||
|
||||
pollingIntervalId = setInterval(() => {
|
||||
if (pollingCallback) {
|
||||
pollingCallback()
|
||||
}
|
||||
}, POLLING_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* US-033: Stop the polling fallback.
|
||||
*/
|
||||
function stopPolling() {
|
||||
if (pollingIntervalId) {
|
||||
clearInterval(pollingIntervalId)
|
||||
pollingIntervalId = null
|
||||
}
|
||||
isPollingActive = false
|
||||
console.log('[SSE] Polling fallback stopped')
|
||||
}
|
||||
|
||||
/**
|
||||
* US-033: Schedule a retry attempt to reconnect to SSE.
|
||||
* If SSE connects successfully, polling will be stopped automatically.
|
||||
*/
|
||||
function scheduleSSERetry() {
|
||||
// Don't schedule if already scheduled
|
||||
if (sseRetryTimeoutId) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`[SSE] Scheduling SSE retry in ${SSE_RETRY_INTERVAL_MS / 1000}s`)
|
||||
|
||||
sseRetryTimeoutId = setTimeout(() => {
|
||||
sseRetryTimeoutId = null
|
||||
|
||||
// Only retry if we're still in fallback mode (polling active)
|
||||
if (isPollingActive && !isSSEActive) {
|
||||
console.log('[SSE] Retrying SSE connection...')
|
||||
connect(currentBatchId)
|
||||
}
|
||||
}, SSE_RETRY_INTERVAL_MS)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from SSE endpoint.
|
||||
*
|
||||
* Closes the EventSource connection and cleans up all resources
|
||||
* including polling fallback and retry timers.
|
||||
*
|
||||
* US-033: Enhanced to clean up all graceful degradation state.
|
||||
*
|
||||
* Safe to call multiple times.
|
||||
*/
|
||||
function disconnect() {
|
||||
console.log('[SSE] Disconnecting and cleaning up')
|
||||
|
||||
// Close EventSource
|
||||
closeEventSource()
|
||||
|
||||
// US-033: Stop polling if active
|
||||
stopPolling()
|
||||
|
||||
// US-033: Clear retry timeout
|
||||
if (sseRetryTimeoutId) {
|
||||
clearTimeout(sseRetryTimeoutId)
|
||||
sseRetryTimeoutId = null
|
||||
}
|
||||
|
||||
// US-033: Reset state
|
||||
currentBatchId = null
|
||||
consecutiveFailures = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* US-033: Set the polling callback for fallback mechanism.
|
||||
*
|
||||
* This callback will be called at regular intervals when SSE fails
|
||||
* and polling fallback is activated. Should fetch/refresh data.
|
||||
*
|
||||
* @param {(() => Promise<void>)|null} callback - Async function to call for polling
|
||||
*/
|
||||
function setPollingCallback(callback) {
|
||||
pollingCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Register callback for status change events.
|
||||
*
|
||||
* Only one callback can be registered at a time.
|
||||
* Call with null to unregister.
|
||||
*
|
||||
* @param {((data: StatusChangeData) => void)|null} callback - Callback function or null
|
||||
*/
|
||||
function onStatusChange(callback) {
|
||||
statusChangeCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if currently connected to SSE endpoint.
|
||||
*
|
||||
* @returns {boolean} True if connected
|
||||
*/
|
||||
function getIsConnected() {
|
||||
return isConnected && eventSource !== null && eventSource.readyState !== EventSource.CLOSED
|
||||
}
|
||||
|
||||
/**
|
||||
* US-033: Check if SSE is actively working (not in fallback mode).
|
||||
*
|
||||
* @returns {boolean} True if SSE is active and receiving events
|
||||
*/
|
||||
function getIsSSEActive() {
|
||||
return isSSEActive
|
||||
}
|
||||
|
||||
/**
|
||||
* US-033: Check if polling fallback is currently active.
|
||||
*
|
||||
* @returns {boolean} True if polling fallback is running
|
||||
*/
|
||||
function getIsPollingActive() {
|
||||
return isPollingActive
|
||||
}
|
||||
|
||||
/**
|
||||
* SSE Service singleton
|
||||
*
|
||||
* US-033: Enhanced with graceful degradation capabilities.
|
||||
*/
|
||||
export const sseService = {
|
||||
connect,
|
||||
disconnect,
|
||||
onStatusChange,
|
||||
setPollingCallback,
|
||||
get isConnected() {
|
||||
return getIsConnected()
|
||||
},
|
||||
// US-033: Expose state for debugging/monitoring
|
||||
get isSSEActive() {
|
||||
return getIsSSEActive()
|
||||
},
|
||||
get isPollingActive() {
|
||||
return getIsPollingActive()
|
||||
},
|
||||
}
|
||||
|
||||
// Also export individual functions for flexibility
|
||||
export { connect, disconnect, onStatusChange, setPollingCallback }
|
||||
593
src/modules/data-entry/stores/batchProgressStore.js
Normal file
593
src/modules/data-entry/stores/batchProgressStore.js
Normal file
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* Batch Progress Store
|
||||
*
|
||||
* Pinia store for tracking bulk upload batch progress via long-polling.
|
||||
* Uses AbortController for clean cancellation on component unmount.
|
||||
*
|
||||
* US-009: Added localStorage persistence for active batch IDs to support
|
||||
* auto-resume polling after page refresh or navigation away.
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import api from '@data-entry/services/api'
|
||||
|
||||
/**
|
||||
* @typedef {Object} JobStatus
|
||||
* @property {string} job_id - OCR job UUID
|
||||
* @property {string} filename - Original filename
|
||||
* @property {string} status - Job status: 'pending' | 'processing' | 'completed' | 'failed'
|
||||
* @property {number|null} receipt_id - Created receipt ID (if completed)
|
||||
* @property {string|null} error_message - Error message (if failed)
|
||||
*/
|
||||
|
||||
// US-009: localStorage key for persisting active batch IDs
|
||||
const ACTIVE_BATCHES_STORAGE_KEY = 'roa2web_active_batch_ids'
|
||||
|
||||
/**
|
||||
* Get active batch IDs from localStorage
|
||||
* @returns {string[]} Array of batch ID strings
|
||||
*/
|
||||
function getStoredActiveBatches() {
|
||||
try {
|
||||
const stored = localStorage.getItem(ACTIVE_BATCHES_STORAGE_KEY)
|
||||
if (!stored) return []
|
||||
const parsed = JSON.parse(stored)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch (e) {
|
||||
console.warn('[BatchProgress] Failed to read localStorage:', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save active batch IDs to localStorage
|
||||
* @param {string[]} batchIds - Array of batch ID strings
|
||||
*/
|
||||
function saveActiveBatches(batchIds) {
|
||||
try {
|
||||
if (batchIds.length === 0) {
|
||||
localStorage.removeItem(ACTIVE_BATCHES_STORAGE_KEY)
|
||||
} else {
|
||||
localStorage.setItem(ACTIVE_BATCHES_STORAGE_KEY, JSON.stringify(batchIds))
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[BatchProgress] Failed to save to localStorage:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a batch ID to localStorage
|
||||
* @param {string} batchId - Batch ID to add
|
||||
*/
|
||||
function addActiveBatch(batchId) {
|
||||
const batches = getStoredActiveBatches()
|
||||
if (!batches.includes(batchId)) {
|
||||
batches.push(batchId)
|
||||
saveActiveBatches(batches)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a batch ID from localStorage
|
||||
* @param {string} batchId - Batch ID to remove
|
||||
*/
|
||||
function removeActiveBatch(batchId) {
|
||||
const batches = getStoredActiveBatches()
|
||||
const filtered = batches.filter(id => id !== batchId)
|
||||
saveActiveBatches(filtered)
|
||||
}
|
||||
|
||||
export const useBatchProgressStore = defineStore('batchProgress', () => {
|
||||
// ============ State ============
|
||||
|
||||
/** @type {import('vue').Ref<number|null>} Current batch ID being tracked */
|
||||
const batchId = ref(null)
|
||||
|
||||
/** @type {import('vue').Ref<Map<string, JobStatus>>} Map of job_id -> JobStatus */
|
||||
const jobs = ref(new Map())
|
||||
|
||||
/** @type {import('vue').Ref<boolean>} Whether polling is active */
|
||||
const isPolling = ref(false)
|
||||
|
||||
/** @type {import('vue').Ref<string|null>} Last error message */
|
||||
const error = ref(null)
|
||||
|
||||
/** @type {AbortController|null} Controller for canceling ongoing requests */
|
||||
let abortController = null
|
||||
|
||||
/**
|
||||
* US-018: Callback function for when jobs complete or fail.
|
||||
* Set this to receive notifications when job status transitions happen.
|
||||
* @type {((completedReceiptIds: number[], failedJobIds: string[]) => void)|null}
|
||||
*/
|
||||
let onJobsTransitionCallback = null
|
||||
|
||||
// ============ Computed ============
|
||||
|
||||
/** Number of jobs in 'pending' status */
|
||||
const pendingCount = computed(() => {
|
||||
let count = 0
|
||||
for (const job of jobs.value.values()) {
|
||||
if (job.status === 'pending') count++
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
/** Number of jobs in 'processing' status */
|
||||
const processingCount = computed(() => {
|
||||
let count = 0
|
||||
for (const job of jobs.value.values()) {
|
||||
if (job.status === 'processing') count++
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
/** Number of jobs in 'completed' status */
|
||||
const completedCount = computed(() => {
|
||||
let count = 0
|
||||
for (const job of jobs.value.values()) {
|
||||
if (job.status === 'completed') count++
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
/** Number of jobs in 'failed' status */
|
||||
const failedCount = computed(() => {
|
||||
let count = 0
|
||||
for (const job of jobs.value.values()) {
|
||||
if (job.status === 'failed') count++
|
||||
}
|
||||
return count
|
||||
})
|
||||
|
||||
/** Total number of jobs */
|
||||
const totalCount = computed(() => jobs.value.size)
|
||||
|
||||
/** Progress percentage (0-100) */
|
||||
const progress = computed(() => {
|
||||
if (totalCount.value === 0) return 0
|
||||
const finished = completedCount.value + failedCount.value
|
||||
return Math.round((finished / totalCount.value) * 100)
|
||||
})
|
||||
|
||||
/** Whether all jobs are finished (completed or failed) */
|
||||
const isComplete = computed(() => {
|
||||
if (totalCount.value === 0) return false
|
||||
return pendingCount.value === 0 && processingCount.value === 0
|
||||
})
|
||||
|
||||
/** Total amount from all completed receipts (from backend response) */
|
||||
const totalAmount = ref(0)
|
||||
|
||||
/** Jobs as array for iteration */
|
||||
const jobsArray = computed(() => Array.from(jobs.value.values()))
|
||||
|
||||
// ============ Actions ============
|
||||
|
||||
/**
|
||||
* Start long-polling for batch status updates.
|
||||
* Uses 30-second wait parameter for efficient long-polling.
|
||||
*
|
||||
* @param {string} id - Batch ID to track
|
||||
* @param {Array<{job_id: string, filename: string}>} [initialJobs] - Optional initial jobs to populate immediately
|
||||
*/
|
||||
async function startPolling(id, initialJobs = null) {
|
||||
// Stop any existing polling
|
||||
stopPolling()
|
||||
|
||||
// Reset state
|
||||
batchId.value = id
|
||||
jobs.value = new Map()
|
||||
error.value = null
|
||||
isPolling.value = true
|
||||
totalAmount.value = 0
|
||||
|
||||
// US-017: Pre-populate jobs immediately if initial jobs provided
|
||||
// This ensures jobs appear instantly in the table before polling fetches real status
|
||||
if (initialJobs && Array.isArray(initialJobs)) {
|
||||
for (const job of initialJobs) {
|
||||
jobs.value.set(job.job_id, {
|
||||
job_id: job.job_id,
|
||||
filename: job.filename,
|
||||
status: 'pending', // Initial status is always pending
|
||||
receipt_id: null,
|
||||
error_message: null,
|
||||
confidence: null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// US-009: Persist batch ID to localStorage for resume after refresh
|
||||
addActiveBatch(id)
|
||||
|
||||
// Create new abort controller for this polling session
|
||||
abortController = new AbortController()
|
||||
|
||||
// Start polling loop
|
||||
await pollLoop()
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop polling and cancel any pending requests.
|
||||
*/
|
||||
function stopPolling() {
|
||||
isPolling.value = false
|
||||
|
||||
// Cancel any pending request
|
||||
if (abortController) {
|
||||
abortController.abort()
|
||||
abortController = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal polling loop - fetches status until complete or stopped.
|
||||
*/
|
||||
async function pollLoop() {
|
||||
while (isPolling.value && batchId.value !== null) {
|
||||
try {
|
||||
// Fetch batch status with 30-second wait (long-polling)
|
||||
const response = await api.get(`/bulk/batches/${batchId.value}/status`, {
|
||||
params: { wait: 30 },
|
||||
signal: abortController?.signal,
|
||||
// Extend timeout for long-polling
|
||||
timeout: 35000
|
||||
})
|
||||
|
||||
const data = response.data
|
||||
|
||||
// US-018: Track status transitions for callback notification
|
||||
const previousJobs = jobs.value
|
||||
const newlyCompletedReceiptIds = []
|
||||
const newlyFailedJobIds = []
|
||||
|
||||
// Update jobs map
|
||||
const newJobs = new Map()
|
||||
for (const job of data.jobs) {
|
||||
const previousJob = previousJobs.get(job.job_id)
|
||||
const previousStatus = previousJob?.status
|
||||
|
||||
// Check for transitions to completed/failed
|
||||
if (previousStatus && previousStatus !== job.status) {
|
||||
if (job.status === 'completed' && job.receipt_id) {
|
||||
newlyCompletedReceiptIds.push(job.receipt_id)
|
||||
console.log(`[BatchProgress] Job ${job.job_id} completed -> receipt ${job.receipt_id}`)
|
||||
} else if (job.status === 'failed') {
|
||||
newlyFailedJobIds.push(job.job_id)
|
||||
console.log(`[BatchProgress] Job ${job.job_id} failed: ${job.error_message}`)
|
||||
}
|
||||
}
|
||||
|
||||
newJobs.set(job.job_id, {
|
||||
job_id: job.job_id,
|
||||
filename: job.filename,
|
||||
status: job.status,
|
||||
receipt_id: job.receipt_id || null,
|
||||
error_message: job.error_message || null,
|
||||
confidence: job.confidence || null
|
||||
})
|
||||
}
|
||||
jobs.value = newJobs
|
||||
|
||||
// US-018: Notify listener of status transitions
|
||||
if (onJobsTransitionCallback && (newlyCompletedReceiptIds.length > 0 || newlyFailedJobIds.length > 0)) {
|
||||
onJobsTransitionCallback(newlyCompletedReceiptIds, newlyFailedJobIds)
|
||||
}
|
||||
|
||||
// Update total amount from backend
|
||||
totalAmount.value = data.total_amount || 0
|
||||
|
||||
// Clear any previous error
|
||||
error.value = null
|
||||
|
||||
// Check if all jobs are finished
|
||||
const finished = data.completed_count + data.failed_count
|
||||
if (finished >= data.total_files) {
|
||||
console.log('[BatchProgress] All jobs finished, stopping polling')
|
||||
isPolling.value = false
|
||||
|
||||
// US-009: Remove completed batch from localStorage
|
||||
if (batchId.value) {
|
||||
removeActiveBatch(batchId.value)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
// Ignore abort errors (expected when stopPolling is called)
|
||||
if (err.name === 'AbortError' || err.code === 'ERR_CANCELED') {
|
||||
console.log('[BatchProgress] Polling aborted')
|
||||
break
|
||||
}
|
||||
|
||||
// Handle other errors
|
||||
console.error('[BatchProgress] Polling error:', err)
|
||||
error.value = err.response?.data?.detail || err.message || 'Failed to fetch batch status'
|
||||
|
||||
// Wait before retrying on error
|
||||
await sleep(2000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset store to initial state.
|
||||
*/
|
||||
function reset() {
|
||||
stopPolling()
|
||||
batchId.value = null
|
||||
jobs.value = new Map()
|
||||
error.value = null
|
||||
totalAmount.value = 0
|
||||
}
|
||||
|
||||
// ============ US-009: Auto-Resume Functions ============
|
||||
|
||||
/**
|
||||
* Get list of active batch IDs stored in localStorage.
|
||||
* Used by views to check if there are batches to resume after refresh.
|
||||
*
|
||||
* @returns {string[]} Array of stored batch IDs
|
||||
*/
|
||||
function getStoredBatchIds() {
|
||||
return getStoredActiveBatches()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a specific batch ID from localStorage.
|
||||
* Called when a batch is determined to be complete (all items processed).
|
||||
*
|
||||
* @param {string} batchIdToRemove - Batch ID to remove from storage
|
||||
*/
|
||||
function clearStoredBatch(batchIdToRemove) {
|
||||
removeActiveBatch(batchIdToRemove)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all stored batch IDs from localStorage.
|
||||
* Used during cleanup or when all batches are confirmed complete.
|
||||
*/
|
||||
function clearAllStoredBatches() {
|
||||
saveActiveBatches([])
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Sleep for given milliseconds.
|
||||
* @param {number} ms - Milliseconds to sleep
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
// ============ US-023: Restore Jobs from Batch ============
|
||||
|
||||
/**
|
||||
* Restore jobs from a stored batch by fetching current status from API.
|
||||
* Only pending/processing jobs are added to the store (completed/failed are already receipts).
|
||||
*
|
||||
* US-023: Called on page refresh/return to restore visibility of active jobs.
|
||||
*
|
||||
* @param {string} storedBatchId - The batch ID to restore from
|
||||
* @returns {Promise<{hasActiveJobs: boolean, jobCount: number}>} Result of restoration
|
||||
*/
|
||||
async function restoreJobsFromBatch(storedBatchId) {
|
||||
try {
|
||||
console.log(`[BatchProgress] Restoring jobs from batch ${storedBatchId}`)
|
||||
|
||||
// Fetch current batch status from API (no wait, just get current state)
|
||||
const response = await api.get(`/bulk/batches/${storedBatchId}/status`, {
|
||||
params: { wait: 0 },
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
const data = response.data
|
||||
|
||||
if (!data.jobs || data.jobs.length === 0) {
|
||||
console.log(`[BatchProgress] Batch ${storedBatchId} has no jobs, removing from storage`)
|
||||
removeActiveBatch(storedBatchId)
|
||||
return { hasActiveJobs: false, jobCount: 0 }
|
||||
}
|
||||
|
||||
// Count and filter active jobs (pending/processing only)
|
||||
const activeJobs = data.jobs.filter(
|
||||
job => job.status === 'pending' || job.status === 'processing'
|
||||
)
|
||||
|
||||
if (activeJobs.length === 0) {
|
||||
// All jobs are completed or failed - no need to restore to UI
|
||||
console.log(`[BatchProgress] Batch ${storedBatchId} has no active jobs (all completed/failed), removing from storage`)
|
||||
removeActiveBatch(storedBatchId)
|
||||
return { hasActiveJobs: false, jobCount: 0 }
|
||||
}
|
||||
|
||||
// Set batch ID and add active jobs to store
|
||||
batchId.value = storedBatchId
|
||||
|
||||
// Add jobs to the Map (merge with existing if any)
|
||||
for (const job of activeJobs) {
|
||||
jobs.value.set(job.job_id, {
|
||||
job_id: job.job_id,
|
||||
filename: job.filename,
|
||||
status: job.status,
|
||||
receipt_id: job.receipt_id || null,
|
||||
error_message: job.error_message || null,
|
||||
confidence: job.confidence || null
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`[BatchProgress] Restored ${activeJobs.length} active jobs from batch ${storedBatchId}`)
|
||||
|
||||
// Start polling for updates
|
||||
if (!isPolling.value) {
|
||||
isPolling.value = true
|
||||
abortController = new AbortController()
|
||||
// Start polling loop in background
|
||||
pollLoop()
|
||||
}
|
||||
|
||||
return { hasActiveJobs: true, jobCount: activeJobs.length }
|
||||
} catch (err) {
|
||||
console.error(`[BatchProgress] Error restoring batch ${storedBatchId}:`, err)
|
||||
|
||||
// If batch not found (404), remove it from storage
|
||||
if (err.response?.status === 404) {
|
||||
console.log(`[BatchProgress] Batch ${storedBatchId} not found, removing from storage`)
|
||||
removeActiveBatch(storedBatchId)
|
||||
}
|
||||
|
||||
return { hasActiveJobs: false, jobCount: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
// ============ US-016: Cancel Actions ============
|
||||
|
||||
/**
|
||||
* Cancel a specific job by job ID.
|
||||
* After successful cancellation, the job is removed from the jobs Map.
|
||||
*
|
||||
* @param {string} jobId - The job ID to cancel
|
||||
* @returns {Promise<{success: boolean, message: string}>} Result of the operation
|
||||
*/
|
||||
async function cancelJob(jobId) {
|
||||
try {
|
||||
const response = await api.post(`/bulk/cancel/${jobId}`)
|
||||
const data = response.data
|
||||
|
||||
if (data.success) {
|
||||
// Remove the cancelled job from the Map
|
||||
jobs.value.delete(jobId)
|
||||
|
||||
console.log(`[BatchProgress] Job ${jobId} cancelled successfully`)
|
||||
return {
|
||||
success: true,
|
||||
message: data.message || 'Job anulat cu succes'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: data.message || 'Eroare la anularea job-ului'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[BatchProgress] Error cancelling job:', err)
|
||||
|
||||
// Extract error message from response or use generic message
|
||||
const errorMessage = err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
'Eroare la anularea job-ului'
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel all pending/processing jobs in a batch.
|
||||
* After successful cancellation, cancelled jobs are removed from the jobs Map.
|
||||
* Completed/failed jobs remain in the Map for visibility.
|
||||
*
|
||||
* @param {number|string} batchIdToCancel - The batch ID to cancel
|
||||
* @returns {Promise<{success: boolean, message: string, cancelledCount: number, skippedCount: number}>}
|
||||
*/
|
||||
async function cancelBatch(batchIdToCancel) {
|
||||
try {
|
||||
const response = await api.post(`/bulk/cancel-batch/${batchIdToCancel}`)
|
||||
const data = response.data
|
||||
|
||||
if (data.success) {
|
||||
// Remove all cancelled jobs from the Map
|
||||
// Jobs with 'pending' or 'processing' status were cancelled
|
||||
const jobsToRemove = []
|
||||
for (const [jobId, job] of jobs.value.entries()) {
|
||||
if (job.status === 'pending' || job.status === 'processing') {
|
||||
jobsToRemove.push(jobId)
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the jobs
|
||||
for (const jobId of jobsToRemove) {
|
||||
jobs.value.delete(jobId)
|
||||
}
|
||||
|
||||
console.log(`[BatchProgress] Batch ${batchIdToCancel} cancelled: ${data.cancelled_count} cancelled, ${data.skipped_count} skipped`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: data.message || `${data.cancelled_count} job-uri anulate`,
|
||||
cancelledCount: data.cancelled_count,
|
||||
skippedCount: data.skipped_count
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: data.message || 'Eroare la anularea batch-ului',
|
||||
cancelledCount: 0,
|
||||
skippedCount: 0
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[BatchProgress] Error cancelling batch:', err)
|
||||
|
||||
// Extract error message from response or use generic message
|
||||
const errorMessage = err.response?.data?.detail ||
|
||||
err.response?.data?.message ||
|
||||
err.message ||
|
||||
'Eroare la anularea batch-ului'
|
||||
|
||||
return {
|
||||
success: false,
|
||||
message: errorMessage,
|
||||
cancelledCount: 0,
|
||||
skippedCount: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Return Public API ============
|
||||
|
||||
return {
|
||||
// State
|
||||
batchId,
|
||||
jobs,
|
||||
isPolling,
|
||||
error,
|
||||
|
||||
// Computed
|
||||
pendingCount,
|
||||
processingCount,
|
||||
completedCount,
|
||||
failedCount,
|
||||
totalCount,
|
||||
progress,
|
||||
isComplete,
|
||||
totalAmount,
|
||||
jobsArray,
|
||||
|
||||
// Actions
|
||||
startPolling,
|
||||
stopPolling,
|
||||
reset,
|
||||
|
||||
// US-009: Auto-Resume Functions
|
||||
getStoredBatchIds,
|
||||
clearStoredBatch,
|
||||
clearAllStoredBatches,
|
||||
|
||||
// US-023: Restore Jobs from Batch
|
||||
restoreJobsFromBatch,
|
||||
|
||||
// US-016: Cancel Actions
|
||||
cancelJob,
|
||||
cancelBatch,
|
||||
|
||||
// US-018: Transition Callback
|
||||
setOnJobsTransitionCallback: (callback) => {
|
||||
onJobsTransitionCallback = callback
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -15,6 +15,13 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
currentReceipt: null,
|
||||
pendingReceipts: [],
|
||||
stats: null,
|
||||
// Processing stats for bulk upload filtering (US-005)
|
||||
processingStats: {
|
||||
pending_count: 0,
|
||||
processing_count: 0,
|
||||
completed_count: 0,
|
||||
failed_count: 0,
|
||||
},
|
||||
loading: false,
|
||||
error: null,
|
||||
pagination: {
|
||||
@@ -29,6 +36,7 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
direction: null,
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
processingStatus: null, // US-005: Filter by processing_status
|
||||
},
|
||||
// Nomenclatures
|
||||
partners: [],
|
||||
@@ -70,11 +78,25 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
if (this.filters.dateTo) {
|
||||
params.date_to = this.filters.dateTo
|
||||
}
|
||||
// US-005: Add processing_status filter
|
||||
// Map frontend filter values to backend values
|
||||
if (this.filters.processingStatus) {
|
||||
if (this.filters.processingStatus === 'in_processing') {
|
||||
// "În procesare" = pending + processing
|
||||
params.processing_status = 'pending,processing'
|
||||
} else {
|
||||
params.processing_status = this.filters.processingStatus
|
||||
}
|
||||
}
|
||||
|
||||
const response = await api.get('/', { params })
|
||||
this.receipts = response.data.items
|
||||
this.pagination.total = response.data.total
|
||||
this.pagination.pages = response.data.pages
|
||||
// US-005: Capture processing_stats from API response
|
||||
if (response.data.processing_stats) {
|
||||
this.processingStats = response.data.processing_stats
|
||||
}
|
||||
} catch (error) {
|
||||
this.error = error.response?.data?.detail || 'Failed to fetch receipts'
|
||||
throw error
|
||||
@@ -436,6 +458,123 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
}
|
||||
},
|
||||
|
||||
// ============ Retry Actions (US-006) ============
|
||||
|
||||
/**
|
||||
* Retry processing for a single failed receipt.
|
||||
* Calls POST /bulk/retry/{receipt_id} to requeue the OCR job.
|
||||
*
|
||||
* @param {number} receiptId - Receipt ID to retry
|
||||
* @returns {Promise<{success: boolean, job_id: string, message: string}>}
|
||||
*/
|
||||
async retryReceipt(receiptId) {
|
||||
try {
|
||||
// Use apiClient directly - bulk endpoints are at /api/data-entry/bulk, not /api/data-entry/receipts
|
||||
const response = await apiClient.post(`/bulk/retry/${receiptId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const detail = error.response?.data?.detail || 'Eroare la reîncărcare'
|
||||
throw new Error(detail)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Retry all failed receipts in a batch.
|
||||
* Calls POST /bulk/retry-batch/{batch_id} to requeue all failed OCR jobs.
|
||||
*
|
||||
* @param {string} batchId - Batch ID (UUID string)
|
||||
* @returns {Promise<{success: boolean, retried_count: number, failed_count: number, message: string}>}
|
||||
*/
|
||||
async retryBatchFailed(batchId) {
|
||||
try {
|
||||
// Use apiClient directly - bulk endpoints are at /api/data-entry/bulk, not /api/data-entry/receipts
|
||||
const response = await apiClient.post(`/bulk/retry-batch/${batchId}`)
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const detail = error.response?.data?.detail || 'Eroare la reîncărcarea batch-ului'
|
||||
throw new Error(detail)
|
||||
}
|
||||
},
|
||||
|
||||
// ============ Bulk Delete (US-027) ============
|
||||
|
||||
/**
|
||||
* Bulk delete receipts by IDs.
|
||||
* Calls DELETE /receipts/bulk and returns partial success response.
|
||||
*
|
||||
* @param {number[]} ids - Array of receipt IDs to delete
|
||||
* @returns {Promise<{deleted: number[], failed: Array<{id: number, error: string}>}>}
|
||||
*/
|
||||
async bulkDeleteReceipts(ids) {
|
||||
try {
|
||||
const response = await api.delete('/bulk', { data: { ids } })
|
||||
return response.data
|
||||
} catch (error) {
|
||||
const detail = error.response?.data?.detail || 'Eroare la ștergerea bonurilor'
|
||||
throw new Error(detail)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove receipts from local array by IDs (US-027).
|
||||
* Updates array in place without re-fetching from server.
|
||||
*
|
||||
* @param {number[]} ids - Array of receipt IDs to remove
|
||||
*/
|
||||
removeReceiptsLocally(ids) {
|
||||
const idsSet = new Set(ids)
|
||||
this.receipts = this.receipts.filter(r => !idsSet.has(r.id))
|
||||
},
|
||||
|
||||
/**
|
||||
* Update a single receipt in place without replacing the array (US-029).
|
||||
* Uses Object.assign() to preserve Vue reactivity and only re-render
|
||||
* the affected row, not the entire list.
|
||||
*
|
||||
* @param {number} receiptId - Receipt ID to update
|
||||
* @param {Object} updates - Object with properties to update
|
||||
* @returns {boolean} true if receipt was found and updated, false otherwise
|
||||
*/
|
||||
updateReceiptInPlace(receiptId, updates) {
|
||||
const idx = this.receipts.findIndex(r => r.id === receiptId)
|
||||
if (idx !== -1) {
|
||||
Object.assign(this.receipts[idx], updates)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
|
||||
/**
|
||||
* Insert a receipt in place at the correct position (US-034, US-043).
|
||||
*
|
||||
* US-043: Always prepend to maintain upload order. When jobs complete,
|
||||
* they transition from jobItems (prepended in unifiedItems) to receiptsList.
|
||||
* Prepending to receiptsList ensures the receipt appears where the job was -
|
||||
* right after the remaining pending jobs.
|
||||
*
|
||||
* This maintains visual position stability:
|
||||
* - Jobs are shown first (from batchProgressStore.jobs)
|
||||
* - As jobs complete, their receipts prepend to this.receipts
|
||||
* - Combined: [remaining_jobs] + [newly_completed_receipts] + [older_receipts]
|
||||
*
|
||||
* @param {Object} receipt - The full receipt object to insert
|
||||
* @returns {boolean} true if receipt was inserted
|
||||
*/
|
||||
insertReceiptInPlace(receipt) {
|
||||
// Check if receipt already exists
|
||||
const existingIdx = this.receipts.findIndex(r => r.id === receipt.id)
|
||||
if (existingIdx !== -1) {
|
||||
// Already exists, update it instead (US-029: in-place update, no reordering)
|
||||
Object.assign(this.receipts[existingIdx], receipt)
|
||||
return true
|
||||
}
|
||||
|
||||
// US-043: Always prepend new receipts to maintain upload order.
|
||||
// This preserves the position of completed jobs (which are prepended in unifiedItems).
|
||||
this.receipts.unshift(receipt)
|
||||
return true
|
||||
},
|
||||
|
||||
// ============ Filters & Pagination ============
|
||||
|
||||
setFilters(filters) {
|
||||
@@ -450,6 +589,7 @@ export const useReceiptsStore = defineStore('receipts', {
|
||||
direction: null,
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
processingStatus: null, // US-005
|
||||
}
|
||||
this.pagination.page = 1
|
||||
},
|
||||
|
||||
@@ -1,7 +1,80 @@
|
||||
<template>
|
||||
<div class="receipt-unified-view">
|
||||
<!-- Header -->
|
||||
<div class="view-header">
|
||||
<div class="receipt-unified-view" :class="{ 'mobile-compose-layout': isMobile }">
|
||||
<!-- US-041/US-042: Mobile Top Bar - Gmail Style -->
|
||||
<header v-if="isMobile" class="mobile-compose-top-bar">
|
||||
<div class="top-bar-left">
|
||||
<Button
|
||||
:icon="isViewMode ? 'pi pi-arrow-left' : 'pi pi-times'"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn"
|
||||
@click="goBack"
|
||||
aria-label="Înapoi"
|
||||
/>
|
||||
<span class="top-bar-title">{{ mobileTitle }}</span>
|
||||
</div>
|
||||
<div class="top-bar-right">
|
||||
<!-- Create/Edit mode icons -->
|
||||
<Button
|
||||
v-if="!isViewMode"
|
||||
icon="pi pi-paperclip"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn"
|
||||
@click="triggerFileAttach"
|
||||
aria-label="Atașează fișier"
|
||||
/>
|
||||
<Button
|
||||
v-if="!isViewMode"
|
||||
icon="pi pi-save"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn"
|
||||
:loading="saving"
|
||||
@click="saveReceipt"
|
||||
aria-label="Salvează"
|
||||
/>
|
||||
<!-- US-042: View mode icons - edit, delete, more menu -->
|
||||
<Button
|
||||
v-if="isViewMode && (receipt?.status === 'draft' || receipt?.status === 'rejected')"
|
||||
icon="pi pi-pencil"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn"
|
||||
@click="goToEdit"
|
||||
aria-label="Editează"
|
||||
/>
|
||||
<Button
|
||||
v-if="isViewMode && canDelete"
|
||||
icon="pi pi-trash"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn top-bar-btn-danger"
|
||||
@click="confirmDelete"
|
||||
aria-label="Șterge"
|
||||
/>
|
||||
<Button
|
||||
v-if="isViewMode"
|
||||
icon="pi pi-ellipsis-v"
|
||||
text
|
||||
rounded
|
||||
class="top-bar-btn"
|
||||
@click="toggleMoreMenu"
|
||||
aria-label="Mai multe opțiuni"
|
||||
aria-haspopup="true"
|
||||
aria-controls="more_menu"
|
||||
/>
|
||||
<Menu
|
||||
ref="moreMenuRef"
|
||||
id="more_menu"
|
||||
:model="moreMenuItems"
|
||||
:popup="true"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Desktop Header -->
|
||||
<div v-if="!isMobile" class="view-header">
|
||||
<div class="header-left">
|
||||
<Button
|
||||
icon="pi pi-arrow-left"
|
||||
@@ -183,19 +256,103 @@
|
||||
<Button label="Creaza" icon="pi pi-plus" @click="createLocalSupplier" />
|
||||
</template>
|
||||
</Dialog>
|
||||
|
||||
<!-- US-041: Mobile Bottom Action Bar - Gmail Compose Style -->
|
||||
<footer v-if="isMobile && !isViewMode" class="mobile-form-bottom-bar" :class="{ 'keyboard-visible': keyboardVisible }">
|
||||
<Button
|
||||
label="Salvează Ciornă"
|
||||
icon="pi pi-save"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="bottom-bar-btn"
|
||||
:loading="saving"
|
||||
@click="saveReceipt"
|
||||
/>
|
||||
<Button
|
||||
label="Trimite pentru Validare"
|
||||
icon="pi pi-send"
|
||||
severity="success"
|
||||
class="bottom-bar-btn"
|
||||
:loading="submitting"
|
||||
:disabled="!canSubmit"
|
||||
@click="submitForReviewMobile"
|
||||
/>
|
||||
</footer>
|
||||
|
||||
<!-- US-042: Mobile View Mode Bottom Bar - Contextual buttons by status -->
|
||||
<footer v-if="isMobile && isViewMode && receipt" class="mobile-form-bottom-bar">
|
||||
<!-- DRAFT status: Editează / Trimite -->
|
||||
<Button
|
||||
v-if="receipt.status === 'draft'"
|
||||
label="Editează"
|
||||
icon="pi pi-pencil"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="bottom-bar-btn"
|
||||
@click="goToEdit"
|
||||
/>
|
||||
<Button
|
||||
v-if="receipt.status === 'draft'"
|
||||
label="Trimite"
|
||||
icon="pi pi-send"
|
||||
severity="success"
|
||||
class="bottom-bar-btn"
|
||||
:loading="submitting"
|
||||
@click="submitReceipt"
|
||||
/>
|
||||
<!-- PENDING status: Validează / Respinge -->
|
||||
<Button
|
||||
v-if="receipt.status === 'pending_review'"
|
||||
label="Validează"
|
||||
icon="pi pi-check"
|
||||
severity="success"
|
||||
class="bottom-bar-btn"
|
||||
:loading="approving"
|
||||
@click="approveReceipt"
|
||||
/>
|
||||
<Button
|
||||
v-if="receipt.status === 'pending_review'"
|
||||
label="Respinge"
|
||||
icon="pi pi-times"
|
||||
severity="danger"
|
||||
class="bottom-bar-btn"
|
||||
@click="openRejectDialog"
|
||||
/>
|
||||
<!-- APPROVED status: Anulează -->
|
||||
<Button
|
||||
v-if="receipt.status === 'approved'"
|
||||
label="Anulează Validarea"
|
||||
icon="pi pi-replay"
|
||||
severity="warning"
|
||||
class="bottom-bar-btn"
|
||||
:loading="cancelling"
|
||||
@click="confirmCancelApproval"
|
||||
/>
|
||||
<!-- REJECTED status: Editează -->
|
||||
<Button
|
||||
v-if="receipt.status === 'rejected'"
|
||||
label="Editează"
|
||||
icon="pi pi-pencil"
|
||||
severity="primary"
|
||||
class="bottom-bar-btn"
|
||||
@click="goToEdit"
|
||||
/>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useConfirm } from 'primevue/useconfirm'
|
||||
import Button from 'primevue/button'
|
||||
import Tag from 'primevue/tag'
|
||||
import Message from 'primevue/message'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Textarea from 'primevue/textarea'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Menu from 'primevue/menu'
|
||||
|
||||
import { useReceiptsStore } from '@data-entry/stores/receiptsStore'
|
||||
import { useCompanyStore } from '@data-entry/stores/sharedStores'
|
||||
@@ -212,9 +369,33 @@ import {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const confirm = useConfirm()
|
||||
const store = useReceiptsStore()
|
||||
const companyStore = useCompanyStore()
|
||||
|
||||
// US-041: Mobile detection and keyboard awareness
|
||||
const isMobile = ref(window.innerWidth < 768)
|
||||
const keyboardVisible = ref(false)
|
||||
const initialViewportHeight = ref(window.innerHeight)
|
||||
|
||||
const handleResize = () => {
|
||||
isMobile.value = window.innerWidth < 768
|
||||
|
||||
// US-041: Detect virtual keyboard on mobile
|
||||
// When keyboard opens, viewport height decreases significantly
|
||||
if (isMobile.value) {
|
||||
const heightDiff = initialViewportHeight.value - window.visualViewport?.height || window.innerHeight
|
||||
keyboardVisible.value = heightDiff > 150 // Keyboard typically takes 150px+ of space
|
||||
}
|
||||
}
|
||||
|
||||
const handleVisualViewportResize = () => {
|
||||
if (!isMobile.value) return
|
||||
const currentHeight = window.visualViewport?.height || window.innerHeight
|
||||
const heightDiff = initialViewportHeight.value - currentHeight
|
||||
keyboardVisible.value = heightDiff > 150
|
||||
}
|
||||
|
||||
// Mode detection
|
||||
const isViewMode = computed(() => !!route.params.id && !route.path.endsWith('/edit'))
|
||||
const isEditMode = computed(() => !!route.params.id && route.path.endsWith('/edit'))
|
||||
@@ -231,6 +412,62 @@ const modeIcon = computed(() => {
|
||||
return 'pi pi-plus-circle'
|
||||
})
|
||||
|
||||
// US-041: Mobile title - shorter for top bar
|
||||
const mobileTitle = computed(() => {
|
||||
if (isViewMode.value) return `Bon #${receipt.value?.id || ''}`
|
||||
if (isEditMode.value) return 'Editare Bon'
|
||||
return 'Bon Nou'
|
||||
})
|
||||
|
||||
// US-041: Can submit validation for mobile
|
||||
const canSubmit = computed(() => {
|
||||
return form.value.amount && form.value.amount > 0 && form.value.receipt_date
|
||||
})
|
||||
|
||||
// US-042: Can delete - allow delete for draft/rejected, not for approved/pending
|
||||
const canDelete = computed(() => {
|
||||
if (!receipt.value) return false
|
||||
return ['draft', 'rejected'].includes(receipt.value.status)
|
||||
})
|
||||
|
||||
// US-042: More menu items - contextual based on status
|
||||
const moreMenuItems = computed(() => {
|
||||
const items = []
|
||||
|
||||
if (!receipt.value) return items
|
||||
|
||||
// Always available: view attachments
|
||||
if (existingAttachments.value.length > 0) {
|
||||
items.push({
|
||||
label: 'Vizualizează Atașamente',
|
||||
icon: 'pi pi-images',
|
||||
command: () => scrollToAttachments()
|
||||
})
|
||||
}
|
||||
|
||||
// Share option
|
||||
items.push({
|
||||
label: 'Partajează',
|
||||
icon: 'pi pi-share-alt',
|
||||
command: () => shareReceipt()
|
||||
})
|
||||
|
||||
// Separator before dangerous actions
|
||||
items.push({ separator: true })
|
||||
|
||||
// Delete option (if allowed)
|
||||
if (canDelete.value) {
|
||||
items.push({
|
||||
label: 'Șterge Bonul',
|
||||
icon: 'pi pi-trash',
|
||||
class: 'p-menuitem-danger',
|
||||
command: () => confirmDelete()
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
// Form state
|
||||
const form = ref(getDefaultUnifiedFormState())
|
||||
const receipt = ref(null)
|
||||
@@ -248,8 +485,12 @@ const saving = ref(false)
|
||||
const submitting = ref(false)
|
||||
const approving = ref(false)
|
||||
const rejecting = ref(false)
|
||||
const cancelling = ref(false)
|
||||
const syncingSuppliers = ref(false)
|
||||
|
||||
// US-042: More menu ref
|
||||
const moreMenuRef = ref(null)
|
||||
|
||||
// Supplier state
|
||||
const supplierSource = ref(null)
|
||||
const supplierWarning = ref({ show: false, cui: '', name: '' })
|
||||
@@ -301,6 +542,13 @@ const getStatusSeverity = (status) => {
|
||||
|
||||
// Lifecycle
|
||||
onMounted(async () => {
|
||||
// US-041: Mobile event listeners
|
||||
window.addEventListener('resize', handleResize)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', handleVisualViewportResize)
|
||||
}
|
||||
initialViewportHeight.value = window.innerHeight
|
||||
|
||||
// Load nomenclatures
|
||||
await store.fetchAllNomenclatures()
|
||||
|
||||
@@ -318,6 +566,14 @@ onMounted(async () => {
|
||||
}
|
||||
})
|
||||
|
||||
// US-041: Cleanup event listeners
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', handleVisualViewportResize)
|
||||
}
|
||||
})
|
||||
|
||||
// Load existing receipt
|
||||
const loadReceipt = async () => {
|
||||
try {
|
||||
@@ -768,6 +1024,219 @@ const formatDateTime = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleString('ro-RO')
|
||||
}
|
||||
|
||||
// US-041: Mobile-specific functions
|
||||
const triggerFileAttach = () => {
|
||||
// Trigger the file input in the UnifiedReceiptForm component
|
||||
unifiedFormRef.value?.resetUpload?.()
|
||||
// Open file dialog via hidden input
|
||||
const fileInput = document.querySelector('.unified-receipt-form .hidden-input')
|
||||
if (fileInput) {
|
||||
fileInput.click()
|
||||
}
|
||||
}
|
||||
|
||||
const submitForReviewMobile = async () => {
|
||||
if (!validateForm()) return
|
||||
|
||||
// For create mode, save first then submit
|
||||
if (isCreateMode.value) {
|
||||
saving.value = true
|
||||
submitting.value = true
|
||||
|
||||
try {
|
||||
// Auto-create supplier if warning shown
|
||||
if (supplierWarning.value.show && form.value.cui) {
|
||||
try {
|
||||
await store.createLocalSupplier({
|
||||
name: form.value.partner_name || `Furnizor ${form.value.cui}`,
|
||||
fiscal_code: form.value.cui,
|
||||
address: form.value.vendor_address || null,
|
||||
})
|
||||
supplierWarning.value = { show: false, cui: '', name: '' }
|
||||
supplierSource.value = 'local'
|
||||
} catch (e) {
|
||||
console.warn('Auto-create supplier failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const payload = unifiedFormToApiPayload(form.value)
|
||||
const savedReceipt = await store.createReceipt(payload)
|
||||
|
||||
// Upload files
|
||||
for (const file of selectedFiles.value) {
|
||||
try {
|
||||
await store.uploadAttachment(savedReceipt.id, file)
|
||||
} catch (e) {
|
||||
console.warn(`Upload failed: ${file.name}`, e)
|
||||
}
|
||||
}
|
||||
|
||||
// Now submit for review
|
||||
const result = await store.submitReceipt(savedReceipt.id)
|
||||
if (result.success) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: 'Bonul a fost trimis spre aprobare',
|
||||
life: 3000,
|
||||
})
|
||||
router.push('/data-entry')
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: result.message,
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: error.message || 'Nu s-a putut trimite bonul',
|
||||
life: 5000,
|
||||
})
|
||||
} finally {
|
||||
saving.value = false
|
||||
submitting.value = false
|
||||
}
|
||||
} else {
|
||||
// Edit mode - use existing submitForReview
|
||||
await submitForReview()
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = () => {
|
||||
confirm.require({
|
||||
message: 'Sigur doriți să ștergeți acest bon?',
|
||||
header: 'Confirmare Ștergere',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
try {
|
||||
await store.deleteReceipt(route.params.id)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: 'Bonul a fost șters',
|
||||
life: 2000,
|
||||
})
|
||||
router.push('/data-entry')
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: error.message || 'Nu s-a putut șterge bonul',
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// US-042: More menu toggle
|
||||
const toggleMoreMenu = (event) => {
|
||||
moreMenuRef.value?.toggle(event)
|
||||
}
|
||||
|
||||
// US-042: Helper functions for more menu
|
||||
const scrollToAttachments = () => {
|
||||
const attachmentsSection = document.querySelector('.attachments-section')
|
||||
if (attachmentsSection) {
|
||||
attachmentsSection.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
const shareReceipt = async () => {
|
||||
if (navigator.share && receipt.value) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: `Bon #${receipt.value.id}`,
|
||||
text: `Bon fiscal - ${receipt.value.partner_name || 'Furnizor'} - ${receipt.value.amount} RON`,
|
||||
url: window.location.href
|
||||
})
|
||||
} catch (error) {
|
||||
// User cancelled sharing or share failed
|
||||
if (error.name !== 'AbortError') {
|
||||
// Fallback: copy URL to clipboard
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Link Copiat',
|
||||
detail: 'Link-ul a fost copiat în clipboard',
|
||||
life: 2000,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback for browsers without Web Share API
|
||||
try {
|
||||
await navigator.clipboard.writeText(window.location.href)
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Link Copiat',
|
||||
detail: 'Link-ul a fost copiat în clipboard',
|
||||
life: 2000,
|
||||
})
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: 'Nu s-a putut copia link-ul',
|
||||
life: 3000,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// US-042: Cancel approval functionality
|
||||
const confirmCancelApproval = () => {
|
||||
confirm.require({
|
||||
message: 'Sigur doriți să anulați validarea acestui bon? Bonul va reveni la status "Ciornă".',
|
||||
header: 'Confirmare Anulare Validare',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
acceptClass: 'p-button-warning',
|
||||
acceptLabel: 'Da, Anulează',
|
||||
rejectLabel: 'Nu',
|
||||
accept: async () => {
|
||||
await cancelApproval()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const cancelApproval = async () => {
|
||||
cancelling.value = true
|
||||
try {
|
||||
// Use existing unapproveReceipt from store
|
||||
const result = await store.unapproveReceipt(route.params.id)
|
||||
if (result.success !== false) {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Succes',
|
||||
detail: 'Validarea a fost anulată. Bonul a revenit la status Ciornă.',
|
||||
life: 3000,
|
||||
})
|
||||
await loadReceipt()
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: result.message || 'Nu s-a putut anula validarea',
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Eroare',
|
||||
detail: error.message || 'Nu s-a putut anula validarea',
|
||||
life: 5000,
|
||||
})
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -868,4 +1337,205 @@ const formatDateTime = (dateStr) => {
|
||||
[data-theme="dark"] .view-header {
|
||||
background: var(--surface-card);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* US-041: Mobile Gmail Compose Layout
|
||||
* Similar to Gmail's email compose interface
|
||||
* PRD Mobile Layout Tokens:
|
||||
* - topBarHeight: 56px
|
||||
* - bottomNavHeight: 56px
|
||||
* - touchTargetMin: 48px
|
||||
* ======================================== */
|
||||
|
||||
/* Mobile compose layout container */
|
||||
.receipt-unified-view.mobile-compose-layout {
|
||||
padding: 0;
|
||||
padding-top: 56px; /* Space for fixed top bar */
|
||||
padding-bottom: 80px; /* Space for fixed bottom action bar */
|
||||
max-width: 100%;
|
||||
min-height: 100vh;
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
|
||||
/* Hide desktop header on mobile */
|
||||
.receipt-unified-view.mobile-compose-layout .view-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile Top Bar - Gmail Compose Style */
|
||||
.mobile-compose-top-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
background: var(--surface-card);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--space-xs);
|
||||
z-index: 1000;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.mobile-compose-top-bar .top-bar-left,
|
||||
.mobile-compose-top-bar .top-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.mobile-compose-top-bar .top-bar-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-color);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
.mobile-compose-top-bar .top-bar-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
min-width: 48px; /* Touch target minimum */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-color);
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.mobile-compose-top-bar .top-bar-btn:active {
|
||||
background: var(--surface-hover);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
|
||||
.mobile-compose-top-bar .top-bar-btn-danger {
|
||||
color: var(--red-500);
|
||||
}
|
||||
|
||||
/* Mobile Bottom Action Bar - Gmail Compose Style */
|
||||
.mobile-form-bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: auto;
|
||||
min-height: 56px;
|
||||
background: var(--surface-card);
|
||||
border-top: 1px solid var(--surface-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
z-index: 1000;
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
|
||||
/* Safe area for iOS notch/home indicator */
|
||||
padding-bottom: max(var(--space-sm), env(safe-area-inset-bottom));
|
||||
transition: transform var(--transition-normal), opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
/* Keyboard-aware: move bar above keyboard */
|
||||
.mobile-form-bottom-bar.keyboard-visible {
|
||||
position: absolute;
|
||||
transform: translateY(-100%);
|
||||
}
|
||||
|
||||
/* Buttons in bottom bar - equal width */
|
||||
.mobile-form-bottom-bar .bottom-bar-btn {
|
||||
flex: 1;
|
||||
min-height: 48px; /* Touch target minimum */
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Primary button emphasis */
|
||||
.mobile-form-bottom-bar .bottom-bar-btn.p-button-success {
|
||||
flex: 1.2; /* Slightly wider for primary action */
|
||||
}
|
||||
|
||||
/* Mobile content area adjustments */
|
||||
.receipt-unified-view.mobile-compose-layout .rejection-message {
|
||||
margin: var(--space-sm);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.receipt-unified-view.mobile-compose-layout .validation-banners {
|
||||
margin: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Form inside mobile layout - 100% width */
|
||||
.receipt-unified-view.mobile-compose-layout :deep(.unified-receipt-form) {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.receipt-unified-view.mobile-compose-layout :deep(.form-body) {
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* Dark Mode Support for Mobile Layout
|
||||
* ======================================== */
|
||||
[data-theme="dark"] .mobile-compose-top-bar {
|
||||
background: var(--surface-card);
|
||||
border-bottom-color: var(--surface-border);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mobile-compose-top-bar .top-bar-btn {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mobile-compose-top-bar .top-bar-btn:active {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mobile-compose-top-bar .top-bar-btn-danger {
|
||||
color: var(--red-400);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .mobile-form-bottom-bar {
|
||||
background: var(--surface-card);
|
||||
border-top-color: var(--surface-border);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
* System Preference Dark Mode Support
|
||||
* ======================================== */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) .mobile-compose-top-bar {
|
||||
background: var(--surface-card);
|
||||
border-bottom-color: var(--surface-border);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .mobile-compose-top-bar .top-bar-btn {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .mobile-compose-top-bar .top-bar-btn:active {
|
||||
background: var(--surface-hover);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .mobile-compose-top-bar .top-bar-btn-danger {
|
||||
color: var(--red-400);
|
||||
}
|
||||
|
||||
:root:not([data-theme="light"]) .mobile-form-bottom-bar {
|
||||
background: var(--surface-card);
|
||||
border-top-color: var(--surface-border);
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user