Files
roa2web-service-auto/src/modules/data-entry/views/receipts/ReceiptsListView.vue
Claude Agent a5740eaf78 feat(mobile-fixes-phase3): Complete US-307 - Restructurare Footer Nav (4 butoane noi)
Implemented by Ralph autonomous loop.
Iteration: 1

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-12 13:38:58 +00:00

4583 lines
136 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"
@logout="handleLogout"
/>
<!-- 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 -->
<div v-if="!isMobile || showFilters" class="filters-row">
<!-- Mobile: Status Dropdown (instead of chips) -->
<Dropdown
v-if="isMobile"
v-model="filters.status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="Status"
class="filter-status"
@change="onFilterChange"
/>
<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"
/>
<!-- Desktop filter action buttons -->
<div v-if="!isMobile" class="filter-actions">
<Button
icon="pi pi-search"
label="Filtrează"
severity="primary"
outlined
size="small"
@click="onFilterChange"
/>
<Button
icon="pi pi-times"
label="Resetează"
severity="secondary"
outlined
size="small"
@click="clearFilters"
/>
</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 + Processing 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>
<span class="cui text-muted">Se procesează...</span>
</div>
<div class="amount-block">
<span class="amount text-muted">-</span>
</div>
<span class="job-processing-indicator-mobile">
<i class="pi pi-spin pi-spinner"></i>
</span>
</div>
<!-- Row 2: Job info -->
<div class="card-row-2">
<span class="text-muted">În procesare</span>
</div>
<!-- Row 3: Processing status -->
<div class="card-row-3">
<span 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-040: Mobile FAB (Floating Action Button) -->
<Transition name="fab-slide">
<button
v-if="isMobile && !mobileSelectionMode && fabVisible"
class="mobile-fab"
@click="goToCreate"
aria-label="Adaugă bon nou"
>
<i class="pi pi-plus"></i>
</button>
</Transition>
<!-- 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) -->
<Menu ref="menuRef" :model="menuItems" popup />
</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 } 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 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 } 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 { sseService } from '@data-entry/services/sseService'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const store = useReceiptsStore()
const companyStore = useCompanyStore()
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)
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-103: 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 search, filter, more menu
return [
{ id: 'search', icon: 'pi pi-search', active: showFilters.value, tooltip: 'Căutare' },
{ id: 'filter', icon: 'pi pi-filter', active: hasActiveFilters.value, tooltip: 'Filtre' },
{ id: 'more', icon: 'pi pi-ellipsis-v', tooltip: 'Mai multe' }
]
})
// US-103: Handle top bar action clicks
const handleTopBarAction = (action) => {
switch (action.id) {
case 'select-all':
selectAllMobile()
break
case 'search':
case 'filter':
showFilters.value = !showFilters.value
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)
}
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: 'text-red-500', command: () => confirmDelete(r) })
}
// PENDING: Approve + Reject
if (r.status === 'pending_review') {
items.push({ separator: true })
items.push({ label: 'Validează', icon: 'pi pi-check', class: 'text-green-500', command: () => confirmApprove(r) })
items.push({ label: 'Respinge', icon: 'pi pi-times', class: 'text-red-500', command: () => openRejectDialog(r) })
}
// APPROVED: Unapprove
if (r.status === 'approved') {
items.push({ separator: true })
items.push({ label: 'Anulare Validare', icon: 'pi pi-undo', class: 'text-orange-500', 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: 'text-red-500', 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-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()
}
// 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) => {
confirm.require({
message: `Sigur doriți să ștergeți acest bon?`,
header: 'Confirmare ștergere',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
accept: async () => {
try {
await store.deleteReceipt(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-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 */
.status-actions-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
}
.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 */
.filters-row {
display: flex;
gap: 0.5rem;
align-items: center;
flex-wrap: wrap;
margin-bottom: 0.75rem;
padding-bottom: 0.75rem;
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;
}
/* 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-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 */
/* Mobile FAB - Floating Action Button */
.mobile-fab {
position: fixed;
bottom: 72px; /* 56px bottom nav + 16px spacing */
right: var(--space-md);
width: 56px;
height: 56px;
border-radius: var(--radius-xl);
background: var(--color-primary);
color: var(--color-text-inverse);
border: none;
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 999;
transition: all var(--transition-fast);
}
.mobile-fab:active {
transform: scale(0.95);
box-shadow: var(--shadow-md);
}
.mobile-fab i {
font-size: var(--text-2xl);
}
/* FAB slide animation */
.fab-slide-enter-active,
.fab-slide-leave-active {
transition: transform var(--transition-normal), opacity var(--transition-normal);
}
.fab-slide-enter-from,
.fab-slide-leave-to {
transform: translateY(100px);
opacity: 0;
}
.fab-slide-enter-to,
.fab-slide-leave-from {
transform: translateY(0);
opacity: 1;
}
/* 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 */
[data-theme="dark"] .mobile-fab {
background: var(--blue-600);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
[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 */
:root:not([data-theme]) .mobile-fab {
background: var(--blue-600);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
}
: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 */
}
</style>