Files
roa2web-service-auto/src/modules/data-entry/views/receipts/ReceiptsListView.vue
Claude Agent cc52ad1850 feat(ui-fixes-phase6): Complete US-701 - Reparare SpeedDial FAB pe Lista Bonuri
Implemented by Ralph autonomous loop.
Iteration: 1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-13 22:29:57 +00:00

5434 lines
164 KiB
Vue

<template>
<div class="receipts-list-view" :class="{ 'mobile-android-layout': isMobile }">
<!-- Drag & Drop Overlay for bulk upload -->
<DragDropOverlay
ref="dragDropOverlayRef"
@upload-complete="onBulkUploadComplete"
/>
<!-- Hidden file input for button-triggered uploads -->
<input
ref="bulkFileInputRef"
type="file"
multiple
accept=".pdf,.png,.jpg,.jpeg,application/pdf,image/png,image/jpeg"
class="hidden-file-input"
@change="onBulkFileInputChange"
/>
<!-- Rejection Modal -->
<Dialog
v-model:visible="rejectDialogVisible"
header="Respingere bon"
:modal="true"
:closable="true"
:style="{ width: '400px' }"
>
<div class="reject-dialog-content">
<p>Motivul respingerii (minim 5 caractere):</p>
<Textarea
v-model="rejectReason"
rows="3"
class="w-full"
placeholder="Introduceți motivul respingerii..."
/>
</div>
<template #footer>
<Button label="Anulează" severity="secondary" @click="rejectDialogVisible = false" />
<Button
label="Respinge"
severity="danger"
icon="pi pi-times"
:disabled="rejectReason.length < 5"
@click="executeReject"
/>
</template>
</Dialog>
<!-- US-103: Mobile Android-Native Top Bar (using shared component) -->
<MobileTopBar
v-if="isMobile"
:title="mobileSelectionMode ? `${selectedReceipts.length} selectate` : 'Bonuri'"
:show-back="mobileSelectionMode"
:show-menu="!mobileSelectionMode"
:selection-active="mobileSelectionMode"
:actions="mobileTopBarActions"
@back-click="exitMobileSelectionMode"
@menu-click="showDrawer = true"
@action-click="handleTopBarAction"
/>
<!-- US-040: Mobile Filter Chips (horizontal scrollable) -->
<div v-if="isMobile && stats && !mobileSelectionMode" class="mobile-filter-chips-container">
<div class="mobile-filter-chips">
<span
class="mobile-filter-chip"
:class="{ active: !filters.status && !filters.processingStatus }"
@click="filterByStatus(null)"
>
Toate
<Badge :value="stats.total?.count || 0" class="chip-badge" />
</span>
<span
class="mobile-filter-chip chip-draft"
:class="{ active: filters.status === 'draft' }"
@click="filterByStatus('draft')"
>
Ciorne
<Badge :value="stats.draft?.count || 0" severity="info" class="chip-badge" />
</span>
<span
class="mobile-filter-chip chip-pending"
:class="{ active: filters.status === 'pending_review' }"
@click="filterByStatus('pending_review')"
>
În așteptare
<Badge :value="stats.pending_review?.count || 0" severity="warning" class="chip-badge" />
</span>
<span
class="mobile-filter-chip chip-approved"
:class="{ active: filters.status === 'approved' }"
@click="filterByStatus('approved')"
>
Validate
<Badge :value="stats.approved?.count || 0" severity="success" class="chip-badge" />
</span>
<span
class="mobile-filter-chip chip-rejected"
:class="{ active: filters.status === 'rejected' }"
@click="filterByStatus('rejected')"
>
Respinse
<Badge :value="stats.rejected?.count || 0" severity="danger" class="chip-badge" />
</span>
<!-- Processing status chips -->
<span
v-if="inProcessingCount > 0"
class="mobile-filter-chip chip-processing"
:class="{ active: filters.processingStatus === 'in_processing' }"
@click="filterByProcessingStatus('in_processing')"
>
<i class="pi pi-spin pi-spinner"></i>
În procesare
<Badge :value="inProcessingCount" class="chip-badge" />
</span>
<span
v-if="failedCount > 0"
class="mobile-filter-chip chip-failed"
:class="{ active: filters.processingStatus === 'failed' }"
@click="filterByProcessingStatus('failed')"
>
<i class="pi pi-exclamation-triangle"></i>
Cu erori
<Badge :value="failedCount" class="chip-badge" />
</span>
</div>
</div>
<!-- Mobile More Menu -->
<Menu ref="moreMenuRef" :model="moreMenuItems" :popup="true" />
<!-- Mobile Drawer Menu (replaces old Sidebar) -->
<MobileDrawerMenu
v-model="showDrawer"
:user="authStore.user"
:companies-store="companyStore"
:period-store="periodStore"
@logout="handleLogout"
/>
<!-- US-503: Filter BottomSheet for mobile -->
<BottomSheet v-model="showFilters">
<h3 class="bottom-sheet-title">Filtre</h3>
<div class="bottom-sheet-filters">
<!-- Status Filter -->
<div class="form-group">
<label class="form-label">Status</label>
<Dropdown
v-model="filters.status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="Toate statusurile"
class="w-full"
/>
</div>
<!-- Search -->
<div class="form-group">
<label class="form-label">Căutare</label>
<InputText
v-model="filters.search"
placeholder="Caută furnizor, CUI, nr. bon..."
class="w-full"
/>
</div>
<!-- Direction Filter -->
<div class="form-group">
<label class="form-label">Tip</label>
<Dropdown
v-model="filters.direction"
:options="directionOptions"
optionLabel="label"
optionValue="value"
placeholder="Toate tipurile"
class="w-full"
/>
</div>
<!-- Date From -->
<div class="form-group">
<label class="form-label">De la data</label>
<Calendar
v-model="filters.dateFrom"
dateFormat="dd.mm.yy"
placeholder="Selectează data"
showIcon
class="w-full"
/>
</div>
<!-- Date To -->
<div class="form-group">
<label class="form-label">Până la data</label>
<Calendar
v-model="filters.dateTo"
dateFormat="dd.mm.yy"
placeholder="Selectează data"
showIcon
class="w-full"
/>
</div>
<!-- Bottom sheet actions -->
<div class="bottom-sheet-actions">
<Button
icon="pi pi-filter-slash"
label="Resetează"
class="p-button-outlined p-button-secondary"
@click="clearFiltersAndClose"
/>
<Button
icon="pi pi-check"
label="Aplică"
@click="applyFiltersAndClose"
/>
</div>
</div>
</BottomSheet>
<!-- Main Card -->
<!-- Page Header (centered, no icon) - Desktop only -->
<div class="page-header" v-if="!isMobile">
<h1 class="page-title">Lista Bonuri Fiscale</h1>
</div>
<!-- Main Card -->
<div class="roa-card">
<!-- Status Filters Row (desktop only - mobile uses dropdown) -->
<div class="status-actions-row" v-if="stats && !isMobile">
<div class="status-chips">
<span
class="status-chip"
:class="{ active: !filters.status && !filters.processingStatus }"
@click="filterByStatus(null)"
>
Toate <Badge :value="stats.total?.count || 0" />
</span>
<span
class="status-chip status-draft"
:class="{ active: filters.status === 'draft' }"
@click="filterByStatus('draft')"
>
Ciorne <Badge :value="stats.draft?.count || 0" severity="info" />
</span>
<span
class="status-chip status-pending"
:class="{ active: filters.status === 'pending_review' }"
@click="filterByStatus('pending_review')"
>
În așteptare <Badge :value="stats.pending_review?.count || 0" severity="warning" />
</span>
<span
class="status-chip status-approved"
:class="{ active: filters.status === 'approved' }"
@click="filterByStatus('approved')"
>
Validate <Badge :value="stats.approved?.count || 0" severity="success" />
</span>
<span
class="status-chip status-rejected"
:class="{ active: filters.status === 'rejected' }"
@click="filterByStatus('rejected')"
>
Respinse <Badge :value="stats.rejected?.count || 0" severity="danger" />
</span>
<!-- US-005: Processing Status Filter Chips -->
<span
v-if="inProcessingCount > 0"
class="status-chip processing-chip-in-progress"
:class="{ active: filters.processingStatus === 'in_processing' }"
@click="filterByProcessingStatus('in_processing')"
>
<i class="pi pi-spin pi-spinner" style="font-size: 0.75rem;"></i>
În procesare <Badge :value="inProcessingCount" />
</span>
<span
v-if="failedCount > 0"
class="status-chip processing-chip-failed"
:class="{ active: filters.processingStatus === 'failed' }"
@click="filterByProcessingStatus('failed')"
>
<i class="pi pi-exclamation-triangle" style="font-size: 0.75rem;"></i>
Cu erori <Badge :value="failedCount" />
</span>
</div>
<!-- Desktop Action Buttons -->
<div v-if="!isMobile" class="desktop-action-buttons">
<Button
label="Upload Bonuri"
icon="pi pi-cloud-upload"
severity="info"
outlined
@click="openBulkFileInput"
/>
<Button
label="Bon Nou"
icon="pi pi-plus"
severity="success"
raised
@click="goToCreate"
/>
</div>
</div>
<!-- Mobile Toolbar (action buttons + Upload + Bon Nou) -->
<div v-if="isMobile" class="mobile-toolbar-container">
<div class="mobile-toolbar-buttons">
<Button
icon="pi pi-filter"
label="Filtre"
:class="{ 'filter-active': hasActiveFilters }"
severity="secondary"
outlined
size="small"
@click="showFilters = !showFilters"
/>
<Button
icon="pi pi-refresh"
severity="secondary"
outlined
size="small"
:loading="loading"
@click="clearFilters"
v-tooltip.bottom="'Resetează filtrele'"
/>
</div>
<div class="mobile-toolbar-buttons mobile-toolbar-actions">
<Button
icon="pi pi-cloud-upload"
label="Upload"
severity="info"
size="small"
outlined
@click="openBulkFileInput"
/>
<Button
label="Bon Nou"
icon="pi pi-plus"
severity="success"
size="small"
raised
@click="goToCreate"
/>
</div>
</div>
<!-- Search and Filters Row (desktop only - mobile uses BottomSheet US-503) -->
<div v-if="!isMobile" class="filters-row">
<InputText
v-model="filters.search"
placeholder="Caută furnizor, CUI, nr. bon..."
class="filter-search"
@keyup.enter="onFilterChange"
>
<template #prefix>
<i class="pi pi-search" />
</template>
</InputText>
<Dropdown
v-model="filters.direction"
:options="directionOptions"
optionLabel="label"
optionValue="value"
placeholder="Tip"
class="filter-direction"
@change="onFilterChange"
/>
<Calendar
v-model="filters.dateFrom"
dateFormat="dd.mm.yy"
placeholder="De la"
showIcon
class="filter-date"
@date-select="onFilterChange"
/>
<Calendar
v-model="filters.dateTo"
dateFormat="dd.mm.yy"
placeholder="Până la"
showIcon
class="filter-date"
@date-select="onFilterChange"
/>
<!-- US-502: Desktop header actions bar (Filter, Reset, Export dropdown) -->
<div v-if="!isMobile" class="header-actions-bar">
<Button
icon="pi pi-filter"
:class="{ 'filter-active': hasActiveFilters }"
severity="secondary"
outlined
size="small"
v-tooltip.bottom="'Filtrează'"
@click="onFilterChange"
/>
<Button
icon="pi pi-filter-slash"
severity="secondary"
outlined
size="small"
v-tooltip.bottom="'Resetează filtrele'"
@click="clearFilters"
/>
<Button
icon="pi pi-download"
severity="secondary"
outlined
size="small"
v-tooltip.bottom="'Export'"
@click="toggleExportMenu"
aria-haspopup="true"
aria-controls="export_menu"
/>
<Menu
ref="exportMenuRef"
id="export_menu"
:model="exportMenuItems"
:popup="true"
/>
</div>
</div>
<!-- Loading -->
<div v-if="loading" class="loading-container">
<ProgressSpinner />
</div>
<!-- Empty State (US-017: checks unified items including jobs) -->
<div v-else-if="!unifiedItems.length" class="empty-state">
<i class="pi pi-inbox"></i>
<h3>Niciun bon găsit</h3>
<p>Creează primul bon fiscal folosind butonul "Bon Nou"</p>
</div>
<!-- Mobile: Compact Cards (US-017: uses unifiedItems to include jobs) -->
<div v-else-if="isMobile" class="receipt-cards">
<!-- US-038: Mobile Selection Header -->
<div v-if="mobileSelectionMode" class="mobile-selection-header">
<div class="selection-header-left">
<Button
icon="pi pi-times"
text
rounded
severity="secondary"
@click="exitMobileSelectionMode"
class="exit-selection-btn"
/>
<span class="selection-count">{{ selectedReceipts.length }} selectate</span>
</div>
<div class="selection-header-right">
<Button
label="Toate"
icon="pi pi-check-square"
text
size="small"
@click="selectAllMobile"
/>
</div>
</div>
<div
v-for="item in unifiedItems"
:key="getItemKey(item)"
class="receipt-card"
:class="[getMobileCardClass(item), { 'card-selected': mobileSelectionMode && isRowSelected(item) }]"
@click="handleMobileCardClick($event, item)"
@touchstart="handleMobileCardTouchStart($event, item)"
@touchmove="handleMobileCardTouchMove"
@touchend="handleMobileCardTouchEnd"
>
<!-- US-017: Job card layout -->
<template v-if="isJobItem(item)">
<!-- Row 1: Filename + Status indicator -->
<div class="card-row-1">
<div class="partner-info">
<span class="partner job-filename-mobile">
<i class="pi pi-file"></i>
{{ item.filename }}
</span>
<!-- US-406: Different subtitle for failed vs processing jobs -->
<span v-if="item.processing_status === 'failed'" class="cui text-error">
Eroare OCR
</span>
<span v-else class="cui text-muted">Se procesează...</span>
</div>
<div class="amount-block">
<span class="amount text-muted">-</span>
</div>
<!-- US-406: Show error icon for failed, spinner for processing -->
<span v-if="item.processing_status === 'failed'" class="job-failed-indicator-mobile">
<i class="pi pi-exclamation-triangle"></i>
</span>
<span v-else class="job-processing-indicator-mobile">
<i class="pi pi-spin pi-spinner"></i>
</span>
</div>
<!-- Row 2: Job info -->
<div class="card-row-2">
<!-- US-406: Show truncated error message for failed jobs -->
<span
v-if="item.processing_status === 'failed' && item.processing_error"
class="text-error job-error-message"
v-tooltip.top="item.processing_error"
>
{{ truncateErrorMessage(item.processing_error) }}
</span>
<span v-else-if="item.processing_status === 'failed'" class="text-error">
Procesare eșuată
</span>
<span v-else class="text-muted">În procesare</span>
</div>
<!-- Row 3: Processing status -->
<div class="card-row-3">
<!-- US-406: Show error badge for failed, processing badge for others -->
<span
v-if="item.processing_status === 'failed'"
class="processing-badge processing-failed"
v-tooltip.top="item.processing_error || 'Eroare la procesare'"
>
<i class="pi pi-exclamation-triangle"></i>
Eroare
</span>
<span v-else class="processing-badge processing-active">
<i class="pi pi-spin pi-spinner"></i>
{{ item.processing_status === 'pending' ? 'În așteptare' : 'Procesare' }}
</span>
<span v-if="item.batch_id" class="batch-badge">
<i class="pi pi-folder"></i> Lot
</span>
</div>
</template>
<!-- Receipt card layout (existing) -->
<template v-else>
<!-- US-038: Selection checkmark indicator -->
<div v-if="mobileSelectionMode && isRowSelected(item)" class="selection-checkmark">
<i class="pi pi-check"></i>
</div>
<!-- Row 1: Partner + CUI + Filename | Amount block + Menu -->
<div class="card-row-1">
<div class="partner-info">
<span class="partner">{{ item.partner_name || '-' }}</span>
<span v-if="item.cui" class="cui">{{ item.cui }}</span>
<!-- US-036: Show filename under partner on mobile -->
<span v-if="getReceiptFilename(item)" class="filename-mobile">
<i class="pi pi-file"></i>
{{ truncateFilename(getReceiptFilename(item)) }}
</span>
</div>
<div class="amount-block">
<span class="amount">{{ formatAmount(item.amount) }}</span>
<span v-if="item.tva_total" class="amount-detail tva">TVA {{ formatAmount(item.tva_total) }}</span>
<span v-if="getPaymentMethodLabel(item)" class="amount-detail payment">{{ getPaymentMethodLabel(item) }}</span>
</div>
<Button
icon="pi pi-ellipsis-v"
text
rounded
size="small"
:disabled="isProcessing(item)"
@click.stop="toggleMenu($event, item)"
/>
</div>
<!-- Row 2: Date + Nr + Doc type + Direction -->
<div class="card-row-2">
<span>{{ formatDateShort(item.receipt_date) }}</span>
<span v-if="item.receipt_number" class="sep"></span>
<span v-if="item.receipt_number" class="receipt-nr">Nr. {{ item.receipt_number }}</span>
<span class="sep"></span>
<span>{{ item.receipt_type === 'bon_fiscal' ? 'Bon' : 'Chit' }}</span>
<span class="sep"></span>
<span :class="item.direction === 'cheltuiala' ? 'direction-out' : 'direction-in'">
{{ item.direction === 'cheltuiala' ? 'Plată' : 'Încasare' }}
</span>
</div>
<!-- Row 3: Status + Processing + Batch + Created by + Attachments -->
<div class="card-row-3">
<span :class="['status-badge-small', getStatusClass(item.status)]">{{ getStatusLabel(item.status) }}</span>
<!-- Processing Status Badge -->
<span
v-if="item.processing_status === 'pending' || item.processing_status === 'processing'"
class="processing-badge processing-active"
>
<i class="pi pi-spin pi-spinner"></i> Procesare
</span>
<span
v-else-if="item.processing_status === 'failed'"
class="processing-badge processing-failed"
v-tooltip.top="item.processing_error"
>
<i class="pi pi-exclamation-triangle"></i> Eroare
</span>
<!-- Batch Badge -->
<span v-if="item.batch_id" class="batch-badge">
<i class="pi pi-folder"></i> Lot
</span>
<span class="created-by">{{ item.created_by }}</span>
<span v-if="item.attachments?.length" class="attachments">
<i class="pi pi-paperclip"></i>{{ item.attachments.length }}
</span>
<!-- US-035: Edit + Retry buttons for failed receipts -->
<template v-if="item.processing_status === 'failed'">
<Button
icon="pi pi-pencil"
severity="secondary"
text
rounded
size="small"
class="edit-btn-mobile"
v-tooltip.top="'Editează manual'"
@click.stop="editReceipt(item.id)"
/>
<Button
icon="pi pi-refresh"
severity="warning"
text
rounded
size="small"
class="retry-btn-mobile"
:loading="retryingReceipts[item.id]"
@click.stop="retryReceipt(item)"
/>
</template>
</div>
</template>
</div>
<!-- Mobile Pagination -->
<div class="mobile-pagination" v-if="pagination.total > pagination.pageSize">
<Button
icon="pi pi-chevron-left"
size="small"
severity="secondary"
:disabled="pagination.page <= 1"
@click="prevPage"
/>
<span class="page-info">{{ pagination.page }} / {{ Math.ceil(pagination.total / pagination.pageSize) }}</span>
<Button
icon="pi pi-chevron-right"
size="small"
severity="secondary"
:disabled="pagination.page >= Math.ceil(pagination.total / pagination.pageSize)"
@click="nextPage"
/>
</div>
<!-- US-103: Mobile Selection Bottom Bar (using shared component) -->
<MobileSelectionFooter
:visible="mobileSelectionMode && selectedReceipts.length > 0"
:actions="mobileSelectionActions"
/>
</div>
<!-- US-608: Mobile FAB SpeedDial with animated options -->
<SpeedDial
v-if="isMobile && !mobileSelectionMode && fabVisible"
:model="speedDialItems"
:radius="80"
direction="up"
type="linear"
showIcon="pi pi-plus"
hideIcon="pi pi-times"
:mask="true"
class="mobile-speed-dial"
:transitionDelay="40"
:pt="{
root: { class: 'mobile-speed-dial-root' },
button: { class: 'mobile-speed-dial-button' },
menu: { class: 'mobile-speed-dial-menu' },
menuitem: { class: 'mobile-speed-dial-item' },
action: { class: 'mobile-speed-dial-action' },
mask: { class: 'mobile-speed-dial-mask' }
}"
/>
<!-- US-103: Mobile Bottom Navigation (using shared component) -->
<!-- US-307: Using default nav items (Dashboard, Bonuri, Facturi, Setări) -->
<MobileBottomNav v-if="isMobile && !mobileSelectionMode" />
<!-- Desktop: Compact Data Table with Batch Grouping (US-002) -->
<!-- US-104: Explicit !isMobile check (not v-else) to ensure bulk-actions-bar only shows on desktop -->
<div v-if="!isMobile && !loading && unifiedItems.length" class="data-table-container">
<!-- Bulk Actions Bar -->
<div v-if="selectedReceipts.length > 0" class="bulk-actions-bar">
<span class="selection-info">
<i class="pi pi-check-square"></i>
{{ selectedReceipts.length }} selectate
</span>
<div class="bulk-buttons">
<Button
v-if="selectedReceipts.every(r => r.status === 'pending_review')"
label="Validează selectate"
icon="pi pi-check"
severity="success"
size="small"
:loading="bulkApproving"
@click="approveSelected"
/>
<Button
label="Șterge"
icon="pi pi-trash"
severity="danger"
size="small"
@click="confirmBulkDelete"
/>
<Button
label="Deselectează"
icon="pi pi-times"
severity="secondary"
size="small"
@click="selectedReceipts = []"
/>
</div>
</div>
<!-- Grouped receipts by batch (US-002) -->
<div class="batch-groups-container">
<div
v-for="group in groupedReceipts"
:key="group.batchId || '__manual__'"
class="batch-group"
>
<!-- Batch Group Header -->
<BatchGroupHeader
:batch-id="group.batchId"
:processing-started-at="group.processingStartedAt"
:items="group.items"
:is-expanded="isGroupExpanded(group.batchId)"
:retrying="retryingBatches[group.batchId]"
@toggle="toggleGroup(group.batchId)"
@retry-all="handleRetryBatchFailed"
@cancel-all="handleCancelBatchAll"
/>
<!-- Group Content (DataTable) - shown when expanded -->
<div
v-show="isGroupExpanded(group.batchId)"
class="batch-group-content"
>
<DataTable
v-model:selection="selectedReceipts"
:value="group.items"
responsiveLayout="scroll"
stripedRows
class="compact-table grouped-table"
:dataKey="(item) => getItemKey(item)"
:rowClass="getRowClass"
>
<!-- US-010: Custom selection column with disabled checkboxes for processing rows -->
<Column headerStyle="width: 3rem">
<template #header>
<Checkbox
:modelValue="isAllSelectableSelected(group.items)"
:disabled="!hasSelectableItems(group.items)"
@update:modelValue="toggleSelectAll(group.items, $event)"
binary
/>
</template>
<template #body="{ data }">
<Checkbox
v-if="!isProcessing(data)"
:modelValue="isRowSelected(data)"
@update:modelValue="toggleRowSelection(data, $event)"
binary
/>
<Checkbox
v-else
:modelValue="false"
:disabled="true"
binary
v-tooltip.top="processingTooltip"
/>
</template>
</Column>
<!-- US-017/US-036: Fișier column - shows filename for jobs and receipts -->
<Column field="filename" header="Fișier" style="min-width: 140px">
<template #body="{ data }">
<template v-if="isJobItem(data)">
<span class="job-filename" v-tooltip.top="data.filename">
<i class="pi pi-file"></i>
{{ truncateFilename(data.filename) }}
</span>
</template>
<template v-else>
<!-- US-036: Show original filename for receipts from attachments -->
<span
v-if="getReceiptFilename(data)"
class="receipt-filename"
v-tooltip.top="getReceiptFilename(data)"
>
<i class="pi pi-file"></i>
{{ truncateFilename(getReceiptFilename(data)) }}
</span>
<span v-else class="text-muted">-</span>
</template>
</template>
</Column>
<Column field="receipt_date" header="Data" style="width: 90px">
<template #body="{ data }">
<template v-if="isJobItem(data)">
<span class="text-muted">-</span>
</template>
<template v-else>
{{ formatDate(data.receipt_date) }}
</template>
</template>
</Column>
<Column field="receipt_number" header="Nr." style="width: 80px">
<template #body="{ data }">
<template v-if="isJobItem(data)">
<span class="text-muted">-</span>
</template>
<template v-else>
{{ data.receipt_number || '-' }}
</template>
</template>
</Column>
<Column field="receipt_type" header="Doc" style="width: 60px">
<template #body="{ data }">
<template v-if="isJobItem(data)">
<span class="text-muted">-</span>
</template>
<template v-else>
<Tag :value="data.receipt_type === 'bon_fiscal' ? 'Bon' : 'Chit'" size="small" />
</template>
</template>
</Column>
<Column field="direction" header="Tip" style="width: 70px">
<template #body="{ data }">
<template v-if="isJobItem(data)">
<span class="text-muted">-</span>
</template>
<template v-else>
<Tag
:value="data.direction === 'cheltuiala' ? 'Plată' : 'Încas.'"
:severity="data.direction === 'cheltuiala' ? 'danger' : 'success'"
size="small"
/>
</template>
</template>
</Column>
<Column field="partner_name" header="Furnizor/Client" style="min-width: 120px">
<template #body="{ data }">
<template v-if="isJobItem(data)">
<span class="text-muted">-</span>
</template>
<template v-else>
{{ data.partner_name || '-' }}
</template>
</template>
</Column>
<Column field="cui" header="CUI" style="width: 100px">
<template #body="{ data }">
<template v-if="isJobItem(data)">
<span class="text-muted">-</span>
</template>
<template v-else>
{{ data.cui || '-' }}
</template>
</template>
</Column>
<Column field="amount" header="Suma" style="width: 90px">
<template #body="{ data }">
<template v-if="isJobItem(data)">
<span class="text-muted">-</span>
</template>
<template v-else>
<strong>{{ formatAmount(data.amount) }}</strong>
</template>
</template>
</Column>
<Column field="tva_total" header="TVA" style="width: 70px">
<template #body="{ data }">
<template v-if="isJobItem(data)">
<span class="text-muted">-</span>
</template>
<template v-else>
<span v-if="data.tva_total">{{ formatAmount(data.tva_total) }}</span>
<span v-else class="text-muted">-</span>
</template>
</template>
</Column>
<Column field="payment_methods" header="Plată" style="width: 100px">
<template #body="{ data }">
<template v-if="isJobItem(data)">
<span class="text-muted">-</span>
</template>
<template v-else>
<span v-if="getPaymentMethodLabel(data)">{{ getPaymentMethodLabel(data) }}</span>
<span v-else class="text-muted">-</span>
</template>
</template>
</Column>
<Column field="created_by" header="Creat de" style="width: 100px">
<template #body="{ data }">
<template v-if="isJobItem(data)">
<span class="text-muted">-</span>
</template>
<template v-else>
{{ data.created_by || '-' }}
</template>
</template>
</Column>
<Column field="created_at" header="Creat la" style="width: 120px">
<template #body="{ data }">
<template v-if="isJobItem(data)">
<span class="text-muted">-</span>
</template>
<template v-else>
{{ formatDateTime(data.created_at) }}
</template>
</template>
</Column>
<Column field="status" header="Status" style="width: 100px">
<template #body="{ data }">
<template v-if="isJobItem(data)">
<span class="text-muted">-</span>
</template>
<template v-else>
<span :class="['status-badge', getStatusClass(data.status)]">
{{ getStatusLabel(data.status) }}
</span>
</template>
</template>
</Column>
<Column field="processing_status" header="Procesare" style="width: 120px">
<template #body="{ data }">
<ProcessingStatusCell
:status="data.processing_status"
:batch-id="data.batch_id"
:processing-error="data.processing_error"
/>
</template>
</Column>
<Column field="attachments" header="Ataș." style="width: 60px">
<template #body="{ data }">
<template v-if="isJobItem(data)">
<span class="text-muted">-</span>
</template>
<template v-else>
<Badge :value="data.attachments?.length || 0" />
</template>
</template>
</Column>
<Column header="Acțiuni" style="width: 120px">
<template #body="{ data }">
<div class="button-group">
<!-- View - only for receipts (not jobs) -->
<Button
v-if="!isJobItem(data)"
icon="pi pi-eye"
severity="info"
text
rounded
size="small"
@click="viewReceipt(data.id)"
/>
<!-- US-006: Retry button for failed receipts -->
<Button
v-if="!isJobItem(data) && data.processing_status === 'failed'"
icon="pi pi-refresh"
severity="warning"
text
rounded
size="small"
:loading="retryingReceipts[data.id]"
v-tooltip.top="'Reîncercă procesarea'"
@click="retryReceipt(data)"
/>
<!-- US-035: Edit button for failed receipts - quick access to manual editing -->
<Button
v-if="!isJobItem(data) && data.processing_status === 'failed'"
icon="pi pi-pencil"
severity="secondary"
text
rounded
size="small"
v-tooltip.top="'Editează manual'"
@click="editReceipt(data.id)"
/>
<!-- US-010: Menu button disabled for processing rows -->
<Button
v-if="!isJobItem(data)"
icon="pi pi-ellipsis-v"
text
rounded
size="small"
:disabled="isProcessing(data)"
@click="toggleMenu($event, data)"
v-tooltip.top="isProcessing(data) ? processingTooltip : null"
/>
<!-- US-017/US-018/US-020: Job row actions -->
<template v-if="isJobItem(data)">
<!-- US-018: Failed job - show error indicator (no Cancel, has Retry) -->
<span
v-if="data.processing_status === 'failed'"
class="job-failed-indicator"
v-tooltip.top="data.processing_error || 'Eroare la procesare'"
>
<i class="pi pi-exclamation-triangle"></i>
</span>
<!-- US-020: Pending/Processing job - show Cancel button + spinner -->
<template v-else>
<Button
icon="pi pi-times"
severity="danger"
text
rounded
size="small"
class="cancel-job-btn"
:loading="cancellingJobs[data.job_id]"
v-tooltip.top="'Anulează procesarea'"
@click="confirmCancelJob(data)"
/>
<span
class="job-processing-indicator"
v-tooltip.top="'Fișierul se procesează...'"
>
<i class="pi pi-spin pi-spinner"></i>
</span>
</template>
</template>
</div>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
<!-- Pagination (separate from grouped tables) -->
<div class="grouped-pagination" v-if="pagination.total > pagination.pageSize">
<Paginator
:rows="pagination.pageSize"
:totalRecords="pagination.total"
:first="(pagination.page - 1) * pagination.pageSize"
@page="onPageChange"
template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink"
/>
</div>
</div>
<!-- Shared Context Menu (for both mobile and desktop) - US-516: MD3 styled -->
<Menu ref="menuRef" :model="menuItems" popup class="receipt-action-menu" />
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, watchEffect } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { useReceiptsStore } from '@data-entry/stores/receiptsStore'
import { useCompanyStore, useAuthStore, useAccountingPeriodStore } from '@data-entry/stores/sharedStores'
import { useBatchProgressStore } from '@data-entry/stores/batchProgressStore'
import Menu from 'primevue/menu'
import Dialog from 'primevue/dialog'
import Textarea from 'primevue/textarea'
import Dropdown from 'primevue/dropdown'
import Checkbox from 'primevue/checkbox'
import MobileDrawerMenu from '@shared/components/mobile/MobileDrawerMenu.vue'
import BottomSheet from '@shared/components/mobile/BottomSheet.vue'
import DragDropOverlay from '@data-entry/components/bulk/DragDropOverlay.vue'
// US-103: Mobile Material Design common components
import MobileTopBar from '@shared/components/mobile/MobileTopBar.vue'
import MobileBottomNav from '@shared/components/mobile/MobileBottomNav.vue'
import MobileSelectionFooter from '@shared/components/mobile/MobileSelectionFooter.vue'
import { exportToExcel, exportToPDF } from '@reports/utils/exportUtils'
import BatchGroupHeader from '@data-entry/components/bulk/BatchGroupHeader.vue'
import ProcessingStatusCell from '@data-entry/components/bulk/ProcessingStatusCell.vue'
import Paginator from 'primevue/paginator'
import SpeedDial from 'primevue/speeddial'
import { sseService } from '@data-entry/services/sseService'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const store = useReceiptsStore()
const companyStore = useCompanyStore()
const periodStore = useAccountingPeriodStore()
const batchProgressStore = useBatchProgressStore()
const authStore = useAuthStore()
// Rejection dialog state
const rejectDialogVisible = ref(false)
const rejectReason = ref('')
const receiptToReject = ref(null)
// Bulk selection state
const selectedReceipts = ref([])
const bulkApproving = ref(false)
// US-038: Mobile long-press selection mode state
const mobileSelectionMode = ref(false)
let longPressTimer = null
let longPressStartPos = { x: 0, y: 0 }
const LONG_PRESS_DURATION = 500 // ms
const LONG_PRESS_MOVE_THRESHOLD = 10 // px - tolerance for finger movement
// Batch grouping state (US-002)
const expandedGroups = ref(new Set())
// US-006: Retry state - tracks which receipts/batches are currently being retried
const retryingReceipts = ref({})
const retryingBatches = ref({})
// US-020: Cancel state - tracks which jobs are currently being cancelled
const cancellingJobs = ref({})
// US-020: Animation state - tracks jobs in fade-out animation before removal
const cancellingJobIds = ref(new Set())
// US-019: Animation state - tracks which items have recently transitioned status
// Items in highlightCompleted will get the green highlight animation
// Items in highlightFailed will get the red highlight animation
const highlightCompleted = ref(new Set())
const highlightFailed = ref(new Set())
// US-019: Animation duration - matches CSS animation (2s)
const HIGHLIGHT_ANIMATION_DURATION_MS = 2000
// Refs for bulk upload via button
const dragDropOverlayRef = ref(null)
const bulkFileInputRef = ref(null)
// US-009: Auto-resume polling state (legacy fallback, see US-032 for SSE)
let autoRefreshInterval = null
const AUTO_REFRESH_INTERVAL_MS = 5000 // 5 seconds between refreshes when processing active
// US-032: SSE connection state - replaces polling for real-time updates
let isSSEConnected = false
// Mobile detection
const isMobile = ref(window.innerWidth < 768)
const showFilters = ref(false)
const handleResize = () => {
isMobile.value = window.innerWidth < 768
}
// US-040: Mobile Android-Native Layout State
const showDrawer = ref(false)
const moreMenuRef = ref(null)
const fabVisible = ref(true)
// US-502: Export Menu ref and items for desktop header actions
const exportMenuRef = ref(null)
// US-608: FAB scroll hide/show state
let lastScrollY = 0
let scrollTimeout = null
// Handle logout from drawer menu
const handleLogout = async () => {
await authStore.logout()
router.push('/login')
}
// US-040: Toggle more menu (3-dot menu)
const toggleMoreMenu = (event) => {
moreMenuRef.value?.toggle(event)
}
// US-040: More menu items
const moreMenuItems = computed(() => [
{
label: 'Actualizează',
icon: 'pi pi-refresh',
command: () => {
store.fetchReceipts()
store.fetchStats()
}
},
{
label: 'Resetează filtre',
icon: 'pi pi-filter-slash',
command: () => clearFilters()
},
{ separator: true },
{
label: 'Setări',
icon: 'pi pi-cog',
command: () => router.push('/settings')
}
])
// US-502: Toggle export menu
const toggleExportMenu = (event) => {
exportMenuRef.value?.toggle(event)
}
// US-502: Export menu items (PDF and XLSX)
const exportMenuItems = computed(() => [
{
label: 'Export PDF',
icon: 'pi pi-file-pdf',
command: () => exportAllReceiptsPDF()
},
{
label: 'Export XLSX',
icon: 'pi pi-file-excel',
command: () => exportAllReceipts()
}
])
// US-608: SpeedDial Items - Bon Nou Manual, Scanare Bon OCR, Upload în Masă
const speedDialItems = [
{
label: 'Bon Nou Manual',
icon: 'pi pi-pencil',
command: () => goToCreate()
},
{
label: 'Scanare Bon OCR',
icon: 'pi pi-camera',
command: () => goToOCRScan()
},
{
label: 'Upload în Masă',
icon: 'pi pi-upload',
command: () => openBulkFileInput()
}
]
// US-608: Navigate to OCR scan page
const goToOCRScan = () => {
console.log('[ReceiptsList] Navigating to OCR scan...')
router.push({ path: '/data-entry/create', query: { mode: 'scan' } }).catch(err => {
console.error('[ReceiptsList] Navigation error:', err)
})
}
// US-103/US-306/US-609: Top bar actions for MobileTopBar component
const mobileTopBarActions = computed(() => {
if (mobileSelectionMode.value) {
// Selection mode - show select all action
return [
{ id: 'select-all', icon: 'pi pi-check-square', label: 'Selectează tot', tooltip: 'Selectează tot' }
]
}
// Normal mode - show filter, reset, export, more menu (US-306, US-609)
return [
{ id: 'filter', icon: 'pi pi-filter', active: hasActiveFilters.value, tooltip: 'Filtre' },
{ id: 'reset', icon: 'pi pi-filter-slash', tooltip: 'Resetează Filtrele' },
{ id: 'export', icon: 'pi pi-download', tooltip: 'Export Excel' },
{ id: 'more', icon: 'pi pi-ellipsis-v', tooltip: 'Mai multe' }
]
})
// US-103/US-306/US-609: Handle top bar action clicks
const handleTopBarAction = (action) => {
switch (action.id) {
case 'select-all':
selectAllMobile()
break
case 'filter':
showFilters.value = !showFilters.value
break
case 'reset':
// US-609: Reset all filters to default values
clearFilters()
break
case 'export':
// US-502: Show export menu (PDF/XLSX dropdown) on mobile too
if (exportMenuRef.value) {
const btn = document.querySelector('.mobile-top-bar .top-bar-btn:nth-child(3)')
if (btn) {
exportMenuRef.value.toggle({ currentTarget: btn })
}
}
break
case 'more':
// The more menu needs to be toggled with the event, but we don't have the event here
// So we need to use a different approach - we'll keep using toggleMoreMenu directly
// by storing a ref to trigger it
if (moreMenuRef.value) {
// Create a synthetic event at the more button position
const btn = document.querySelector('.mobile-top-bar .top-bar-btn:last-child')
if (btn) {
moreMenuRef.value.toggle({ currentTarget: btn })
}
}
break
}
}
// US-307: Removed custom mobileBottomNavItems - using MobileBottomNav defaults
// Upload functionality moved to FAB (US-303)
// US-103/US-113: Selection footer actions for MobileSelectionFooter component
// Bonuri batch actions: Delete + Export
const mobileSelectionActions = computed(() => [
{
label: 'Șterge',
icon: 'pi pi-trash',
severity: 'danger',
handler: () => confirmBulkDelete()
},
{
label: 'Export',
icon: 'pi pi-download',
severity: 'secondary',
handler: () => exportSelectedReceipts()
}
])
// US-040: Handle scroll to show/hide FAB
const handleScroll = () => {
if (!isMobile.value) return
const currentScrollY = window.scrollY
const scrollDelta = currentScrollY - lastScrollY
// Clear any pending timeout
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
// Hide FAB when scrolling down, show when scrolling up
if (scrollDelta > 10) {
// Scrolling down - hide FAB
fabVisible.value = false
} else if (scrollDelta < -10) {
// Scrolling up - show FAB
fabVisible.value = true
}
lastScrollY = currentScrollY
// Show FAB after scroll stops (300ms delay)
scrollTimeout = setTimeout(() => {
fabVisible.value = true
}, 300)
}
onMounted(async () => {
window.addEventListener('resize', handleResize)
// US-040: Add scroll listener for FAB visibility
window.addEventListener('scroll', handleScroll, { passive: true })
await store.fetchStats()
await store.fetchReceipts()
// US-018: Set up callback for when jobs transition to completed/failed
// This triggers a receipt refresh so the row can transition from job to receipt
batchProgressStore.setOnJobsTransitionCallback(handleJobsTransition)
// US-032: Set up SSE callback for real-time status updates
sseService.onStatusChange(handleSSEStatusChange)
// US-033: Set up polling callback for graceful degradation
// When SSE fails, sseService will use this callback for fallback polling
sseService.setPollingCallback(async () => {
console.log('[ReceiptsList] Polling fallback: refreshing data')
await store.fetchReceipts()
await store.fetchStats()
// Check if processing is done
if (inProcessingCount.value === 0) {
console.log('[ReceiptsList] Processing complete during polling, disconnecting')
disconnectSSE()
batchProgressStore.clearAllStoredBatches()
}
})
// US-009/US-032: Check for active processing and start SSE/polling if needed
await checkAndResumeProcessing()
// US-038: Add listener for tap outside to exit selection mode
document.addEventListener('click', handleOutsideTap)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
// US-040: Clean up scroll listener
window.removeEventListener('scroll', handleScroll)
if (scrollTimeout) {
clearTimeout(scrollTimeout)
}
// US-032: Clean up SSE connection (also cleans up polling fallback via US-033)
disconnectSSE()
// US-009: Clean up auto-refresh interval (legacy fallback)
stopAutoRefresh()
// US-018: Clean up transition callback
batchProgressStore.setOnJobsTransitionCallback(null)
// US-032: Unregister SSE callback
sseService.onStatusChange(null)
// US-033: Unregister polling callback
sseService.setPollingCallback(null)
// US-038: Clean up outside tap listener
document.removeEventListener('click', handleOutsideTap)
})
// Watch for company changes and reload data
watch(
() => companyStore.selectedCompany,
async (newCompany, oldCompany) => {
// Skip initial load (handled by onMounted) and same company
if (!oldCompany || !newCompany) return
if (newCompany.id_firma === oldCompany.id_firma) return
console.log('[ReceiptsList] Company changed, reloading data...')
// Reset filters and pagination when company changes
filters.value = {
status: null,
search: '',
direction: null,
dateFrom: null,
dateTo: null,
processingStatus: null, // US-005
}
store.clearFilters()
await store.fetchStats()
await store.fetchReceipts()
}
)
// Mobile context menu
const menuRef = ref()
const selectedReceipt = ref(null)
const toggleMenu = (event, receipt) => {
selectedReceipt.value = receipt
menuRef.value.toggle(event)
}
// US-516: Receipt action menu items with MD3 styling classes
const menuItems = computed(() => {
const r = selectedReceipt.value
if (!r) return []
const items = [
{ label: 'Vizualizează', icon: 'pi pi-eye', command: () => viewReceipt(r.id) }
]
// DRAFT: Edit + Submit + Delete
if (r.status === 'draft') {
items.push({ label: 'Editează', icon: 'pi pi-pencil', command: () => editReceipt(r.id) })
items.push({ label: 'Spre aprobare', icon: 'pi pi-send', command: () => confirmSubmit(r) })
items.push({ separator: true })
items.push({ label: 'Șterge', icon: 'pi pi-trash', class: 'menu-item-danger', command: () => confirmDelete(r) })
}
// PENDING: Approve + Reject
if (r.status === 'pending_review') {
items.push({ separator: true })
items.push({ label: 'Validează', icon: 'pi pi-check', class: 'menu-item-success', command: () => confirmApprove(r) })
items.push({ label: 'Respinge', icon: 'pi pi-times', class: 'menu-item-danger', command: () => openRejectDialog(r) })
}
// APPROVED: Unapprove
if (r.status === 'approved') {
items.push({ separator: true })
items.push({ label: 'Anulare Validare', icon: 'pi pi-undo', class: 'menu-item-warning', command: () => confirmUnapprove(r) })
}
// REJECTED: Edit + Resubmit + Delete
if (r.status === 'rejected') {
items.push({ label: 'Editează', icon: 'pi pi-pencil', command: () => editReceipt(r.id) })
items.push({ label: 'Retrimite', icon: 'pi pi-replay', command: () => confirmResubmit(r) })
items.push({ separator: true })
items.push({ label: 'Șterge', icon: 'pi pi-trash', class: 'menu-item-danger', command: () => confirmDelete(r) })
}
return items
})
const filters = ref({
status: null,
search: '',
direction: null,
dateFrom: null,
dateTo: null,
processingStatus: null, // US-005: processing_status filter
})
// Direction filter options
const directionOptions = [
{ value: null, label: 'Toate' },
{ value: 'cheltuiala', label: 'Plăți' },
{ value: 'incasare', label: 'Încasări' },
]
// Status filter options (for mobile dropdown)
const statusOptions = [
{ value: null, label: 'Toate' },
{ value: 'draft', label: 'Ciorne' },
{ value: 'pending_review', label: 'În așteptare' },
{ value: 'approved', label: 'Validate' },
{ value: 'rejected', label: 'Respinse' },
]
const receipts = computed(() => store.receipts)
const loading = computed(() => store.loading)
const pagination = computed(() => store.pagination)
const stats = computed(() => store.stats)
// US-005: Processing stats from store
const processingStats = computed(() => store.processingStats)
// ============ US-017: Unified Items (Receipts + Pending Jobs) ============
/**
* Check if an item is a pending job (not yet a receipt).
* Jobs have job_id but no receipt id.
*
* @param {Object} item - Receipt or Job object
* @returns {boolean} True if item is a job, false if receipt
*/
const isJobItem = (item) => {
return item._isJob === true
}
/**
* Get a unique key for an item (used as Vue :key).
* Jobs use job_id, receipts use id.
*
* @param {Object} item - Receipt or Job object
* @returns {string|number} Unique key
*/
const getItemKey = (item) => {
return isJobItem(item) ? `job_${item.job_id}` : item.id
}
/**
* Unified list of items: receipts from store + active jobs from batchProgressStore.
* Jobs are converted to a receipt-like structure for consistent rendering.
*
* This computed merges two data sources:
* 1. receipts from receiptsStore (existing receipts from backend)
* 2. jobs from batchProgressStore (pending/processing/failed jobs)
*
* US-018: When a job completes and becomes a receipt:
* - The receipts list is refreshed (via handleJobsTransition callback)
* - The completed job is filtered out because its receipt_id now exists in receipts
* - The row smoothly transitions from job display to receipt display (same position)
*
* Failed jobs remain as job rows with error_message displayed.
* Completed jobs with receipt_id matching an existing receipt are filtered out.
*/
const unifiedItems = computed(() => {
const receiptsList = receipts.value || []
const jobsMap = batchProgressStore.jobs || new Map()
// Get all receipt_ids from receipts to filter out completed jobs
const existingReceiptIds = new Set()
for (const r of receiptsList) {
existingReceiptIds.add(r.id)
}
// Convert jobs to receipt-like objects for unified rendering
// Include: pending, processing, and failed jobs
// Exclude: completed jobs whose receipt_id is already in receipts list
const jobItems = []
for (const [jobId, job] of jobsMap.entries()) {
// US-018: Skip completed jobs that have a receipt_id matching an existing receipt
// This is the "transition" - the job row is replaced by the receipt row
if (job.status === 'completed' && job.receipt_id && existingReceiptIds.has(job.receipt_id)) {
continue
}
// Include pending, processing, and failed jobs
// - pending/processing: active jobs waiting to be processed
// - failed: jobs that failed OCR, show with error message
if (job.status !== 'pending' && job.status !== 'processing' && job.status !== 'failed') {
// Skip 'completed' jobs without matching receipt (edge case - receipt not yet fetched)
// They'll appear once the receipts list is refreshed
continue
}
// Convert job to receipt-like structure
jobItems.push({
// Mark as job for identification
_isJob: true,
job_id: jobId,
// Use job_id as temporary id for grouping/keying
id: `job_${jobId}`,
// Display filename in the "Fișier" column
filename: job.filename,
// Batch info from batchProgressStore
batch_id: batchProgressStore.batchId,
// Processing status maps directly
processing_status: job.status, // 'pending', 'processing', or 'failed'
processing_error: job.error_message,
processing_started_at: null, // Will be set from batch info
// Empty/placeholder values for receipt columns
receipt_date: null,
receipt_number: null,
receipt_type: null,
direction: null,
partner_name: null,
cui: null,
amount: null,
tva_total: null,
payment_methods: null,
status: null, // No workflow status yet
created_by: null,
created_at: null,
attachments: []
})
}
// Combine receipts and jobs
// Jobs should appear at the top since they're most recent uploads
return [...jobItems, ...receiptsList]
})
// US-005: Count for "În procesare" chip (pending + processing)
const inProcessingCount = computed(() => {
return (processingStats.value?.pending_count || 0) + (processingStats.value?.processing_count || 0)
})
// US-005: Count for "Cu erori" chip
const failedCount = computed(() => processingStats.value?.failed_count || 0)
// Check if any filters are active (for mobile filter indicator)
const hasActiveFilters = computed(() => {
return (
filters.value.status !== null ||
filters.value.search !== '' ||
filters.value.direction !== null ||
filters.value.dateFrom !== null ||
filters.value.dateTo !== null ||
filters.value.processingStatus !== null // US-005
)
})
// ============ Batch Grouping (US-002) ============
/**
* Group items (receipts + pending jobs) by batch_id for visual grouping in DataTable.
* - Groups with batch_id are sorted by processing_started_at descending
* - Items without batch_id (manual) go into a special "null" group at the end
*
* US-017: Now uses unifiedItems which includes both receipts and pending jobs
*/
const groupedReceipts = computed(() => {
const groups = new Map()
// Group items by batch_id (includes both receipts and jobs)
for (const item of unifiedItems.value) {
const batchId = item.batch_id || '__manual__' // Use special key for manual items
if (!groups.has(batchId)) {
groups.set(batchId, {
batchId: item.batch_id, // null for manual
processingStartedAt: item.processing_started_at,
items: []
})
}
groups.get(batchId).items.push(item)
}
// Convert to array and sort
const groupsArray = Array.from(groups.values())
// Sort groups: batches with processing_started_at descending, manual group at end
groupsArray.sort((a, b) => {
// Manual group (no batch_id) always at the end
if (!a.batchId && b.batchId) return 1
if (a.batchId && !b.batchId) return -1
if (!a.batchId && !b.batchId) return 0
// Both have batch_id: sort by processing_started_at descending (most recent first)
const dateA = a.processingStartedAt ? new Date(a.processingStartedAt).getTime() : 0
const dateB = b.processingStartedAt ? new Date(b.processingStartedAt).getTime() : 0
return dateB - dateA
})
return groupsArray
})
/**
* Check if a group has active processing (pending or processing status)
*/
const hasActiveProcessing = (group) => {
return group.items.some(
item => item.processing_status === 'pending' || item.processing_status === 'processing'
)
}
// ============ US-010: Row Lock for Processing Receipts ============
/**
* Check if an item (receipt or job) is currently being processed.
* These items should be read-only with disabled actions.
*
* US-017: Job items with pending/processing status are in processing state
* US-018: Failed jobs are NOT in processing state - they can have actions (retry)
*/
const isProcessing = (item) => {
// Job items: check status (failed jobs are not "processing")
if (isJobItem(item)) {
return item.processing_status === 'pending' || item.processing_status === 'processing'
}
// Receipt items check processing_status
return item.processing_status === 'pending' || item.processing_status === 'processing'
}
/**
* Tooltip message for disabled buttons on processing rows
*/
const processingTooltip = 'Fișierul se procesează'
/**
* Get row CSS class based on data.
* Adds .row-processing class for receipts/jobs in pending/processing state.
* Adds .row-failed class for failed jobs AND failed receipts.
* US-019: Adds highlight animation classes for status transitions.
*
* US-017: Also applies to job items (which are always processing)
* US-018: Failed jobs get different styling
* US-019: Items that recently transitioned get highlight animation classes
* US-035: Failed receipts also get row-failed styling for visual identification
*/
const getRowClass = (data) => {
const classes = []
// US-019: Check for highlight animation classes (status transitions)
const itemKey = getItemKey(data)
if (highlightCompleted.value.has(itemKey)) {
classes.push('row-highlight-completed')
} else if (highlightFailed.value.has(itemKey)) {
classes.push('row-highlight-failed')
}
// US-020: Check for cancel fade-out animation
if (isJobItem(data) && cancellingJobIds.value.has(data.job_id)) {
classes.push('row-cancelling')
}
if (isJobItem(data)) {
// US-018: Failed jobs get different styling than processing jobs
if (data.processing_status === 'failed') {
classes.push('row-failed')
} else {
// Pending/processing jobs
classes.push('row-processing')
}
} else {
// Receipt items check processing_status
if (isProcessing(data)) {
classes.push('row-processing')
} else if (data.processing_status === 'failed') {
// US-035: Failed receipts get red highlight for easy identification
classes.push('row-failed')
}
}
return classes.join(' ')
}
/**
* US-019: Get CSS classes for mobile card including highlight animations.
* Similar to getRowClass but for mobile cards.
* US-035: Adds card-failed class for failed receipts.
*
* @param {Object} data - Receipt or Job object
* @returns {Object} Class object for Vue :class binding
*/
const getMobileCardClass = (data) => {
const itemKey = getItemKey(data)
// US-035: Check if this is a failed receipt (not job, and has processing_status='failed')
const isFailedReceipt = !isJobItem(data) && data.processing_status === 'failed'
return {
'card-processing': isProcessing(data),
'card-job': isJobItem(data),
'card-failed': isFailedReceipt,
'card-highlight-completed': highlightCompleted.value.has(itemKey),
'card-highlight-failed': highlightFailed.value.has(itemKey)
}
}
/**
* Check if a row is currently selected
*/
const isRowSelected = (data) => {
return selectedReceipts.value.some(r => r.id === data.id)
}
/**
* Toggle selection for a single row
*/
const toggleRowSelection = (data, selected) => {
if (selected) {
// Add to selection if not already selected
if (!isRowSelected(data)) {
selectedReceipts.value = [...selectedReceipts.value, data]
}
} else {
// Remove from selection
selectedReceipts.value = selectedReceipts.value.filter(r => r.id !== data.id)
}
}
/**
* Check if group has selectable items (non-processing)
*/
const hasSelectableItems = (items) => {
return items.some(item => !isProcessing(item))
}
/**
* Check if all selectable items in group are selected
*/
const isAllSelectableSelected = (items) => {
const selectableItems = items.filter(item => !isProcessing(item))
if (selectableItems.length === 0) return false
return selectableItems.every(item => isRowSelected(item))
}
/**
* Toggle select all for group (only selectable items)
*/
const toggleSelectAll = (items, selected) => {
const selectableItems = items.filter(item => !isProcessing(item))
if (selected) {
// Add all selectable items that aren't already selected
const currentIds = new Set(selectedReceipts.value.map(r => r.id))
const toAdd = selectableItems.filter(item => !currentIds.has(item.id))
selectedReceipts.value = [...selectedReceipts.value, ...toAdd]
} else {
// Remove all items from this group from selection
const groupIds = new Set(items.map(item => item.id))
selectedReceipts.value = selectedReceipts.value.filter(r => !groupIds.has(r.id))
}
}
// ============================================================================
// US-038: Mobile Long-Press Selection Mode
// ============================================================================
/**
* Handle touch start on mobile card - initiates long-press timer
*/
const handleMobileCardTouchStart = (event, item) => {
// Don't start long-press for items in processing
if (isJobItem(item) || isProcessing(item)) return
const touch = event.touches[0]
longPressStartPos = { x: touch.clientX, y: touch.clientY }
longPressTimer = setTimeout(() => {
// Activate selection mode and select this item
mobileSelectionMode.value = true
if (!isRowSelected(item)) {
toggleRowSelection(item, true)
}
// Provide haptic feedback if available
if (navigator.vibrate) {
navigator.vibrate(50)
}
}, LONG_PRESS_DURATION)
}
/**
* Handle touch move - cancel long-press if finger moves too much
*/
const handleMobileCardTouchMove = (event) => {
if (!longPressTimer) return
const touch = event.touches[0]
const deltaX = Math.abs(touch.clientX - longPressStartPos.x)
const deltaY = Math.abs(touch.clientY - longPressStartPos.y)
// Cancel if moved beyond threshold (user is scrolling)
if (deltaX > LONG_PRESS_MOVE_THRESHOLD || deltaY > LONG_PRESS_MOVE_THRESHOLD) {
clearTimeout(longPressTimer)
longPressTimer = null
}
}
/**
* Handle touch end - cancel long-press timer
*/
const handleMobileCardTouchEnd = () => {
if (longPressTimer) {
clearTimeout(longPressTimer)
longPressTimer = null
}
}
/**
* Handle mobile card click/tap
* - In selection mode: toggle selection
* - Normal mode: navigate to receipt
*/
const handleMobileCardClick = (event, item) => {
// Ignore if this is a job item
if (isJobItem(item)) return
if (mobileSelectionMode.value) {
// In selection mode: toggle selection (don't navigate)
event.preventDefault()
event.stopPropagation()
// Don't allow selecting items in processing
if (isProcessing(item)) return
toggleRowSelection(item, !isRowSelected(item))
} else {
// Normal mode: navigate to view receipt
viewReceipt(item.id)
}
}
/**
* Exit mobile selection mode and clear selection
*/
const exitMobileSelectionMode = () => {
mobileSelectionMode.value = false
selectedReceipts.value = []
}
/**
* Handle tap outside cards to exit selection mode
*/
const handleOutsideTap = (event) => {
if (!mobileSelectionMode.value) return
// Check if tap is outside any receipt card or selection UI elements
const target = event.target
const isInsideCard = target.closest('.receipt-card')
const isInsideSelectionHeader = target.closest('.mobile-selection-header')
const isInsideBottomBar = target.closest('.mobile-selection-bottom-bar')
if (!isInsideCard && !isInsideSelectionHeader && !isInsideBottomBar) {
exitMobileSelectionMode()
}
}
/**
* Select all visible items on mobile
*/
const selectAllMobile = () => {
const selectableItems = unifiedItems.value.filter(item => !isJobItem(item) && !isProcessing(item))
const currentIds = new Set(selectedReceipts.value.map(r => r.id))
const toAdd = selectableItems.filter(item => !currentIds.has(item.id))
selectedReceipts.value = [...selectedReceipts.value, ...toAdd]
}
/**
* Toggle group expansion
*/
const toggleGroup = (groupKey) => {
const key = groupKey || '__manual__'
if (expandedGroups.value.has(key)) {
expandedGroups.value.delete(key)
} else {
expandedGroups.value.add(key)
}
// Trigger reactivity
expandedGroups.value = new Set(expandedGroups.value)
}
/**
* Check if group is expanded
*/
const isGroupExpanded = (groupKey) => {
const key = groupKey || '__manual__'
return expandedGroups.value.has(key)
}
/**
* Auto-expand groups with active processing on data change
*/
watchEffect(() => {
const newExpandedGroups = new Set(expandedGroups.value)
for (const group of groupedReceipts.value) {
const key = group.batchId || '__manual__'
if (hasActiveProcessing(group)) {
newExpandedGroups.add(key)
}
}
// Only update if there are changes
if (newExpandedGroups.size !== expandedGroups.value.size) {
expandedGroups.value = newExpandedGroups
}
})
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('ro-RO')
}
/**
* US-017: Truncate filename for display in table column.
* Shows first 20 characters with ellipsis if longer.
*
* @param {string} filename - Original filename
* @returns {string} Truncated filename
*/
const truncateFilename = (filename) => {
if (!filename) return '-'
const maxLength = 20
if (filename.length <= maxLength) return filename
return filename.substring(0, maxLength) + '...'
}
/**
* US-406: Truncate error message for display on mobile card row 2.
* Shows first 40 characters with ellipsis if longer.
* Full error available via tooltip on hover/click.
*
* @param {string} error - Error message from OCR processing
* @returns {string} Truncated error message
*/
const truncateErrorMessage = (error) => {
if (!error) return 'Eroare necunoscută'
const maxLength = 40
if (error.length <= maxLength) return error
return error.substring(0, maxLength) + '...'
}
/**
* US-036: Get original filename from a receipt's attachments.
* For receipts, the original_filename is stored in attachments[0].filename.
* For jobs, the filename is directly on the job object.
*
* @param {Object} item - Receipt or Job object
* @returns {string|null} Original filename or null if not available
*/
const getReceiptFilename = (item) => {
// For job items, filename is directly available
if (isJobItem(item)) {
return item.filename || null
}
// For receipts, get from first attachment
if (item.attachments && item.attachments.length > 0) {
return item.attachments[0].filename || null
}
return null
}
const formatDateShort = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('ro-RO', { day: '2-digit', month: '2-digit' })
}
const formatAmount = (amount) => {
return new Intl.NumberFormat('ro-RO', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount)
}
const getStatusClass = (status) => {
const classes = {
draft: 'status-draft',
pending_review: 'status-pending',
approved: 'status-approved',
rejected: 'status-rejected',
synced: 'status-synced',
}
return classes[status] || ''
}
const getStatusLabel = (status) => {
const labels = {
draft: 'Ciornă',
pending_review: 'În așteptare',
approved: 'Validat',
rejected: 'Respins',
synced: 'Sincronizat',
}
return labels[status] || status
}
const formatDateTime = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('ro-RO', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
const getPaymentMethodLabel = (receipt) => {
if (!receipt.payment_methods || receipt.payment_methods.length === 0) return null
// Combine payment methods: "Card 50 / Num 30"
return receipt.payment_methods.map(pm => {
const method = pm.method === 'CARD' ? 'Card' : 'Num'
return `${method} ${formatAmount(pm.amount)}`
}).join(' / ')
}
const filterByStatus = async (status) => {
if (filters.value.status === status) return // Do nothing if already selected
filters.value.status = status
await onFilterChange()
}
// US-005: Filter by processing status
const filterByProcessingStatus = async (processingStatus) => {
// Toggle off if already selected
if (filters.value.processingStatus === processingStatus) {
filters.value.processingStatus = null
} else {
filters.value.processingStatus = processingStatus
}
await onFilterChange()
}
const onFilterChange = async () => {
selectedReceipts.value = [] // Clear selection on filter change
store.setFilters({
status: filters.value.status,
search: filters.value.search,
direction: filters.value.direction,
dateFrom: filters.value.dateFrom
? filters.value.dateFrom.toISOString().split('T')[0]
: null,
dateTo: filters.value.dateTo
? filters.value.dateTo.toISOString().split('T')[0]
: null,
processingStatus: filters.value.processingStatus, // US-005
})
await store.fetchReceipts()
}
const clearFilters = async () => {
selectedReceipts.value = [] // Clear selection on filter reset
filters.value = {
status: null,
search: '',
direction: null,
dateFrom: null,
dateTo: null,
processingStatus: null, // US-005
}
store.clearFilters()
await store.fetchReceipts()
}
// US-503: Clear filters and close BottomSheet (for mobile)
const clearFiltersAndClose = async () => {
await clearFilters()
showFilters.value = false
}
// US-503: Apply filters and close BottomSheet (for mobile)
const applyFiltersAndClose = async () => {
await onFilterChange()
showFilters.value = false
}
// Refresh data (for mobile toolbar)
const refreshData = async () => {
await store.fetchReceipts()
await store.fetchStats()
toast.add({
severity: 'success',
summary: 'Actualizare reușită',
detail: 'Datele au fost actualizate',
life: 3000
})
}
// ============ Bulk Upload Handler ============
/**
* Open the hidden file input for bulk upload (triggered by button click).
* This is the alternative to drag & drop for mobile devices.
*/
const openBulkFileInput = () => {
if (bulkFileInputRef.value) {
bulkFileInputRef.value.click()
}
}
/**
* Handle file selection from the hidden file input.
* Passes the files to DragDropOverlay's handleFiles for validation and upload.
*
* US-037 FIX: On mobile browsers (Chrome Android/iOS), File objects can be invalidated
* by the browser's SnapshotState mechanism after the event handler completes.
* We MUST:
* 1. Convert FileList to Array immediately (Array.from doesn't access file content)
* 2. Pass to handleFiles which clones files to memory BEFORE accessing properties
* 3. Only reset the input AFTER handleFiles completes (awaited)
*
* See: https://issues.chromium.org/40703873
*/
const onBulkFileInputChange = async (event) => {
const files = event.target?.files
if (!files || files.length === 0) return
// Convert FileList to Array immediately - this is safe, doesn't read file content
const filesArray = Array.from(files)
// Use the DragDropOverlay's handleFiles method for consistent behavior
// CRITICAL: handleFiles clones files to memory before accessing properties
if (dragDropOverlayRef.value?.handleFiles) {
await dragDropOverlayRef.value.handleFiles(filesArray)
}
// Reset the file input so the same files can be selected again
// CRITICAL: Only reset AFTER handleFiles completes to avoid invalidating files
if (bulkFileInputRef.value) {
bulkFileInputRef.value.value = ''
}
}
/**
* Handle bulk upload completion from DragDropOverlay.
* Refreshes the receipts list to show new items.
* US-009: Stores batch ID for auto-resume.
* US-017: Pre-populates jobs for instant display in table.
* US-032: Starts SSE connection instead of polling.
*/
const onBulkUploadComplete = async (uploadResult) => {
console.log('[ReceiptsList] Bulk upload complete:', uploadResult)
// US-009/US-017: Register the batch ID and start polling with initial jobs
if (uploadResult.batchId) {
// US-017: Pass initial jobs with filenames for instant display
// Jobs will appear immediately in the table while polling fetches real status
batchProgressStore.startPolling(uploadResult.batchId, uploadResult.jobs)
}
// Refresh data after a short delay to allow backend processing to start
// The new receipts will appear with processing_status='pending'
setTimeout(async () => {
await store.fetchReceipts()
await store.fetchStats()
// US-032: Connect to SSE for real-time updates instead of polling
connectSSE(uploadResult.batchId)
}, 500)
}
const onPageChange = async (event) => {
selectedReceipts.value = [] // Clear selection on page change
store.setPage(event.page + 1)
await store.fetchReceipts()
}
const prevPage = async () => {
if (pagination.value.page > 1) {
store.setPage(pagination.value.page - 1)
await store.fetchReceipts()
}
}
const nextPage = async () => {
const totalPages = Math.ceil(pagination.value.total / pagination.value.pageSize)
if (pagination.value.page < totalPages) {
store.setPage(pagination.value.page + 1)
await store.fetchReceipts()
}
}
const goToCreate = () => {
console.log('[ReceiptsList] Navigating to create...')
router.push('/data-entry/create').catch(err => {
console.error('[ReceiptsList] Navigation error:', err)
})
}
const viewReceipt = (id) => {
router.push(`/data-entry/${id}`)
}
const editReceipt = (id) => {
router.push(`/data-entry/${id}/edit`)
}
const confirmDelete = (receipt) => {
// US-517: MD3 styled delete dialog with receipt details
const storeName = receipt.store_name || 'Magazin necunoscut'
const amount = receipt.total
? new Intl.NumberFormat('ro-RO', { style: 'currency', currency: 'RON' }).format(receipt.total)
: 'sumă necunoscută'
confirm.require({
message: `Ești sigur că vrei să ștergi bonul de la "${storeName}" în valoare de ${amount}?`,
header: 'Șterge bonul?',
icon: 'pi pi-trash',
acceptClass: 'p-button-danger',
rejectClass: 'p-button-text',
acceptLabel: 'Șterge',
rejectLabel: 'Anulează',
accept: async () => {
try {
await store.deleteReceipt(receipt.id)
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost șters',
life: 3000,
})
await store.fetchReceipts()
await store.fetchStats()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut șterge bonul',
life: 5000,
})
}
},
})
}
// ============ Workflow Actions ============
const confirmSubmit = (receipt) => {
confirm.require({
message: 'Sigur doriți să trimiteți bonul spre aprobare? Statusul va deveni "În așteptare".',
header: 'Confirmare trimitere',
icon: 'pi pi-send',
acceptClass: 'p-button-success',
acceptLabel: 'Trimite',
rejectLabel: 'Anulează',
accept: async () => {
try {
await store.submitReceipt(receipt.id)
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost trimis spre aprobare',
life: 3000,
})
await store.fetchReceipts()
await store.fetchStats()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut trimite bonul',
life: 5000,
})
}
},
})
}
const confirmApprove = (receipt) => {
confirm.require({
message: 'Sigur doriți să validați acest bon?',
header: 'Confirmare validare',
icon: 'pi pi-check',
acceptClass: 'p-button-success',
acceptLabel: 'Validează',
rejectLabel: 'Anulează',
accept: async () => {
try {
await store.approveReceipt(receipt.id)
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost validat',
life: 3000,
})
await store.fetchReceipts()
await store.fetchStats()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut valida bonul',
life: 5000,
})
}
},
})
}
const confirmUnapprove = (receipt) => {
confirm.require({
message: 'Sigur doriți să anulați validarea acestui bon? Bonul va reveni în starea "În așteptare".',
header: 'Confirmare anulare validare',
icon: 'pi pi-undo',
acceptClass: 'p-button-warning',
acceptLabel: 'Anulează Validarea',
rejectLabel: 'Renunță',
accept: async () => {
try {
await store.unapproveReceipt(receipt.id)
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Validarea a fost anulată',
life: 3000,
})
await store.fetchReceipts()
await store.fetchStats()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut anula validarea',
life: 5000,
})
}
},
})
}
const openRejectDialog = (receipt) => {
receiptToReject.value = receipt
rejectReason.value = ''
rejectDialogVisible.value = true
}
const executeReject = async () => {
if (!receiptToReject.value || rejectReason.value.length < 5) return
try {
await store.rejectReceipt(receiptToReject.value.id, rejectReason.value)
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost respins',
life: 3000,
})
rejectDialogVisible.value = false
await store.fetchReceipts()
await store.fetchStats()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut respinge bonul',
life: 5000,
})
}
}
const confirmResubmit = (receipt) => {
confirm.require({
message: 'Sigur doriți să retrimiteți bonul spre aprobare?',
header: 'Confirmare retrimitere',
icon: 'pi pi-replay',
acceptClass: 'p-button-success',
acceptLabel: 'Retrimite',
rejectLabel: 'Anulează',
accept: async () => {
try {
await store.resubmitReceipt(receipt.id)
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost retrimis spre aprobare',
life: 3000,
})
await store.fetchReceipts()
await store.fetchStats()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut retrimite bonul',
life: 5000,
})
}
},
})
}
// ============ Bulk Actions ============
const approveSelected = async () => {
const pendingOnly = selectedReceipts.value.filter(r => r.status === 'pending_review')
if (!pendingOnly.length) return
bulkApproving.value = true
let successCount = 0
let errorCount = 0
for (const receipt of pendingOnly) {
try {
await store.approveReceipt(receipt.id)
successCount++
} catch (error) {
errorCount++
}
}
bulkApproving.value = false
selectedReceipts.value = []
if (successCount > 0) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: `${successCount} bonuri validate`,
life: 3000,
})
}
if (errorCount > 0) {
toast.add({
severity: 'warn',
summary: 'Atenție',
detail: `${errorCount} bonuri nu au putut fi validate`,
life: 5000,
})
}
await store.fetchReceipts()
await store.fetchStats()
}
/**
* US-113: Export selected receipts to Excel.
* Exports receipt data including store name, date, amount, VAT, status etc.
*/
const exportSelectedReceipts = () => {
const receipts = selectedReceipts.value
if (receipts.length === 0) {
toast.add({
severity: 'warn',
summary: 'Atenție',
detail: 'Nu există bonuri selectate pentru export',
life: 3000,
})
return
}
// Map receipt data to export format
const exportData = receipts.map(r => ({
'Magazin': r.store_name || r.partner_name || '-',
'CUI': r.cui || '-',
'Data': r.receipt_date ? formatDate(r.receipt_date) : '-',
'Nr. Bon': r.receipt_number || '-',
'Tip Document': r.receipt_type === 'receipt' ? 'Bon' : r.receipt_type === 'invoice' ? 'Factură' : r.receipt_type || '-',
'Direcție': r.direction === 'expense' ? 'Cheltuială' : r.direction === 'income' ? 'Venit' : r.direction || '-',
'Suma': r.amount || 0,
'TVA': r.tva_total || 0,
'Metodă Plată': getPaymentMethodLabel(r) || '-',
'Status': getStatusLabel(r.status) || '-',
'Creat de': r.created_by || '-',
'Creat la': r.created_at ? formatDate(r.created_at) : '-',
}))
const result = exportToExcel(
exportData,
`bonuri_selectate_${receipts.length}`,
'Bonuri'
)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Export reușit',
detail: `${receipts.length} bonuri exportate cu succes`,
life: 3000,
})
} else {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-a putut exporta lista de bonuri',
life: 5000,
})
}
}
/**
* US-306: Export all currently visible receipts to Excel.
* Exports all receipts from the current filtered list (excludes job items).
*/
const exportAllReceipts = () => {
// Filter out job items - only export actual receipts
const receiptsList = unifiedItems.value.filter(item => !isJobItem(item))
if (receiptsList.length === 0) {
toast.add({
severity: 'warn',
summary: 'Atenție',
detail: 'Nu există bonuri de exportat',
life: 3000,
})
return
}
// Map receipt data to export format
const exportData = receiptsList.map(r => ({
'Magazin': r.store_name || r.partner_name || '-',
'CUI': r.cui || '-',
'Data': r.receipt_date ? formatDate(r.receipt_date) : '-',
'Nr. Bon': r.receipt_number || '-',
'Tip Document': r.receipt_type === 'receipt' ? 'Bon' : r.receipt_type === 'invoice' ? 'Factură' : r.receipt_type || '-',
'Direcție': r.direction === 'expense' ? 'Cheltuială' : r.direction === 'income' ? 'Venit' : r.direction || '-',
'Suma': r.amount || 0,
'TVA': r.tva_total || 0,
'Metodă Plată': getPaymentMethodLabel(r) || '-',
'Status': getStatusLabel(r.status) || '-',
'Creat de': r.created_by || '-',
'Creat la': r.created_at ? formatDate(r.created_at) : '-',
}))
const result = exportToExcel(
exportData,
`bonuri_export_${receiptsList.length}`,
'Bonuri'
)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Export reușit',
detail: `${receiptsList.length} bonuri exportate cu succes`,
life: 3000,
})
} else {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-a putut exporta lista de bonuri',
life: 5000,
})
}
}
/**
* US-502: Export all currently visible receipts to PDF.
* Exports all receipts from the current filtered list (excludes job items).
*/
const exportAllReceiptsPDF = () => {
// Filter out job items - only export actual receipts
const receiptsList = unifiedItems.value.filter(item => !isJobItem(item))
if (receiptsList.length === 0) {
toast.add({
severity: 'warn',
summary: 'Atenție',
detail: 'Nu există bonuri de exportat',
life: 3000,
})
return
}
// Map receipt data to export format for PDF
const exportData = receiptsList.map(r => ({
store_name: r.store_name || r.partner_name || '-',
cui: r.cui || '-',
receipt_date: r.receipt_date ? formatDate(r.receipt_date) : '-',
receipt_number: r.receipt_number || '-',
receipt_type: r.receipt_type === 'receipt' ? 'Bon' : r.receipt_type === 'invoice' ? 'Factură' : r.receipt_type || '-',
direction: r.direction === 'expense' ? 'Cheltuială' : r.direction === 'income' ? 'Venit' : r.direction || '-',
amount: r.amount || 0,
tva_total: r.tva_total || 0,
status: getStatusLabel(r.status) || '-',
}))
// Define columns for PDF with proportional widths
const columns = [
{ field: 'store_name', header: 'Magazin', type: 'text', width: 0.22 },
{ field: 'cui', header: 'CUI', type: 'text', width: 0.10 },
{ field: 'receipt_date', header: 'Data', type: 'text', width: 0.10 },
{ field: 'receipt_number', header: 'Nr. Bon', type: 'text', width: 0.10 },
{ field: 'receipt_type', header: 'Tip', type: 'text', width: 0.08 },
{ field: 'direction', header: 'Direcție', type: 'text', width: 0.10 },
{ field: 'amount', header: 'Suma', type: 'number', width: 0.10 },
{ field: 'tva_total', header: 'TVA', type: 'number', width: 0.10 },
{ field: 'status', header: 'Status', type: 'text', width: 0.10 },
]
const result = exportToPDF(
exportData,
columns,
`bonuri_export_${receiptsList.length}`,
{
companyName: companyStore.selectedCompany?.name || '',
title: 'Lista Bonuri Fiscale',
period: '',
}
)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Export reușit',
detail: `${receiptsList.length} bonuri exportate cu succes (PDF)`,
life: 3000,
})
} else {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-a putut exporta lista de bonuri în format PDF',
life: 5000,
})
}
}
/**
* US-026: Confirmation dialog for bulk delete.
* Uses PrimeVue ConfirmDialog to ask user before deleting multiple receipts.
*/
const confirmBulkDelete = () => {
const count = selectedReceipts.value.length
if (count === 0) return
confirm.require({
message: `Ești sigur că vrei să ștergi ${count} ${count === 1 ? 'bon' : 'bonuri'}?`,
header: 'Confirmare ștergere',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Șterge',
rejectLabel: 'Anulează',
accept: async () => {
// US-027 will implement the actual bulk delete logic
await executeBulkDelete()
},
})
}
/**
* US-027: Execute bulk delete for selected receipts.
* Calls DELETE /receipts/bulk, removes deleted receipts locally,
* shows appropriate toast, and updates stats.
*/
const executeBulkDelete = async () => {
const ids = selectedReceipts.value.map(r => r.id)
const totalCount = ids.length
if (totalCount === 0) return
try {
// Call bulk delete API
const result = await store.bulkDeleteReceipts(ids)
const deletedCount = result.deleted?.length || 0
const failedCount = result.failed?.length || 0
// Remove deleted receipts from local array (instant, no re-fetch)
if (deletedCount > 0) {
store.removeReceiptsLocally(result.deleted)
}
// US-028: Navigate to previous page if current page becomes empty
if (store.receipts.length === 0 && pagination.value.page > 1) {
store.setPage(pagination.value.page - 1)
await store.fetchReceipts()
}
// Clear selection after delete and exit mobile selection mode (US-039)
selectedReceipts.value = []
mobileSelectionMode.value = false
// Update stats via API call
await store.fetchStats()
// Show appropriate toast based on result
if (failedCount === 0 && deletedCount > 0) {
// All successful
toast.add({
severity: 'success',
summary: 'Succes',
detail: `${deletedCount} ${deletedCount === 1 ? 'bon șters' : 'bonuri șterse'}`,
life: 3000,
})
} else if (deletedCount > 0 && failedCount > 0) {
// Partial success
toast.add({
severity: 'warn',
summary: 'Ștergere parțială',
detail: `${deletedCount} din ${totalCount} șterse, ${failedCount} au eșuat`,
life: 5000,
})
} else if (deletedCount === 0 && failedCount > 0) {
// All failed
const firstError = result.failed[0]?.error || 'Eroare necunoscută'
toast.add({
severity: 'error',
summary: 'Eroare',
detail: failedCount === 1 ? firstError : `${failedCount} bonuri nu au putut fi șterse`,
life: 5000,
})
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Eroare la ștergerea bonurilor',
life: 5000,
})
}
}
// ============ US-006: Retry Actions ============
/**
* Retry processing for a single failed receipt.
* Shows loading state on the retry button during the operation.
*/
const retryReceipt = async (receipt) => {
retryingReceipts.value[receipt.id] = true
try {
const result = await store.retryReceipt(receipt.id)
toast.add({
severity: 'success',
summary: 'Succes',
detail: result.message || 'Bon reîncarcat în procesare',
life: 3000,
})
// Refresh the list to show updated status
await store.fetchReceipts()
await store.fetchStats()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Fișierul original nu mai este disponibil',
life: 5000,
})
} finally {
delete retryingReceipts.value[receipt.id]
}
}
/**
* Handler for BatchGroupHeader retry-all event.
* Sets loading state and calls the retry API.
*/
const handleRetryBatchFailed = async (batchId) => {
retryingBatches.value[batchId] = true
try {
const result = await store.retryBatchFailed(batchId)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: result.message,
life: 3000,
})
}
if (result.failed_count > 0) {
toast.add({
severity: 'warn',
summary: 'Atenție',
detail: `${result.failed_count} bonuri nu au putut fi reîncarcate`,
life: 5000,
})
}
// Refresh the list to show updated status
await store.fetchReceipts()
await store.fetchStats()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-au putut reîncărca bonurile',
life: 5000,
})
} finally {
delete retryingBatches.value[batchId]
}
}
// ============ US-020: Cancel Job Actions ============
/**
* Show confirmation dialog for cancelling a job.
* Uses PrimeVue ConfirmDialog to ask user before cancelling.
*
* @param {Object} job - The job item to cancel (from unifiedItems)
*/
const confirmCancelJob = (job) => {
confirm.require({
message: `Anulezi procesarea pentru "${job.filename}"?`,
header: 'Confirmare anulare',
icon: 'pi pi-times-circle',
acceptClass: 'p-button-danger',
acceptLabel: 'Anulează',
rejectLabel: 'Nu',
accept: async () => {
await executeCancelJob(job.job_id, job.filename)
}
})
}
/**
* US-020: Fade-out animation duration for cancelled jobs (matches CSS animation)
*/
const CANCEL_FADEOUT_DURATION_MS = 300
/**
* Helper to wait for a specified number of milliseconds.
* @param {number} ms - Milliseconds to wait
*/
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
/**
* Execute the cancel operation for a job.
* Shows loading state, triggers fade-out animation, then removes the job.
*
* Flow:
* 1. Start loading state on button
* 2. Start fade-out animation on row
* 3. Wait for animation to complete (300ms)
* 4. Call store's cancelJob to actually cancel and remove
* 5. Show success/error toast
*
* @param {string} jobId - The job ID to cancel
* @param {string} filename - The filename for toast messages
*/
const executeCancelJob = async (jobId, filename) => {
cancellingJobs.value[jobId] = true
// US-020: Start fade-out animation immediately
cancellingJobIds.value.add(jobId)
cancellingJobIds.value = new Set(cancellingJobIds.value) // Force reactivity
// Wait for animation to complete before actually removing the row
await sleep(CANCEL_FADEOUT_DURATION_MS)
try {
const result = await batchProgressStore.cancelJob(jobId)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: `Procesarea pentru "${filename}" a fost anulată`,
life: 3000,
})
} else {
// On failure, remove the animation class (row stays visible)
cancellingJobIds.value.delete(jobId)
cancellingJobIds.value = new Set(cancellingJobIds.value)
toast.add({
severity: 'error',
summary: 'Eroare',
detail: result.message || 'Nu s-a putut anula procesarea',
life: 5000,
})
}
} catch (error) {
// On error, remove the animation class (row stays visible)
cancellingJobIds.value.delete(jobId)
cancellingJobIds.value = new Set(cancellingJobIds.value)
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut anula procesarea',
life: 5000,
})
} finally {
delete cancellingJobs.value[jobId]
// Clean up animation state (already removed from store on success)
cancellingJobIds.value.delete(jobId)
cancellingJobIds.value = new Set(cancellingJobIds.value)
}
}
// ============ US-021: Cancel All Jobs in Batch ============
/**
* Handler for BatchGroupHeader cancel-all event.
* Shows confirmation dialog with info about affected files and completed receipts.
*
* @param {Object} payload - Event payload from BatchGroupHeader
* @param {string} payload.batchId - The batch ID to cancel
* @param {number} payload.pendingProcessingCount - Count of jobs that will be cancelled
* @param {number} payload.completedCount - Count of completed receipts that will remain
*/
const handleCancelBatchAll = (payload) => {
const { batchId, pendingProcessingCount, completedCount } = payload
// Build confirmation message
let message = `Anulezi procesarea pentru ${pendingProcessingCount} ${pendingProcessingCount === 1 ? 'fișier' : 'fișiere'}?`
// If there are completed receipts, mention they will remain
if (completedCount > 0) {
message += `\n\nBonurile deja procesate (${completedCount}) vor rămâne în sistem.`
}
confirm.require({
message,
header: 'Confirmare anulare batch',
icon: 'pi pi-times-circle',
acceptClass: 'p-button-danger',
acceptLabel: 'Anulează tot',
rejectLabel: 'Nu',
accept: async () => {
await executeCancelBatch(batchId, pendingProcessingCount)
}
})
}
/**
* Execute batch cancellation.
* Calls batchProgressStore.cancelBatch and shows toast notification.
*
* @param {string} batchId - The batch ID to cancel
* @param {number} expectedCount - Expected number of cancelled jobs (for toast message)
*/
const executeCancelBatch = async (batchId, expectedCount) => {
try {
const result = await batchProgressStore.cancelBatch(batchId)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: result.message || `${result.cancelledCount} fișiere anulate`,
life: 3000,
})
} else {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: result.message || 'Nu s-a putut anula batch-ul',
life: 5000,
})
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut anula batch-ul',
life: 5000,
})
}
}
// ============ US-018/US-019: Job Transition Handler with Animations ============
/**
* Handle job status transitions from batchProgressStore polling.
* When jobs complete, refresh receipts to fetch the newly created receipt data.
* When jobs fail, they remain as job rows with error messages displayed.
*
* US-019: Also triggers highlight animations for visual feedback.
*
* @param {number[]} completedReceiptIds - Receipt IDs from newly completed jobs
* @param {string[]} failedJobIds - Job IDs of newly failed jobs
*/
const handleJobsTransition = async (completedReceiptIds, failedJobIds) => {
console.log('[ReceiptsList] Job transitions detected:', {
completedReceiptIds,
failedJobIds
})
// US-019: Add completed receipts to highlight set for green animation
// Use the receipt ID (not job_id) since completed jobs become receipts
for (const receiptId of completedReceiptIds) {
highlightCompleted.value.add(receiptId)
// Remove from highlight set after animation completes
setTimeout(() => {
highlightCompleted.value.delete(receiptId)
// Force reactivity update
highlightCompleted.value = new Set(highlightCompleted.value)
}, HIGHLIGHT_ANIMATION_DURATION_MS)
}
// Force reactivity update for initial additions
if (completedReceiptIds.length > 0) {
highlightCompleted.value = new Set(highlightCompleted.value)
}
// US-019: Add failed job IDs to highlight set for red animation
// Use job_id format since failed jobs remain as job rows
for (const jobId of failedJobIds) {
const jobKey = `job_${jobId}`
highlightFailed.value.add(jobKey)
// Remove from highlight set after animation completes
setTimeout(() => {
highlightFailed.value.delete(jobKey)
// Force reactivity update
highlightFailed.value = new Set(highlightFailed.value)
}, HIGHLIGHT_ANIMATION_DURATION_MS)
}
// Force reactivity update for initial additions
if (failedJobIds.length > 0) {
highlightFailed.value = new Set(highlightFailed.value)
}
// US-043: Instead of refreshing all receipts (which reorders by created_at),
// fetch each completed receipt individually and insert it in place.
// This maintains the original upload order within the batch.
if (completedReceiptIds.length > 0) {
console.log('[ReceiptsList] Fetching completed receipts individually to preserve order')
// Fetch each receipt and insert at the beginning of the receipts list
// This keeps them in the position where the jobs were (prepended to receipts)
for (const receiptId of completedReceiptIds) {
try {
const receipt = await store.fetchReceiptById(receiptId)
if (receipt) {
// Insert at the beginning of the receipts list (where jobs are prepended)
// This preserves the visual position - the receipt replaces its job
store.insertReceiptInPlace(receipt)
console.log(`[ReceiptsList] Receipt ${receiptId} inserted in place`)
}
} catch (err) {
console.error(`[ReceiptsList] Failed to fetch receipt ${receiptId}:`, err)
}
}
// Update stats separately (doesn't affect list ordering)
await store.fetchStats()
}
// Failed jobs remain in the jobs Map and will be displayed as job rows
// with error messages - no additional action needed
}
// ============ US-009/US-032: Auto-Resume Processing Functions ============
/**
* Check for active processing batches and resume SSE/polling if needed.
* Called on component mount to restore state after page refresh.
*
* US-023: Now also restores active jobs to batchProgressStore so they appear in table.
* US-032: Uses SSE instead of polling for real-time updates.
*/
const checkAndResumeProcessing = async () => {
// Check localStorage for stored batch IDs
const storedBatchIds = batchProgressStore.getStoredBatchIds()
if (storedBatchIds.length === 0) {
console.log('[ReceiptsList] No stored batch IDs found')
// US-032: Still check if there are processing receipts from DB
// (could be from another session or direct API upload)
if (inProcessingCount.value > 0) {
console.log('[ReceiptsList] Found processing receipts in DB, connecting SSE')
connectSSE()
}
return
}
console.log('[ReceiptsList] Found stored batch IDs:', storedBatchIds)
// US-023: Restore jobs from each stored batch
// This fetches current status from API and populates batchProgressStore.jobs
let totalRestoredJobs = 0
for (const storedBatchId of storedBatchIds) {
const result = await batchProgressStore.restoreJobsFromBatch(storedBatchId)
if (result.hasActiveJobs) {
totalRestoredJobs += result.jobCount
}
}
// If we restored active jobs, show notification and connect to SSE
if (totalRestoredJobs > 0) {
console.log(`[ReceiptsList] Restored ${totalRestoredJobs} active jobs from stored batches`)
// US-032: Connect to SSE for real-time updates instead of polling
connectSSE()
// Show toast notification for resumed processing
toast.add({
severity: 'info',
summary: 'Procesare în curs detectată',
detail: `${totalRestoredJobs} ${totalRestoredJobs === 1 ? 'fișier' : 'fișiere'} în procesare`,
life: 4000,
})
} else {
// No active jobs found in stored batches
// This means all jobs completed/failed between page load and now
console.log('[ReceiptsList] No active jobs found in stored batches')
// Check if there are still active processing items in receipts (from DB)
const hasActiveProcessing = inProcessingCount.value > 0
if (hasActiveProcessing) {
// US-032: Connect to SSE for real-time updates instead of polling
connectSSE()
toast.add({
severity: 'info',
summary: 'Procesare în curs detectată',
detail: 'Se actualizează statusul automat...',
life: 4000,
})
}
}
}
/**
* Start auto-refresh interval to poll for processing updates.
* Refreshes receipts list every 5 seconds while processing is active.
*
* US-032: This is now a fallback mechanism - SSE is the primary method.
* Only used when SSE connection fails (see US-033 for graceful degradation).
*/
const startAutoRefresh = () => {
// Don't start if already running
if (autoRefreshInterval) {
console.log('[ReceiptsList] Auto-refresh already running')
return
}
console.log('[ReceiptsList] Starting auto-refresh polling')
autoRefreshInterval = setInterval(async () => {
// Refresh receipts to get latest processing status
await store.fetchReceipts()
await store.fetchStats()
// Check if processing is still active
if (inProcessingCount.value === 0) {
console.log('[ReceiptsList] Processing complete, stopping auto-refresh')
stopAutoRefresh()
// Clean up all stored batches since processing is done
batchProgressStore.clearAllStoredBatches()
// Show completion toast
toast.add({
severity: 'success',
summary: 'Procesare completă',
detail: 'Toate bonurile au fost procesate',
life: 4000,
})
}
}, AUTO_REFRESH_INTERVAL_MS)
}
/**
* Stop the auto-refresh interval.
* Called on unmount or when processing completes.
*/
const stopAutoRefresh = () => {
if (autoRefreshInterval) {
clearInterval(autoRefreshInterval)
autoRefreshInterval = null
console.log('[ReceiptsList] Auto-refresh stopped')
}
}
// ============ US-032: SSE Integration Functions ============
/**
* Connect to SSE for real-time status updates.
* Called when there are receipts in processing state.
* Replaces polling for more efficient real-time updates.
*
* @param {string|null} batchId - Optional batch ID to filter events
*/
const connectSSE = (batchId = null) => {
if (isSSEConnected) {
console.log('[ReceiptsList] SSE already connected')
return
}
console.log('[ReceiptsList] Connecting to SSE for real-time updates')
sseService.connect(batchId)
isSSEConnected = true
// Stop polling if it was running - SSE replaces it
stopAutoRefresh()
}
/**
* Disconnect from SSE endpoint.
* Called when no more receipts are in processing state,
* when component unmounts, or on error.
*/
const disconnectSSE = () => {
if (!isSSEConnected) {
return
}
console.log('[ReceiptsList] Disconnecting from SSE')
sseService.disconnect()
isSSEConnected = false
}
/**
* Handle SSE status change events.
* Called by sseService when a receipt status update is received.
* Updates the receipt in place without re-fetching the entire list.
*
* US-032: This is the core of SSE integration - efficient single-row updates.
*
* @param {Object} data - Status change event data from SSE
* @param {number} data.receipt_id - Receipt ID that changed
* @param {string} data.status - Workflow status (DRAFT, PENDING_REVIEW, etc.)
* @param {string|null} data.processing_status - Processing status (pending, processing, completed, failed)
* @param {string|null} data.batch_id - Batch ID this receipt belongs to
*/
const handleSSEStatusChange = async (data) => {
console.log('[ReceiptsList] SSE status change received:', data)
// Build updates object from SSE event data
const updates = {}
if (data.status !== undefined) {
updates.status = data.status
}
if (data.processing_status !== undefined) {
updates.processing_status = data.processing_status
}
// US-029: Update the receipt in place for efficient single-row re-render
const wasUpdated = store.updateReceiptInPlace(data.receipt_id, updates)
if (wasUpdated) {
console.log(`[ReceiptsList] Receipt ${data.receipt_id} updated in place via SSE`)
// US-019: Trigger highlight animation for status transitions
if (data.processing_status === 'completed') {
highlightCompleted.value.add(data.receipt_id)
// Force reactivity update
highlightCompleted.value = new Set(highlightCompleted.value)
// Remove highlight after animation
setTimeout(() => {
highlightCompleted.value.delete(data.receipt_id)
highlightCompleted.value = new Set(highlightCompleted.value)
}, HIGHLIGHT_ANIMATION_DURATION_MS)
} else if (data.processing_status === 'failed') {
highlightFailed.value.add(data.receipt_id)
// Force reactivity update
highlightFailed.value = new Set(highlightFailed.value)
// Remove highlight after animation
setTimeout(() => {
highlightFailed.value.delete(data.receipt_id)
highlightFailed.value = new Set(highlightFailed.value)
}, HIGHLIGHT_ANIMATION_DURATION_MS)
}
} else {
// US-034: Receipt not found in current list
// Instead of refreshing the whole list, check if it belongs to an active batch
// and fetch/insert it individually to maintain list stability
console.log(`[ReceiptsList] Receipt ${data.receipt_id} not found in current list`)
// Check if this receipt belongs to an active batch we're tracking
const storedBatchIds = batchProgressStore.getStoredBatchIds()
const belongsToActiveBatch = data.batch_id && storedBatchIds.includes(data.batch_id)
if (belongsToActiveBatch) {
// Fetch the individual receipt and insert it locally
console.log(`[ReceiptsList] Receipt ${data.receipt_id} belongs to active batch ${data.batch_id}, fetching individually...`)
try {
const receipt = await store.fetchReceiptById(data.receipt_id)
if (receipt) {
store.insertReceiptInPlace(receipt)
console.log(`[ReceiptsList] Receipt ${data.receipt_id} inserted into list`)
// Apply highlight animation for newly inserted receipt
if (data.processing_status === 'completed') {
highlightCompleted.value.add(data.receipt_id)
highlightCompleted.value = new Set(highlightCompleted.value)
setTimeout(() => {
highlightCompleted.value.delete(data.receipt_id)
highlightCompleted.value = new Set(highlightCompleted.value)
}, HIGHLIGHT_ANIMATION_DURATION_MS)
} else if (data.processing_status === 'failed') {
highlightFailed.value.add(data.receipt_id)
highlightFailed.value = new Set(highlightFailed.value)
setTimeout(() => {
highlightFailed.value.delete(data.receipt_id)
highlightFailed.value = new Set(highlightFailed.value)
}, HIGHLIGHT_ANIMATION_DURATION_MS)
}
}
} catch (err) {
console.error(`[ReceiptsList] Failed to fetch receipt ${data.receipt_id}:`, err)
}
} else {
// Receipt doesn't belong to an active batch - just update stats
// User can manually refresh to see the receipt
console.log(`[ReceiptsList] Receipt ${data.receipt_id} not in active batch, skipping list refresh`)
}
// Only update stats, not the full list
await store.fetchStats()
}
// Check if all processing is complete - disconnect SSE if so
await checkAndDisconnectSSEIfDone()
}
/**
* Check if all processing is complete and disconnect SSE if so.
* Also refreshes stats to update the processing counts.
*/
const checkAndDisconnectSSEIfDone = async () => {
// Refresh stats to get accurate processing counts
await store.fetchStats()
// Check if any receipts still processing
if (inProcessingCount.value === 0) {
console.log('[ReceiptsList] All processing complete, disconnecting SSE')
disconnectSSE()
// Clean up stored batches
batchProgressStore.clearAllStoredBatches()
// Show completion toast
toast.add({
severity: 'success',
summary: 'Procesare completă',
detail: 'Toate bonurile au fost procesate',
life: 4000,
})
}
}
/**
* Clean up batch IDs from localStorage for batches that have completed.
* Checks each stored batch against current receipts to find completed ones.
*
* @param {string[]} storedBatchIds - Array of stored batch IDs to check
*/
const cleanupCompletedBatches = (storedBatchIds) => {
// Get unique batch IDs from current receipts that still have active processing
const activeBatchIds = new Set()
for (const receipt of receipts.value) {
if (
receipt.batch_id &&
(receipt.processing_status === 'pending' || receipt.processing_status === 'processing')
) {
activeBatchIds.add(receipt.batch_id)
}
}
// Remove stored batches that are no longer active
for (const storedId of storedBatchIds) {
if (!activeBatchIds.has(storedId)) {
console.log(`[ReceiptsList] Batch ${storedId} is complete, removing from storage`)
batchProgressStore.clearStoredBatch(storedId)
}
}
}
</script>
<style scoped>
/* Status Filters + Action Button Row */
/* US-610: Reduced margin-bottom to var(--space-sm) and use design tokens */
.status-actions-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
flex-wrap: wrap;
margin-bottom: var(--space-sm);
padding-bottom: var(--space-xs);
}
.status-chips {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
flex: 1;
}
.status-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
color: var(--text-color-secondary);
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
border: 2px solid transparent;
}
.status-chip:hover {
background: var(--surface-hover);
}
.status-chip.active {
background: var(--surface-200);
color: var(--text-color);
font-weight: 600;
position: relative;
}
.status-chip.active::after {
content: '';
position: absolute;
bottom: -4px;
left: 0;
right: 0;
height: 3px;
background: var(--text-color);
border-radius: 2px;
}
/* Color accents for active status */
.status-chip.status-draft.active { background: var(--blue-100); color: var(--blue-700); }
.status-chip.status-draft.active::after { background: var(--blue-700); }
.status-chip.status-pending.active { background: var(--yellow-100); color: var(--yellow-700); }
.status-chip.status-pending.active::after { background: var(--yellow-600); }
.status-chip.status-approved.active { background: var(--green-100); color: var(--green-700); }
.status-chip.status-approved.active::after { background: var(--green-600); }
.status-chip.status-rejected.active { background: var(--red-100); color: var(--red-700); }
.status-chip.status-rejected.active::after { background: var(--red-600); }
/* Search and Filters Row */
/* US-610: Reduced margin-bottom to var(--space-sm) and use design tokens */
.filters-row {
display: flex;
gap: var(--space-sm);
align-items: center;
flex-wrap: wrap;
margin-bottom: var(--space-sm);
padding-bottom: var(--space-sm);
border-bottom: 1px solid var(--surface-border);
}
.filter-search {
flex: 1 1 200px;
min-width: 200px;
}
.filter-date {
width: 130px;
}
.filter-direction {
width: 110px;
}
.filter-status {
width: 140px;
}
.filter-actions {
display: flex;
gap: 0.5rem;
margin-left: auto;
}
/* US-502: Header actions bar for unified Filter/Reset/Export buttons */
.header-actions-bar {
display: flex;
gap: var(--space-xs);
margin-left: auto;
align-items: center;
}
.header-actions-bar .p-button {
min-width: auto;
padding: var(--space-sm);
}
.header-actions-bar .filter-active {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--surface-ground);
}
.header-actions-bar .filter-active:hover {
background: var(--color-primary-dark);
border-color: var(--color-primary-dark);
}
/* Compact DataTable - styles moved to vendor/primevue-overrides.css */
/* Action buttons - always on same line */
.compact-table .button-group {
display: flex;
flex-wrap: nowrap;
gap: 0.25rem;
}
/* Mobile Cards */
.receipt-cards {
display: flex;
flex-direction: column;
gap: 0.625rem;
}
.receipt-card {
background: var(--surface-card);
border-radius: var(--radius-md);
padding: var(--space-sm) var(--space-md);
box-shadow: var(--shadow-sm);
cursor: pointer;
border: 1px solid var(--surface-border);
transition: all var(--transition-fast);
position: relative; /* US-038: For absolute positioned checkmark */
}
.receipt-card:active {
background: var(--surface-ground);
transform: scale(0.99);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
}
/* US-038: Mobile Selection Mode Styles */
.mobile-selection-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-sm) var(--space-md);
background: var(--blue-50);
border-radius: var(--radius-md);
margin-bottom: var(--space-sm);
border: 1px solid var(--blue-200);
}
.selection-header-left {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.selection-header-left .exit-selection-btn {
color: var(--blue-700);
}
.selection-header-left .selection-count {
font-weight: var(--font-semibold);
color: var(--blue-700);
font-size: var(--text-sm);
}
.selection-header-right {
display: flex;
align-items: center;
gap: var(--space-xs);
}
/* Selected card state */
.receipt-card.card-selected {
background: var(--blue-50);
border-color: var(--blue-500);
}
.receipt-card.card-selected:active {
background: var(--blue-100);
}
/* Selection checkmark indicator */
.selection-checkmark {
position: absolute;
top: var(--space-sm);
right: var(--space-sm);
width: 24px;
height: 24px;
background: var(--blue-500);
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: var(--text-xs);
z-index: 1;
}
/* Dark mode support for selection */
[data-theme="dark"] .mobile-selection-header {
background: var(--blue-900);
border-color: var(--blue-700);
}
[data-theme="dark"] .selection-header-left .exit-selection-btn,
[data-theme="dark"] .selection-header-left .selection-count {
color: var(--blue-300);
}
[data-theme="dark"] .receipt-card.card-selected {
background: var(--blue-900);
border-color: var(--blue-400);
}
[data-theme="dark"] .receipt-card.card-selected:active {
background: var(--blue-800);
}
[data-theme="dark"] .selection-checkmark {
background: var(--blue-400);
}
/* Auto dark mode (system preference) */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .mobile-selection-header {
background: var(--blue-900);
border-color: var(--blue-700);
}
:root:not([data-theme]) .selection-header-left .exit-selection-btn,
:root:not([data-theme]) .selection-header-left .selection-count {
color: var(--blue-300);
}
:root:not([data-theme]) .receipt-card.card-selected {
background: var(--blue-900);
border-color: var(--blue-400);
}
:root:not([data-theme]) .receipt-card.card-selected:active {
background: var(--blue-800);
}
:root:not([data-theme]) .selection-checkmark {
background: var(--blue-400);
}
}
/* US-103: Mobile Selection Bottom Bar styles now in MobileSelectionFooter.vue */
.card-row-1 {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.card-row-1 .partner-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.card-row-1 .partner {
font-weight: 600;
font-size: 0.875rem;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.card-row-1 .cui {
font-size: 0.7rem;
color: var(--text-color-secondary);
}
.card-row-1 .amount-block {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.125rem;
}
.card-row-1 .amount {
font-weight: 700;
font-size: 0.875rem;
color: var(--text-color);
}
.card-row-1 .amount-detail {
font-size: 0.875rem;
font-weight: 400;
color: var(--text-color-secondary);
}
.card-row-1 .amount-detail.tva {
color: var(--green-600);
}
.card-row-1 .amount-detail.payment {
color: var(--purple-600);
}
.card-row-2 {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
color: var(--text-color-secondary);
flex-wrap: wrap;
}
.card-row-2 .sep {
color: var(--surface-border);
}
.card-row-2 .receipt-nr {
font-weight: 500;
}
.card-row-2 .direction-out {
color: var(--red-600);
font-weight: 500;
}
.card-row-2 .direction-in {
color: var(--green-600);
font-weight: 500;
}
.card-row-3 {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.7rem;
color: var(--text-color-secondary);
margin-top: 0.25rem;
}
.card-row-3 .sep {
color: var(--surface-border);
}
.card-row-3 .created-by {
font-weight: 500;
}
.card-row-3 .attachments {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.125rem;
color: var(--text-color-secondary);
}
/* Small status badge for mobile cards */
.status-badge-small {
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.65rem;
font-weight: 600;
text-transform: uppercase;
}
.status-badge-small.status-draft { background-color: var(--blue-100); color: var(--blue-600); }
.status-badge-small.status-pending { background-color: var(--orange-100); color: var(--orange-600); }
.status-badge-small.status-approved { background-color: var(--green-100); color: var(--green-600); }
.status-badge-small.status-rejected { background-color: var(--red-100); color: var(--red-600); }
.status-badge-small.status-synced { background-color: var(--teal-100); color: var(--teal-600); }
/* Status text colors for mobile */
.status-text-draft { color: var(--blue-600); }
.status-text-pending_review { color: var(--orange-600); }
.status-text-approved { color: var(--green-600); }
.status-text-rejected { color: var(--red-600); }
.status-text-synced { color: var(--teal-600); }
/* Mobile Pagination */
.mobile-pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 1rem;
padding: 1rem 0 0.5rem;
margin-top: 0.5rem;
}
.page-info {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-color-secondary);
min-width: 60px;
text-align: center;
}
/* Mobile Toolbar */
.mobile-toolbar-container {
margin-bottom: 1rem;
}
.mobile-toolbar-buttons {
display: flex;
gap: 0.5rem;
align-items: stretch;
}
/* Action buttons (Filtre, Resetează) - equal width */
.mobile-toolbar-buttons .p-button-outlined {
flex: 1;
min-width: 0;
justify-content: center;
}
/* Bon Nou button - highlighted, takes more space */
.mobile-toolbar-buttons .p-button-success {
flex: 1.2;
min-width: 0;
justify-content: center;
font-weight: 600;
}
/* Active filter indicator */
.filter-active {
border-color: var(--primary-color, #2563eb) !important;
background: rgba(37, 99, 235, 0.05) !important;
color: var(--primary-color, #2563eb) !important;
}
/* Force green background for Bon Nou button (desktop) */
.status-actions-row .p-button.p-button-success {
background: var(--green-600, #16a34a) !important;
border-color: var(--green-600, #16a34a) !important;
color: white !important;
}
.status-actions-row .p-button.p-button-success:hover {
background: var(--green-700, #15803d) !important;
border-color: var(--green-700, #15803d) !important;
}
/* Mobile responsive */
@media (max-width: 768px) {
.receipts-list-view {
padding: 0.75rem;
}
/* Status + Action Row mobile */
.status-actions-row {
gap: 0.75rem;
justify-content: center;
}
.status-chips {
width: 100%;
gap: 0.25rem;
justify-content: center;
}
.status-actions-row .p-button {
flex: 0 0 auto;
min-width: 140px;
}
.status-chip {
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
}
/* Filters row mobile - compact grid layout */
.filters-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
padding-bottom: 0.5rem;
}
/* Search field - full width at top */
.filters-row .filter-search {
grid-column: 1 / -1;
}
/* Status dropdown - full width below search */
.filters-row .filter-status {
grid-column: 1 / -1;
width: 100% !important;
}
/* Direction and dates - 2 columns */
.filter-direction,
.filter-date {
width: 100% !important;
min-width: unset !important;
}
/* Remove desktop filter action buttons on mobile */
.filter-actions {
display: none;
}
}
/* Reject dialog */
.reject-dialog-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.reject-dialog-content p {
margin: 0;
color: var(--text-color-secondary);
}
/* Text muted helper */
.text-muted {
color: var(--text-color-secondary);
}
/* Compact table adjustments - styles moved to vendor/primevue-overrides.css */
/* Bulk Actions Bar */
.bulk-actions-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: linear-gradient(135deg, var(--blue-50) 0%, var(--blue-100) 100%);
border: 1px solid var(--blue-200);
border-radius: 6px;
margin-bottom: 0.75rem;
}
.selection-info {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: var(--blue-700);
}
.selection-info i {
font-size: 1.1rem;
}
.bulk-buttons {
display: flex;
gap: 0.5rem;
}
@media (max-width: 768px) {
.bulk-actions-bar {
flex-direction: column;
gap: 0.75rem;
align-items: stretch;
}
.bulk-buttons {
justify-content: flex-end;
}
}
/* ============ Batch Grouping Styles (US-002) ============ */
.batch-groups-container {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.batch-group {
border-radius: var(--radius-md);
overflow: hidden;
}
.batch-group-content {
border: 1px solid var(--surface-border);
border-top: none;
border-bottom-left-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
background: var(--surface-card);
}
/* Remove top border radius on grouped tables */
.batch-group-content .grouped-table {
border-radius: 0;
}
/* Pagination for grouped view */
.grouped-pagination {
display: flex;
justify-content: center;
padding: var(--space-md) 0;
margin-top: var(--space-sm);
border-top: 1px solid var(--surface-border);
}
/* Mobile adjustments for batch groups */
@media (max-width: 768px) {
.batch-groups-container {
gap: var(--space-xs);
}
.batch-group-content {
overflow-x: auto;
}
}
/* ============ US-005: Processing Status Filter Chips ============ */
/* In Processing chip - blue colors */
.status-chip.processing-chip-in-progress {
background: var(--blue-100);
color: var(--blue-700);
}
.status-chip.processing-chip-in-progress:hover {
background: var(--blue-200);
}
.status-chip.processing-chip-in-progress.active {
background: var(--blue-100);
color: var(--blue-700);
font-weight: var(--font-semibold);
}
.status-chip.processing-chip-in-progress.active::after {
background: var(--blue-700);
}
/* Failed chip - red colors */
.status-chip.processing-chip-failed {
background: var(--red-100);
color: var(--red-700);
}
.status-chip.processing-chip-failed:hover {
background: var(--red-200);
}
.status-chip.processing-chip-failed.active {
background: var(--red-100);
color: var(--red-700);
font-weight: var(--font-semibold);
}
.status-chip.processing-chip-failed.active::after {
background: var(--red-700);
}
/* ============ US-010: Row Lock for Processing Receipts ============ */
/* Note: PrimeVue row styles are in vendor/primevue-overrides.css */
/* ============ Bulk Upload Button Styles ============ */
/* Hidden file input */
.hidden-file-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
/* Desktop action buttons container */
.desktop-action-buttons {
display: flex;
gap: var(--space-sm);
align-items: center;
}
/* ============ Mobile Toolbar Enhancements ============ */
.mobile-toolbar-container {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.mobile-toolbar-actions {
justify-content: flex-end;
}
/* ============ Mobile Card Processing & Batch Styles ============ */
/* Card processing state - visual indicator */
.receipt-card.card-processing {
opacity: 0.85;
background: linear-gradient(135deg, var(--surface-card) 0%, var(--blue-50) 100%);
border-color: var(--blue-200);
}
/* Processing badge for mobile cards */
.processing-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.65rem;
font-weight: var(--font-semibold);
white-space: nowrap;
}
.processing-badge.processing-active {
background: var(--blue-100);
color: var(--blue-700);
}
.processing-badge.processing-failed {
background: var(--red-100);
color: var(--red-700);
cursor: help;
}
/* Batch badge for mobile cards */
.batch-badge {
display: inline-flex;
align-items: center;
gap: 0.125rem;
padding: 0.125rem 0.375rem;
border-radius: 3px;
font-size: 0.65rem;
font-weight: var(--font-medium);
background: var(--surface-200);
color: var(--text-color-secondary);
white-space: nowrap;
}
.batch-badge i {
font-size: 0.6rem;
}
/* Retry button in mobile cards */
.retry-btn-mobile {
margin-left: auto;
flex-shrink: 0;
}
/* Card row 3 adjustments for new badges */
.card-row-3 {
flex-wrap: wrap;
row-gap: 0.25rem;
}
/* Dark mode adjustments */
[data-theme="dark"] .receipt-card.card-processing {
background: linear-gradient(135deg, var(--surface-card) 0%, var(--blue-900) 100%);
border-color: var(--blue-700);
}
[data-theme="dark"] .processing-badge.processing-active {
background: var(--blue-900);
color: var(--blue-300);
}
[data-theme="dark"] .processing-badge.processing-failed {
background: var(--red-900);
color: var(--red-300);
}
[data-theme="dark"] .batch-badge {
background: var(--surface-700);
color: var(--text-color-secondary);
}
/* ============ US-017: Job Row Styles ============ */
/* Filename display for job rows */
.job-filename {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-sm);
color: var(--text-color);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-filename i {
color: var(--text-color-secondary);
flex-shrink: 0;
}
/* Processing indicator in Actions column for jobs */
.job-processing-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--blue-500);
}
.job-processing-indicator .pi-spinner {
font-size: var(--text-base);
}
/* Dark mode adjustments for job rows */
[data-theme="dark"] .job-filename {
color: var(--text-color);
}
[data-theme="dark"] .job-filename i {
color: var(--text-color-secondary);
}
[data-theme="dark"] .job-processing-indicator {
color: var(--blue-400);
}
/* ============ US-017: Mobile Job Card Styles ============ */
/* Job card styling */
.receipt-card.card-job {
cursor: default;
opacity: 0.9;
}
/* Filename display in mobile job cards */
.job-filename-mobile {
display: flex;
align-items: center;
gap: var(--space-xs);
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.job-filename-mobile i {
color: var(--text-color-secondary);
flex-shrink: 0;
}
/* Processing indicator in mobile job cards */
.job-processing-indicator-mobile {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--blue-500);
}
/* Dark mode adjustments for mobile job cards */
[data-theme="dark"] .receipt-card.card-job {
background: linear-gradient(135deg, var(--surface-card) 0%, var(--blue-900) 100%);
border-color: var(--blue-700);
}
[data-theme="dark"] .job-filename-mobile {
color: var(--text-color);
}
[data-theme="dark"] .job-processing-indicator-mobile {
color: var(--blue-400);
}
/* ============ US-406: Mobile Failed Job Styles ============ */
/* Failed job indicator icon in mobile cards (replaces spinner) */
.job-failed-indicator-mobile {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--red-500);
}
.job-failed-indicator-mobile .pi-exclamation-triangle {
font-size: var(--text-lg);
}
/* Error text color utility class */
.text-error {
color: var(--red-600);
}
/* Truncated error message in job card row 2 */
.job-error-message {
font-size: var(--text-xs);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
cursor: help;
}
/* Dark mode adjustments for mobile failed jobs */
[data-theme="dark"] .job-failed-indicator-mobile {
color: var(--red-400);
}
[data-theme="dark"] .text-error {
color: var(--red-400);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .job-failed-indicator-mobile {
color: var(--red-400);
}
:root:not([data-theme]) .text-error {
color: var(--red-400);
}
}
/* ============ US-018: Failed Job Row Styles ============ */
/* Failed job indicator in Actions column */
.job-failed-indicator {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
color: var(--red-500);
cursor: help;
}
.job-failed-indicator .pi-exclamation-triangle {
font-size: var(--text-base);
}
/* Dark mode adjustments for failed job indicator */
[data-theme="dark"] .job-failed-indicator {
color: var(--red-400);
}
/* ============ US-036: Receipt Filename Display Styles ============ */
/**
* Filename display for receipt rows in desktop table.
* Similar to job-filename but for completed receipts.
*/
.receipt-filename {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-sm);
color: var(--text-color);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.receipt-filename i {
color: var(--text-color-secondary);
flex-shrink: 0;
}
/**
* Mobile card filename display - small, grey text below partner info.
* Following the acceptance criteria: "font mic, gri sub partener"
*/
.filename-mobile {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--text-xs);
color: var(--text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-top: var(--space-xs);
}
.filename-mobile i {
font-size: 0.65rem;
flex-shrink: 0;
}
/* Dark mode adjustments for receipt filename */
[data-theme="dark"] .receipt-filename {
color: var(--text-color);
}
[data-theme="dark"] .receipt-filename i {
color: var(--text-color-secondary);
}
[data-theme="dark"] .filename-mobile {
color: var(--text-color-secondary);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .receipt-filename {
color: var(--text-color);
}
:root:not([data-theme]) .receipt-filename i {
color: var(--text-color-secondary);
}
:root:not([data-theme]) .filename-mobile {
color: var(--text-color-secondary);
}
}
/* ============ US-035: Failed Receipt Mobile Card Styles ============ */
/**
* Failed receipt card styling - subtle red highlight for easy identification.
* Mirrors the row-failed styling from primevue-overrides.css for consistency.
*/
.receipt-card.card-failed {
border-left: 3px solid var(--red-500);
background: linear-gradient(135deg, var(--surface-card) 0%, var(--red-50) 100%);
border-color: var(--red-200);
}
.receipt-card.card-failed:hover {
background: linear-gradient(135deg, var(--surface-hover) 0%, var(--red-100) 100%);
}
/* Dark mode adjustments for failed receipt cards */
[data-theme="dark"] .receipt-card.card-failed {
background: linear-gradient(135deg, var(--surface-card) 0%, var(--red-900) 100%);
border-color: var(--red-700);
}
[data-theme="dark"] .receipt-card.card-failed:hover {
background: linear-gradient(135deg, var(--surface-hover) 0%, var(--red-800) 100%);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .receipt-card.card-failed {
background: linear-gradient(135deg, var(--surface-card) 0%, var(--red-900) 100%);
border-color: var(--red-700);
}
:root:not([data-theme]) .receipt-card.card-failed:hover {
background: linear-gradient(135deg, var(--surface-hover) 0%, var(--red-800) 100%);
}
}
/* Edit button in mobile cards for failed receipts */
.edit-btn-mobile {
flex-shrink: 0;
}
/* ============ US-019: Mobile Card Highlight Animations ============ */
/**
* Keyframe animations for mobile card highlight on status change.
* These mirror the DataTable row animations but for mobile cards.
*/
/* Success highlight animation for mobile cards */
@keyframes cardHighlightGreen {
0% {
background-color: var(--green-100);
border-color: var(--green-300);
}
100% {
background-color: var(--surface-card);
border-color: var(--surface-border);
}
}
/* Error highlight animation for mobile cards */
@keyframes cardHighlightRed {
0% {
background-color: var(--red-100);
border-color: var(--red-300);
}
100% {
background-color: var(--surface-card);
border-color: var(--surface-border);
}
}
/* Apply green highlight animation to completed cards */
.receipt-card.card-highlight-completed {
animation: cardHighlightGreen 2s ease-out forwards;
}
/* Apply red highlight animation to failed cards */
.receipt-card.card-highlight-failed {
animation: cardHighlightRed 2s ease-out forwards;
}
/* Dark mode adjustments for mobile card animations */
@keyframes cardHighlightGreenDark {
0% {
background-color: color-mix(in srgb, var(--green-500) 25%, var(--surface-card));
border-color: var(--green-700);
}
100% {
background-color: var(--surface-card);
border-color: var(--surface-border);
}
}
@keyframes cardHighlightRedDark {
0% {
background-color: color-mix(in srgb, var(--red-500) 25%, var(--surface-card));
border-color: var(--red-700);
}
100% {
background-color: var(--surface-card);
border-color: var(--surface-border);
}
}
[data-theme="dark"] .receipt-card.card-highlight-completed {
animation-name: cardHighlightGreenDark;
}
[data-theme="dark"] .receipt-card.card-highlight-failed {
animation-name: cardHighlightRedDark;
}
/* System preference dark mode for mobile card animations */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .receipt-card.card-highlight-completed {
animation-name: cardHighlightGreenDark;
}
:root:not([data-theme]) .receipt-card.card-highlight-failed {
animation-name: cardHighlightRedDark;
}
}
/* US-019: Accessibility - Disable animations for users who prefer reduced motion */
@media (prefers-reduced-motion: reduce) {
.receipt-card.card-highlight-completed,
.receipt-card.card-highlight-failed {
animation: none;
}
}
/* ============ US-020: Cancel Job Button & Animation Styles ============ */
/* Cancel button for pending/processing jobs */
.cancel-job-btn {
/* Slightly smaller than other buttons to fit with spinner */
width: 28px !important;
height: 28px !important;
padding: 0 !important;
}
.cancel-job-btn:hover {
background: var(--red-50) !important;
}
[data-theme="dark"] .cancel-job-btn:hover {
background: var(--red-900) !important;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .cancel-job-btn:hover {
background: var(--red-900) !important;
}
}
/* US-020: Fade-out animation CSS is in vendor/primevue-overrides.css */
/* Row animation applied via .row-cancelling class from getRowClass() */
/* ============ US-040: Mobile Android-Native Layout Styles ============ */
/**
* Mobile Android-Native Layout
*
* Implements a Gmail/WhatsApp-like layout with:
* - Fixed top bar (56px)
* - Horizontal scrollable filter chips
* - Fixed bottom navigation (56px)
* - FAB (56x56px) with scroll hide/show behavior
*
* PRD tokens used:
* - topBarHeight: 56px
* - bottomNavHeight: 56px
* - fabSize: 56px
* - fabBottomOffset: 72px (56px bottom nav + 16px spacing)
* - touchTargetMin: 48px
*/
/* Main container adjustments for mobile layout */
.receipts-list-view.mobile-android-layout {
padding-top: calc(56px + var(--space-sm)); /* Top bar height + spacing */
padding-bottom: calc(56px + var(--space-sm)); /* Bottom nav height + spacing */
min-height: 100vh;
}
.receipts-list-view.mobile-android-layout .roa-card {
border-radius: 0;
box-shadow: none;
margin: 0;
padding: var(--space-sm);
}
/* US-103: Mobile Top Bar styles now in MobileTopBar.vue */
/* Mobile Filter Chips Container */
.mobile-filter-chips-container {
position: fixed;
top: 56px; /* Below top bar */
left: 0;
right: 0;
background: var(--surface-card);
border-bottom: 1px solid var(--surface-border);
z-index: 999;
padding: var(--space-sm) 0;
}
.mobile-filter-chips {
display: flex;
gap: var(--space-sm);
padding: 0 var(--space-md);
overflow-x: auto;
scrollbar-width: none; /* Firefox */
-webkit-overflow-scrolling: touch;
}
.mobile-filter-chips::-webkit-scrollbar {
display: none; /* Chrome, Safari */
}
.mobile-filter-chip {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-full);
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-color-secondary);
background: var(--surface-100);
border: 1px solid var(--surface-border);
cursor: pointer;
white-space: nowrap;
transition: all var(--transition-fast);
min-height: 32px;
}
.mobile-filter-chip:active {
transform: scale(0.95);
}
.mobile-filter-chip.active {
background: var(--color-primary);
color: var(--color-text-inverse);
border-color: var(--color-primary);
}
.mobile-filter-chip .chip-badge {
margin-left: var(--space-xs);
}
/* Filter chip color variants when active */
.mobile-filter-chip.chip-draft.active {
background: var(--blue-600);
border-color: var(--blue-600);
}
.mobile-filter-chip.chip-pending.active {
background: var(--yellow-600);
border-color: var(--yellow-600);
}
.mobile-filter-chip.chip-approved.active {
background: var(--green-600);
border-color: var(--green-600);
}
.mobile-filter-chip.chip-rejected.active {
background: var(--red-600);
border-color: var(--red-600);
}
.mobile-filter-chip.chip-processing {
background: var(--blue-100);
color: var(--blue-700);
border-color: var(--blue-200);
}
.mobile-filter-chip.chip-processing.active {
background: var(--blue-600);
color: white;
border-color: var(--blue-600);
}
.mobile-filter-chip.chip-failed {
background: var(--red-100);
color: var(--red-700);
border-color: var(--red-200);
}
.mobile-filter-chip.chip-failed.active {
background: var(--red-600);
color: white;
border-color: var(--red-600);
}
.mobile-filter-chip i {
font-size: var(--text-xs);
}
/* US-103: Mobile Bottom Navigation styles now in MobileBottomNav.vue */
/* US-608: Mobile SpeedDial - Floating Action Button with animated options */
.mobile-speed-dial {
position: fixed !important;
bottom: 72px; /* 56px bottom nav + 16px spacing */
right: var(--space-md);
z-index: 999;
}
/* SpeedDial main button styling */
.mobile-speed-dial .p-speeddial-button {
width: 56px !important;
height: 56px !important;
border-radius: 50% !important;
background: var(--color-primary) !important;
border: none !important;
box-shadow: var(--shadow-lg) !important;
transition: all var(--transition-fast) !important;
}
.mobile-speed-dial .p-speeddial-button:hover,
.mobile-speed-dial .p-speeddial-button:focus {
background: var(--color-primary-dark) !important;
box-shadow: var(--shadow-xl) !important;
}
.mobile-speed-dial .p-speeddial-button:active {
transform: scale(0.95) !important;
}
.mobile-speed-dial .p-speeddial-button .p-button-icon {
font-size: var(--text-2xl) !important;
color: var(--color-text-inverse) !important;
}
/* SpeedDial action buttons styling */
.mobile-speed-dial .p-speeddial-action {
width: 48px !important;
height: 48px !important;
background: var(--surface-card) !important;
border: 1px solid var(--surface-border) !important;
box-shadow: var(--shadow-md) !important;
transition: all var(--transition-fast) !important;
}
.mobile-speed-dial .p-speeddial-action:hover,
.mobile-speed-dial .p-speeddial-action:focus {
background: var(--surface-hover) !important;
transform: scale(1.1) !important;
}
.mobile-speed-dial .p-speeddial-action .p-speeddial-action-icon {
color: var(--color-primary) !important;
font-size: var(--text-lg) !important;
}
/* SpeedDial mask overlay (background when open) */
.mobile-speed-dial .p-speeddial-mask {
background: rgba(0, 0, 0, 0.4) !important;
}
/* SpeedDial tooltip labels */
.mobile-speed-dial .p-speeddial-action::before {
content: attr(aria-label);
position: absolute;
right: 60px;
white-space: nowrap;
background: var(--surface-card);
color: var(--text-color);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
font-weight: var(--font-medium);
box-shadow: var(--shadow-sm);
border: 1px solid var(--surface-border);
opacity: 0;
transform: translateX(10px);
transition: all var(--transition-fast);
pointer-events: none;
}
.mobile-speed-dial.p-speeddial-opened .p-speeddial-action::before {
opacity: 1;
transform: translateX(0);
}
/* Mobile Sidebar (Hamburger Menu) */
.mobile-sidebar {
width: 280px !important;
}
.sidebar-header {
padding: var(--space-md);
border-bottom: 1px solid var(--surface-border);
}
.sidebar-title {
font-size: var(--text-xl);
font-weight: var(--font-bold);
color: var(--color-primary);
}
.sidebar-menu {
display: flex;
flex-direction: column;
padding: var(--space-sm) 0;
}
.sidebar-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
color: var(--text-color);
text-decoration: none;
font-size: var(--text-base);
font-weight: var(--font-medium);
transition: background var(--transition-fast);
}
.sidebar-item:hover,
.sidebar-item:active {
background: var(--surface-hover);
}
.sidebar-item.active,
.sidebar-item.router-link-active {
color: var(--color-primary);
background: var(--blue-50);
}
.sidebar-item i {
font-size: var(--text-xl);
width: 24px;
text-align: center;
}
/* Adjust content area when filter chips are visible */
.receipts-list-view.mobile-android-layout .mobile-filter-chips-container + .roa-card,
.receipts-list-view.mobile-android-layout:has(.mobile-filter-chips-container) .roa-card {
margin-top: 48px; /* Height of filter chips container */
}
/* Hide old mobile toolbar on Android layout (replaced by top bar) */
.receipts-list-view.mobile-android-layout .mobile-toolbar-container {
display: none;
}
/* Adjust receipt cards container for mobile layout */
.receipts-list-view.mobile-android-layout .receipt-cards {
padding-bottom: 80px; /* Space for FAB and bottom nav */
}
/* US-103: Dark mode for MobileTopBar now in component */
[data-theme="dark"] .mobile-filter-chips-container {
background: var(--surface-card);
border-bottom-color: var(--surface-border);
}
[data-theme="dark"] .mobile-filter-chip {
background: var(--surface-700);
border-color: var(--surface-border);
color: var(--text-color-secondary);
}
[data-theme="dark"] .mobile-filter-chip.active {
background: var(--blue-600);
border-color: var(--blue-600);
color: white;
}
[data-theme="dark"] .mobile-filter-chip.chip-processing {
background: var(--blue-900);
color: var(--blue-300);
border-color: var(--blue-700);
}
[data-theme="dark"] .mobile-filter-chip.chip-failed {
background: var(--red-900);
color: var(--red-300);
border-color: var(--red-700);
}
/* US-103: Dark mode for MobileBottomNav now in component */
/* US-608: Dark mode for SpeedDial */
[data-theme="dark"] .mobile-speed-dial .p-speeddial-button {
background: var(--blue-600) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
}
[data-theme="dark"] .mobile-speed-dial .p-speeddial-button:hover,
[data-theme="dark"] .mobile-speed-dial .p-speeddial-button:focus {
background: var(--blue-700) !important;
}
[data-theme="dark"] .mobile-speed-dial .p-speeddial-action {
background: var(--surface-card) !important;
border-color: var(--surface-border) !important;
}
[data-theme="dark"] .mobile-speed-dial .p-speeddial-action:hover,
[data-theme="dark"] .mobile-speed-dial .p-speeddial-action:focus {
background: var(--surface-hover) !important;
}
[data-theme="dark"] .mobile-speed-dial .p-speeddial-action .p-speeddial-action-icon {
color: var(--blue-400) !important;
}
[data-theme="dark"] .mobile-speed-dial .p-speeddial-action::before {
background: var(--surface-card);
color: var(--text-color);
border-color: var(--surface-border);
}
[data-theme="dark"] .sidebar-item.active,
[data-theme="dark"] .sidebar-item.router-link-active {
color: var(--blue-400);
background: var(--blue-900);
}
/* System preference dark mode support */
/* US-103: Auto dark mode for MobileTopBar now in component */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .mobile-filter-chips-container {
background: var(--surface-card);
border-bottom-color: var(--surface-border);
}
:root:not([data-theme]) .mobile-filter-chip {
background: var(--surface-700);
border-color: var(--surface-border);
color: var(--text-color-secondary);
}
:root:not([data-theme]) .mobile-filter-chip.active {
background: var(--blue-600);
border-color: var(--blue-600);
color: white;
}
:root:not([data-theme]) .mobile-filter-chip.chip-processing {
background: var(--blue-900);
color: var(--blue-300);
border-color: var(--blue-700);
}
:root:not([data-theme]) .mobile-filter-chip.chip-failed {
background: var(--red-900);
color: var(--red-300);
border-color: var(--red-700);
}
/* US-103: Auto dark mode for MobileBottomNav now in component */
/* US-608: Auto dark mode for SpeedDial */
:root:not([data-theme]) .mobile-speed-dial .p-speeddial-button {
background: var(--blue-600) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
}
:root:not([data-theme]) .mobile-speed-dial .p-speeddial-button:hover,
:root:not([data-theme]) .mobile-speed-dial .p-speeddial-button:focus {
background: var(--blue-700) !important;
}
:root:not([data-theme]) .mobile-speed-dial .p-speeddial-action {
background: var(--surface-card) !important;
border-color: var(--surface-border) !important;
}
:root:not([data-theme]) .mobile-speed-dial .p-speeddial-action:hover,
:root:not([data-theme]) .mobile-speed-dial .p-speeddial-action:focus {
background: var(--surface-hover) !important;
}
:root:not([data-theme]) .mobile-speed-dial .p-speeddial-action .p-speeddial-action-icon {
color: var(--blue-400) !important;
}
:root:not([data-theme]) .mobile-speed-dial .p-speeddial-action::before {
background: var(--surface-card);
color: var(--text-color);
border-color: var(--surface-border);
}
:root:not([data-theme]) .sidebar-item.active,
:root:not([data-theme]) .sidebar-item.router-link-active {
color: var(--blue-400);
background: var(--blue-900);
}
}
/* Adjust layout when filter chips are visible - accounting for their height */
.receipts-list-view.mobile-android-layout:has(.mobile-filter-chips-container) {
padding-top: calc(56px + 48px + var(--space-sm)); /* Top bar + filter chips + spacing */
}
/* ================================================
US-503: BottomSheet Filters Styles
Pattern from InvoicesView.vue
================================================ */
.bottom-sheet-title {
font-size: var(--text-lg);
font-weight: var(--font-semibold);
color: var(--text-color);
margin: 0 0 var(--space-md) 0;
}
.bottom-sheet-filters {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.bottom-sheet-actions {
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
margin-top: var(--space-md);
padding-top: var(--space-md);
border-top: 1px solid var(--surface-border);
}
</style>
<!-- US-608: Non-scoped styles for SpeedDial mask (appended to body) -->
<style>
/* US-608: SpeedDial mask overlay - appears when SpeedDial is open */
.p-speeddial-mask.mobile-speed-dial-mask {
position: fixed !important;
top: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
background: rgba(0, 0, 0, 0.5) !important;
z-index: 998 !important;
animation: fadeIn 200ms ease-out !important;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Dark mode mask */
[data-theme="dark"] .p-speeddial-mask.mobile-speed-dial-mask {
background: rgba(0, 0, 0, 0.7) !important;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .p-speeddial-mask.mobile-speed-dial-mask {
background: rgba(0, 0, 0, 0.7) !important;
}
}
</style>
<!-- US-516: Non-scoped styles for Receipt Action Menu (teleported to body) -->
<style>
/* ============================================================================
* US-516: Receipt Action Menu - Material Design 3 Styling
* Non-scoped because PrimeVue Menu is teleported to document body
* ============================================================================ */
/* Base menu container styling */
.receipt-action-menu.p-menu.p-menu-overlay {
/* MD3 Surface: Use tonal surface for elevation */
background: var(--md-sys-color-surface-container-high, var(--surface-card)) !important;
border: 1px solid var(--md-sys-color-outline-variant, var(--surface-border)) !important;
border-radius: var(--radius-lg, 12px) !important;
min-width: 200px !important;
max-width: 280px !important;
/* MD3 Elevation: Softer shadow for modern look */
box-shadow:
0 2px 4px -1px rgba(0, 0, 0, 0.1),
0 4px 6px -1px rgba(0, 0, 0, 0.06),
0 8px 16px -2px rgba(0, 0, 0, 0.1) !important;
/* Smooth entry animation */
transform-origin: top center !important;
animation: receiptMenuSlideIn 200ms ease-out !important;
overflow: hidden !important;
}
/* MD3 Menu Animation */
@keyframes receiptMenuSlideIn {
from {
opacity: 0;
transform: scale(0.92) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
/* Menu container padding for visual breathing room */
.receipt-action-menu.p-menu .p-menu-list {
padding: var(--space-sm, 8px) 0 !important;
}
/* Menu item container */
.receipt-action-menu.p-menu .p-menuitem {
padding: 0 !important;
margin: 0 var(--space-xs, 4px) !important;
}
/* PrimeVue 4 uses .p-menuitem-content for the clickable area */
.receipt-action-menu.p-menu .p-menuitem-content {
display: flex !important;
align-items: center !important;
gap: var(--space-md, 16px) !important;
padding: var(--space-sm, 8px) var(--space-md, 16px) !important;
min-height: 48px !important; /* MD3: 48px touch target for accessibility */
font-size: var(--text-base, 1rem) !important;
color: var(--md-sys-color-on-surface, var(--text-color)) !important;
background: transparent !important;
border-radius: var(--radius-md, 8px) !important;
text-decoration: none !important;
cursor: pointer !important;
transition: background-color var(--transition-fast, 150ms) ease,
transform var(--transition-fast, 150ms) ease !important;
position: relative !important;
overflow: hidden !important;
}
/* MD3 State Layer: Hover effect */
.receipt-action-menu.p-menu .p-menuitem-content:hover {
background: var(--md-sys-color-surface-variant, var(--surface-hover)) !important;
}
/* MD3 State Layer: Active/Press effect */
.receipt-action-menu.p-menu .p-menuitem-content:active {
background: var(--md-sys-color-primary-container, var(--primary-100)) !important;
transform: scale(0.98) !important;
}
/* MD3 Focus visible state for keyboard navigation */
.receipt-action-menu.p-menu .p-menuitem:focus-visible .p-menuitem-content {
outline: 2px solid var(--md-sys-color-primary, var(--color-primary)) !important;
outline-offset: -2px !important;
}
/* Icon styling - MD3 leading icon */
.receipt-action-menu.p-menu .p-menuitem-icon {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 24px !important;
height: 24px !important;
font-size: 1.25rem !important;
color: var(--md-sys-color-on-surface-variant, var(--color-text-secondary)) !important;
flex-shrink: 0 !important;
transition: color var(--transition-fast, 150ms) ease !important;
}
/* Icon color on hover */
.receipt-action-menu.p-menu .p-menuitem-content:hover .p-menuitem-icon {
color: var(--md-sys-color-primary, var(--color-primary)) !important;
}
/* Text styling - MD3 label */
.receipt-action-menu.p-menu .p-menuitem-text {
font-weight: var(--font-medium, 500) !important;
font-size: var(--text-base, 1rem) !important;
line-height: 1.5 !important;
color: var(--md-sys-color-on-surface, var(--text-color)) !important;
}
/* Spacing between menu items */
.receipt-action-menu.p-menu .p-menuitem + .p-menuitem {
margin-top: 2px !important;
}
/* ============================================================================
* US-516: Menu Separator (Divider before destructive actions)
* ============================================================================ */
.receipt-action-menu.p-menu .p-menu-separator {
margin: var(--space-sm, 8px) var(--space-md, 16px) !important;
border: none !important;
border-top: 1px solid var(--md-sys-color-outline-variant, var(--surface-border)) !important;
height: 0 !important;
}
/* ============================================================================
* US-516: Semantic Color Classes for Menu Items
* ============================================================================ */
/* Danger/Destructive actions (Delete, Reject) */
.receipt-action-menu.p-menu .p-menuitem.menu-item-danger .p-menuitem-content {
color: var(--md-sys-color-error, var(--color-error)) !important;
}
.receipt-action-menu.p-menu .p-menuitem.menu-item-danger .p-menuitem-icon {
color: var(--md-sys-color-error, var(--color-error)) !important;
}
.receipt-action-menu.p-menu .p-menuitem.menu-item-danger .p-menuitem-text {
color: var(--md-sys-color-error, var(--color-error)) !important;
}
.receipt-action-menu.p-menu .p-menuitem.menu-item-danger .p-menuitem-content:hover {
background: var(--md-sys-color-error-container, var(--red-100)) !important;
}
.receipt-action-menu.p-menu .p-menuitem.menu-item-danger .p-menuitem-content:active {
background: var(--md-sys-color-error-container, var(--red-200)) !important;
}
/* Success actions (Approve, Validate) */
.receipt-action-menu.p-menu .p-menuitem.menu-item-success .p-menuitem-content {
color: var(--color-success, #059669) !important;
}
.receipt-action-menu.p-menu .p-menuitem.menu-item-success .p-menuitem-icon {
color: var(--color-success, #059669) !important;
}
.receipt-action-menu.p-menu .p-menuitem.menu-item-success .p-menuitem-text {
color: var(--color-success, #059669) !important;
}
.receipt-action-menu.p-menu .p-menuitem.menu-item-success .p-menuitem-content:hover {
background: var(--green-100, rgba(5, 150, 105, 0.1)) !important;
}
.receipt-action-menu.p-menu .p-menuitem.menu-item-success .p-menuitem-content:active {
background: var(--green-200, rgba(5, 150, 105, 0.2)) !important;
}
/* Warning actions (Unapprove) */
.receipt-action-menu.p-menu .p-menuitem.menu-item-warning .p-menuitem-content {
color: var(--color-warning, #d97706) !important;
}
.receipt-action-menu.p-menu .p-menuitem.menu-item-warning .p-menuitem-icon {
color: var(--color-warning, #d97706) !important;
}
.receipt-action-menu.p-menu .p-menuitem.menu-item-warning .p-menuitem-text {
color: var(--color-warning, #d97706) !important;
}
.receipt-action-menu.p-menu .p-menuitem.menu-item-warning .p-menuitem-content:hover {
background: var(--yellow-100, rgba(217, 119, 6, 0.1)) !important;
}
.receipt-action-menu.p-menu .p-menuitem.menu-item-warning .p-menuitem-content:active {
background: var(--yellow-200, rgba(217, 119, 6, 0.2)) !important;
}
/* ============================================================================
* US-516: Dark Mode Support
* ============================================================================ */
[data-theme="dark"] .receipt-action-menu.p-menu.p-menu-overlay {
background: var(--surface-800, #1e293b) !important;
border-color: var(--surface-600, #475569) !important;
box-shadow:
0 4px 8px -2px rgba(0, 0, 0, 0.3),
0 8px 16px -4px rgba(0, 0, 0, 0.25) !important;
}
[data-theme="dark"] .receipt-action-menu.p-menu .p-menuitem-content:hover {
background: var(--surface-700, #334155) !important;
}
[data-theme="dark"] .receipt-action-menu.p-menu .p-menuitem-content:active {
background: var(--surface-600, #475569) !important;
}
[data-theme="dark"] .receipt-action-menu.p-menu .p-menu-separator {
border-top-color: var(--surface-600, #475569) !important;
}
/* Dark mode semantic colors */
[data-theme="dark"] .receipt-action-menu.p-menu .p-menuitem.menu-item-danger .p-menuitem-content:hover {
background: var(--red-900, rgba(220, 38, 38, 0.2)) !important;
}
[data-theme="dark"] .receipt-action-menu.p-menu .p-menuitem.menu-item-success .p-menuitem-content:hover {
background: var(--green-900, rgba(5, 150, 105, 0.2)) !important;
}
[data-theme="dark"] .receipt-action-menu.p-menu .p-menuitem.menu-item-warning .p-menuitem-content:hover {
background: var(--yellow-900, rgba(217, 119, 6, 0.2)) !important;
}
/* Auto dark mode (system preference) */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) .receipt-action-menu.p-menu.p-menu-overlay {
background: var(--surface-800, #1e293b) !important;
border-color: var(--surface-600, #475569) !important;
box-shadow:
0 4px 8px -2px rgba(0, 0, 0, 0.3),
0 8px 16px -4px rgba(0, 0, 0, 0.25) !important;
}
:root:not([data-theme]) .receipt-action-menu.p-menu .p-menuitem-content:hover {
background: var(--surface-700, #334155) !important;
}
:root:not([data-theme]) .receipt-action-menu.p-menu .p-menuitem-content:active {
background: var(--surface-600, #475569) !important;
}
:root:not([data-theme]) .receipt-action-menu.p-menu .p-menu-separator {
border-top-color: var(--surface-600, #475569) !important;
}
:root:not([data-theme]) .receipt-action-menu.p-menu .p-menuitem.menu-item-danger .p-menuitem-content:hover {
background: var(--red-900, rgba(220, 38, 38, 0.2)) !important;
}
:root:not([data-theme]) .receipt-action-menu.p-menu .p-menuitem.menu-item-success .p-menuitem-content:hover {
background: var(--green-900, rgba(5, 150, 105, 0.2)) !important;
}
:root:not([data-theme]) .receipt-action-menu.p-menu .p-menuitem.menu-item-warning .p-menuitem-content:hover {
background: var(--yellow-900, rgba(217, 119, 6, 0.2)) !important;
}
}
</style>