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:
Claude Agent
2026-01-12 08:33:17 +00:00
parent b4a226409c
commit 7b3541403f
53 changed files with 15810 additions and 196 deletions

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

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

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

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

View 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
}
}
})

View File

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

View File

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