Implemented by Ralph autonomous loop. Iteration: 1 Co-Authored-By: Claude <noreply@anthropic.com>
4583 lines
136 KiB
Vue
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>
|