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>