feat: Add bulk approval to main list, remove separate approval page

- Remove /approval route and ReceiptApprovalView.vue
- Add checkbox selection column to receipts DataTable
- Add bulk actions bar with "Validează selectate" button
- Add visual indicator (underline) for active status filter
- Update CLAUDE.md with test environment instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-18 12:47:50 +02:00
parent a90d9093de
commit bc8a2ad138
5 changed files with 152 additions and 505 deletions

View File

@@ -1,5 +1,10 @@
# CLAUDE.md - Data Entry App
## IMPORTANT - Reguli pentru Claude
- **Pentru teste folosește DOAR `./start-data-entry-test.sh`** (server TEST 10.0.20.121)
- **NU folosi `./start-data-entry-dev.sh`** decât la instrucțiuni explicite de la utilizator (server PRODUCȚIE 10.0.20.36)
## Scop
Aplicatie pentru introducere date in ERP (bonuri fiscale, chitante) cu workflow de aprobare.

View File

@@ -64,12 +64,6 @@ const dataEntryMenuItems = computed(() => [
items: [
{ to: '/', icon: 'pi pi-list', label: 'Lista Bonuri' },
{ to: '/create', icon: 'pi pi-plus', label: 'Bon Nou' },
{
to: '/approval',
icon: 'pi pi-check-circle',
label: 'Aprobare',
badge: pendingCount.value > 0 ? pendingCount.value : null
},
]
}
])

View File

@@ -32,12 +32,6 @@ const routes = [
component: () => import('../views/receipts/ReceiptCreateView.vue'),
meta: { title: 'Editare Bon', requiresAuth: true }
},
{
path: '/approval',
name: 'ReceiptApproval',
component: () => import('../views/receipts/ReceiptApprovalView.vue'),
meta: { title: 'Aprobare Bonuri', requiresAuth: true }
}
]
const router = createRouter({

View File

@@ -1,488 +0,0 @@
<template>
<div class="receipt-approval-view">
<div class="roa-card">
<div class="roa-card-header">
<h2 class="roa-card-title">
<i class="pi pi-check-circle"></i>
Aprobare Bonuri
<Badge v-if="pendingReceipts.length" :value="pendingReceipts.length" severity="danger" />
</h2>
<Button
v-if="selectedReceipts.length > 0"
:label="`Aproba selectate (${selectedReceipts.length})`"
icon="pi pi-check"
severity="success"
@click="approveSelected"
:loading="approving"
/>
</div>
<!-- Loading -->
<div v-if="loading" class="loading-container">
<ProgressSpinner />
</div>
<!-- Empty State -->
<div v-else-if="!pendingReceipts.length" class="empty-state">
<i class="pi pi-check-circle"></i>
<h3>Niciun bon de aprobat</h3>
<p>Toate bonurile au fost procesate</p>
</div>
<!-- Pending Receipts List -->
<div v-else>
<DataTable
v-model:selection="selectedReceipts"
:value="pendingReceipts"
responsiveLayout="scroll"
stripedRows
>
<Column selectionMode="multiple" headerStyle="width: 3rem" />
<Column field="receipt_date" header="Data" style="width: 100px">
<template #body="{ data }">
{{ formatDate(data.receipt_date) }}
</template>
</Column>
<Column field="partner_name" header="Furnizor" style="min-width: 150px">
<template #body="{ data }">
{{ data.partner_name || '-' }}
</template>
</Column>
<Column field="amount" header="Suma" style="width: 120px">
<template #body="{ data }">
<strong>{{ formatAmount(data.amount) }}</strong>
</template>
</Column>
<Column field="created_by" header="Creat de" style="width: 120px" />
<Column field="attachments" header="Atasamente" style="width: 100px">
<template #body="{ data }">
<Badge :value="data.attachments?.length || 0" />
</template>
</Column>
<Column header="Actiuni" style="width: 200px">
<template #body="{ data }">
<div class="button-group">
<Button
icon="pi pi-eye"
severity="info"
text
rounded
@click="viewReceipt(data)"
v-tooltip="'Detalii'"
/>
<Button
icon="pi pi-check"
severity="success"
text
rounded
@click="approveReceipt(data)"
v-tooltip="'Aproba'"
/>
<Button
icon="pi pi-times"
severity="danger"
text
rounded
@click="openRejectDialog(data)"
v-tooltip="'Respinge'"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</div>
<!-- Receipt Detail Dialog -->
<Dialog
v-model:visible="detailDialog"
modal
:header="`Bon #${selectedReceiptDetail?.id}`"
:style="{ width: '90vw', maxWidth: '900px' }"
>
<template v-if="selectedReceiptDetail">
<TabView>
<TabPanel header="Detalii">
<div class="detail-grid-dialog">
<div class="detail-section">
<h4>Informatii Document</h4>
<div class="detail-list">
<div class="detail-item">
<span class="label">Tip:</span>
<span>{{ selectedReceiptDetail.receipt_type === 'bon_fiscal' ? 'Bon Fiscal' : 'Chitanta' }}</span>
</div>
<div class="detail-item">
<span class="label">Data:</span>
<span>{{ formatDate(selectedReceiptDetail.receipt_date) }}</span>
</div>
<div class="detail-item">
<span class="label">Suma:</span>
<strong>{{ formatAmount(selectedReceiptDetail.amount) }}</strong>
</div>
<div class="detail-item">
<span class="label">Furnizor:</span>
<span>{{ selectedReceiptDetail.partner_name || '-' }}</span>
</div>
<div class="detail-item">
<span class="label">Descriere:</span>
<span>{{ selectedReceiptDetail.description || '-' }}</span>
</div>
</div>
</div>
<div class="detail-section">
<h4>Atasamente</h4>
<div v-if="selectedReceiptDetail.attachments?.length" class="attachments-preview">
<div
v-for="att in selectedReceiptDetail.attachments"
:key="att.id"
class="attachment-preview-item"
>
<Image
v-if="att.mime_type?.startsWith('image/')"
:src="store.getAttachmentUrl(att.id)"
:alt="att.filename"
preview
width="150"
/>
<a v-else :href="store.getAttachmentUrl(att.id)" target="_blank">
<i class="pi pi-file-pdf"></i>
{{ att.filename }}
</a>
</div>
</div>
<p v-else class="no-data">Niciun atasament</p>
</div>
</div>
</TabPanel>
<TabPanel header="Note Contabile">
<div v-if="selectedReceiptDetail.entries?.length" class="entries-section">
<table class="entries-table">
<thead>
<tr>
<th>Tip</th>
<th>Cont</th>
<th>Denumire</th>
<th style="text-align: right;">Suma</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in selectedReceiptDetail.entries" :key="entry.id">
<td>
<Tag
:value="entry.entry_type === 'debit' ? 'D' : 'C'"
:severity="entry.entry_type === 'debit' ? 'danger' : 'success'"
/>
</td>
<td>{{ entry.account_code }}</td>
<td>{{ entry.account_name || '-' }}</td>
<td :class="entry.entry_type" style="text-align: right;">
{{ formatAmount(entry.amount) }}
</td>
</tr>
</tbody>
</table>
</div>
<p v-else class="no-data">Nu exista note contabile</p>
</TabPanel>
</TabView>
<div class="dialog-actions">
<Button
label="Aproba"
icon="pi pi-check"
severity="success"
@click="approveReceipt(selectedReceiptDetail)"
/>
<Button
label="Respinge"
icon="pi pi-times"
severity="danger"
@click="openRejectDialog(selectedReceiptDetail); detailDialog = false;"
/>
</div>
</template>
</Dialog>
<!-- Reject Dialog -->
<Dialog
v-model:visible="rejectDialog"
modal
header="Respingere Bon"
:style="{ width: '500px' }"
>
<div class="form-field">
<label>Motiv respingere *</label>
<Textarea
v-model="rejectReason"
rows="4"
placeholder="Introduceti motivul respingerii..."
/>
</div>
<template #footer>
<Button
label="Anuleaza"
icon="pi pi-times"
severity="secondary"
@click="rejectDialog = false"
/>
<Button
label="Respinge"
icon="pi pi-check"
severity="danger"
@click="confirmReject"
:disabled="!rejectReason || rejectReason.length < 5"
:loading="rejecting"
/>
</template>
</Dialog>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import { useReceiptsStore } from '../../stores/receiptsStore'
const toast = useToast()
const store = useReceiptsStore()
const pendingReceipts = ref([])
const selectedReceipts = ref([])
const loading = ref(true)
const approving = ref(false)
const rejecting = ref(false)
const detailDialog = ref(false)
const selectedReceiptDetail = ref(null)
const rejectDialog = ref(false)
const receiptToReject = ref(null)
const rejectReason = ref('')
onMounted(async () => {
await loadPendingReceipts()
})
const loadPendingReceipts = async () => {
loading.value = true
try {
pendingReceipts.value = await store.fetchPendingReceipts()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-au putut incarca bonurile',
life: 5000,
})
} finally {
loading.value = false
}
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('ro-RO')
}
const formatAmount = (amount) => {
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
}).format(amount)
}
const viewReceipt = (receipt) => {
selectedReceiptDetail.value = receipt
detailDialog.value = true
}
const approveReceipt = async (receipt) => {
approving.value = true
try {
const result = await store.approveReceipt(receipt.id)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost aprobat',
life: 3000,
})
detailDialog.value = false
await loadPendingReceipts()
} else {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: result.message,
life: 5000,
})
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut aproba bonul',
life: 5000,
})
} finally {
approving.value = false
}
}
const approveSelected = async () => {
if (!selectedReceipts.value.length) return
approving.value = true
let successCount = 0
let errorCount = 0
for (const receipt of selectedReceipts.value) {
try {
const result = await store.approveReceipt(receipt.id)
if (result.success) {
successCount++
} else {
errorCount++
}
} catch (error) {
errorCount++
}
}
approving.value = false
selectedReceipts.value = []
if (successCount > 0) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: `${successCount} bonuri aprobate`,
life: 3000,
})
}
if (errorCount > 0) {
toast.add({
severity: 'warn',
summary: 'Atentie',
detail: `${errorCount} bonuri nu au putut fi aprobate`,
life: 5000,
})
}
await loadPendingReceipts()
}
const openRejectDialog = (receipt) => {
receiptToReject.value = receipt
rejectReason.value = ''
rejectDialog.value = true
}
const confirmReject = async () => {
if (!receiptToReject.value || !rejectReason.value) return
rejecting.value = true
try {
const result = await store.rejectReceipt(receiptToReject.value.id, rejectReason.value)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost respins',
life: 3000,
})
rejectDialog.value = false
await loadPendingReceipts()
} else {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: result.message,
life: 5000,
})
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut respinge bonul',
life: 5000,
})
} finally {
rejecting.value = false
}
}
</script>
<style scoped>
.detail-grid-dialog {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
}
@media (max-width: 768px) {
.detail-grid-dialog {
grid-template-columns: 1fr;
}
}
.detail-section h4 {
margin-bottom: 1rem;
color: #333;
}
.detail-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.detail-item {
display: flex;
gap: 0.5rem;
}
.detail-item .label {
color: #666;
min-width: 80px;
}
.attachments-preview {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.attachment-preview-item {
max-width: 150px;
}
.no-data {
color: #666;
font-style: italic;
}
.entries-section {
overflow-x: auto;
}
.dialog-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #eee;
}
</style>

View File

@@ -206,7 +206,34 @@
<!-- Desktop: Compact Data Table -->
<div v-else 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="Deselectează"
icon="pi pi-times"
severity="secondary"
size="small"
@click="selectedReceipts = []"
/>
</div>
</div>
<DataTable
v-model:selection="selectedReceipts"
:value="receipts"
:paginator="true"
:rows="pagination.pageSize"
@@ -216,7 +243,9 @@
responsiveLayout="scroll"
stripedRows
class="compact-table"
dataKey="id"
>
<Column selectionMode="multiple" headerStyle="width: 3rem" />
<Column field="receipt_date" header="Data" style="width: 90px">
<template #body="{ data }">
{{ formatDate(data.receipt_date) }}
@@ -357,6 +386,10 @@ const rejectDialogVisible = ref(false)
const rejectReason = ref('')
const receiptToReject = ref(null)
// Bulk selection state
const selectedReceipts = ref([])
const bulkApproving = ref(false)
// Mobile detection
const isMobile = ref(window.innerWidth < 768)
const handleResize = () => {
@@ -509,6 +542,7 @@ const filterByStatus = async (status) => {
}
const onFilterChange = async () => {
selectedReceipts.value = [] // Clear selection on filter change
store.setFilters({
status: filters.value.status,
search: filters.value.search,
@@ -524,6 +558,7 @@ const onFilterChange = async () => {
}
const clearFilters = async () => {
selectedReceipts.value = [] // Clear selection on filter reset
filters.value = {
status: null,
search: '',
@@ -536,6 +571,7 @@ const clearFilters = async () => {
}
const onPageChange = async (event) => {
selectedReceipts.value = [] // Clear selection on page change
store.setPage(event.page + 1)
await store.fetchReceipts()
}
@@ -747,6 +783,50 @@ const confirmResubmit = (receipt) => {
},
})
}
// ============ 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()
}
</script>
<style scoped>
@@ -754,9 +834,10 @@ const confirmResubmit = (receipt) => {
.status-row {
display: flex;
align-items: center;
gap: 0.375rem;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.5rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
}
.status-chip {
@@ -770,6 +851,7 @@ const confirmResubmit = (receipt) => {
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
border: 2px solid transparent;
}
.status-chip:hover {
@@ -780,13 +862,32 @@ const confirmResubmit = (receipt) => {
background: #e2e8f0;
color: #1e293b;
font-weight: 600;
position: relative;
}
.status-chip.active::after {
content: '';
position: absolute;
bottom: -4px;
left: 0;
right: 0;
height: 3px;
background: #1e293b;
border-radius: 2px;
}
/* Color accents for active status */
.status-chip.status-draft.active { background: #dbeafe; color: #1d4ed8; }
.status-chip.status-pending.active { background: #fef3c7; color: #d97706; }
.status-chip.status-approved.active { background: #dcfce7; color: #16a34a; }
.status-chip.status-rejected.active { background: #fee2e2; color: #dc2626; }
.status-chip.status-draft.active::after { background: #1d4ed8; }
.status-chip.status-pending.active { background: #fef3c7; color: #b45309; }
.status-chip.status-pending.active::after { background: #d97706; }
.status-chip.status-approved.active { background: #dcfce7; color: #15803d; }
.status-chip.status-approved.active::after { background: #16a34a; }
.status-chip.status-rejected.active { background: #fee2e2; color: #b91c1c; }
.status-chip.status-rejected.active::after { background: #dc2626; }
/* Search and Filters Row */
.filters-row {
@@ -1054,4 +1155,45 @@ const confirmResubmit = (receipt) => {
.compact-table :deep(.p-datatable-wrapper) {
overflow-x: auto;
}
/* Bulk Actions Bar */
.bulk-actions-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%);
border: 1px solid #bfdbfe;
border-radius: 6px;
margin-bottom: 0.75rem;
}
.selection-info {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: #1e40af;
}
.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;
}
}
</style>