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

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