feat: Add data-entry-app for fiscal receipts with approval workflow

New application for entering fiscal receipts (bonuri fiscale) with:

Backend (FastAPI + SQLModel + Alembic):
- Receipt, ReceiptAttachment, AccountingEntry models
- CRUD operations with async SQLite database
- Workflow: DRAFT → PENDING_REVIEW → APPROVED/REJECTED
- Auto-generation of accounting entries with VAT calculation
- File upload support (images, PDFs)
- Predefined expense types (Fuel, Materials, Office, etc.)
- Nomenclature service for partners, accounts, cash registers

Frontend (Vue.js 3 + PrimeVue + Pinia):
- ReceiptsListView with filters and stats
- ReceiptCreateView with image upload
- ReceiptDetailView with accounting entries
- ReceiptApprovalView for accountant approval

Documentation:
- REQUIREMENTS.md with functional specifications
- ARCHITECTURE.md with technical decisions
- CLAUDE.md for AI assistant guidance

Phase 1 MVP uses SQLite, prepared for Oracle integration in Phase 2.

🤖 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-11 17:30:51 +02:00
parent 5823cedb94
commit 21c12ddb0f
45 changed files with 7524 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ro">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Data Entry - Bonuri Fiscale</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

View File

@@ -0,0 +1,27 @@
{
"name": "data-entry-frontend",
"version": "1.0.0",
"description": "Data Entry App - Vue.js Frontend",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix"
},
"dependencies": {
"vue": "^3.4.0",
"vue-router": "^4.2.5",
"pinia": "^2.1.7",
"axios": "^1.6.5",
"primevue": "^3.48.0",
"primeicons": "^6.0.1",
"@primevue/themes": "^4.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"vite": "^5.0.10",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.20.0"
}
}

View File

@@ -0,0 +1,129 @@
<template>
<div class="app-container">
<header class="app-header">
<div class="header-content">
<h1 class="app-title">
<i class="pi pi-receipt"></i>
Data Entry - Bonuri Fiscale
</h1>
<nav class="app-nav">
<router-link to="/" class="nav-link">
<i class="pi pi-list"></i> Lista Bonuri
</router-link>
<router-link to="/create" class="nav-link">
<i class="pi pi-plus"></i> Bon Nou
</router-link>
<router-link to="/approval" class="nav-link">
<i class="pi pi-check-circle"></i> Aprobare
<Badge v-if="pendingCount > 0" :value="pendingCount" severity="danger" />
</router-link>
</nav>
</div>
</header>
<main class="app-main">
<router-view />
</main>
<Toast position="top-right" />
<ConfirmDialog />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useReceiptsStore } from './stores/receiptsStore'
const receiptsStore = useReceiptsStore()
const pendingCount = ref(0)
onMounted(async () => {
try {
const stats = await receiptsStore.fetchStats()
pendingCount.value = stats?.pending_review?.count || 0
} catch (error) {
console.error('Failed to fetch stats:', error)
}
})
</script>
<style scoped>
.app-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.app-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 2rem;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header-content {
max-width: 1400px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.app-title {
font-size: 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
margin: 0;
}
.app-nav {
display: flex;
gap: 0.5rem;
}
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 6px;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background-color 0.2s;
font-weight: 500;
}
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.nav-link.router-link-active {
background-color: rgba(255, 255, 255, 0.3);
}
.app-main {
flex: 1;
padding: 2rem;
background-color: #f5f7fa;
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
text-align: center;
}
.app-nav {
flex-wrap: wrap;
justify-content: center;
}
.app-main {
padding: 1rem;
}
}
</style>

View File

@@ -0,0 +1,275 @@
/* Global styles for Data Entry App */
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Card styles */
.roa-card {
background: white;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.roa-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid #eee;
}
.roa-card-title {
font-size: 1.25rem;
font-weight: 600;
color: #333;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
/* Form styles */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.form-field {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-field label {
font-weight: 500;
color: #555;
font-size: 0.9rem;
}
.form-field .p-inputtext,
.form-field .p-dropdown,
.form-field .p-calendar,
.form-field .p-inputnumber {
width: 100%;
}
.form-field-full {
grid-column: 1 / -1;
}
/* Status badges */
.status-badge {
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
}
.status-draft {
background-color: #e3f2fd;
color: #1976d2;
}
.status-pending {
background-color: #fff3e0;
color: #f57c00;
}
.status-approved {
background-color: #e8f5e9;
color: #388e3c;
}
.status-rejected {
background-color: #ffebee;
color: #d32f2f;
}
.status-synced {
background-color: #e0f2f1;
color: #00796b;
}
/* Table styles */
.data-table-container {
overflow-x: auto;
}
.p-datatable .p-datatable-header {
background: transparent;
border: none;
padding: 0 0 1rem 0;
}
.p-datatable .p-datatable-thead > tr > th {
background: #f8f9fa;
color: #495057;
font-weight: 600;
}
/* Button groups */
.button-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
/* Upload area */
.upload-area {
border: 2px dashed #ddd;
border-radius: 12px;
padding: 2rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
}
.upload-area:hover {
border-color: #667eea;
background-color: #f8f9ff;
}
.upload-area.has-files {
border-style: solid;
border-color: #667eea;
}
/* Image preview */
.image-preview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.image-preview-item {
position: relative;
border-radius: 8px;
overflow: hidden;
aspect-ratio: 1;
}
.image-preview-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.image-preview-item .remove-btn {
position: absolute;
top: 0.5rem;
right: 0.5rem;
}
/* Accounting entries table */
.entries-table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.entries-table th,
.entries-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #eee;
}
.entries-table th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
}
.entries-table .debit {
color: #d32f2f;
}
.entries-table .credit {
color: #388e3c;
}
/* Stats cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: white;
border-radius: 12px;
padding: 1.25rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
text-align: center;
}
.stat-card .stat-value {
font-size: 2rem;
font-weight: 700;
color: #333;
}
.stat-card .stat-label {
color: #666;
font-size: 0.9rem;
margin-top: 0.25rem;
}
/* Loading state */
.loading-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
}
/* Empty state */
.empty-state {
text-align: center;
padding: 3rem;
color: #666;
}
.empty-state i {
font-size: 4rem;
color: #ddd;
margin-bottom: 1rem;
}
.empty-state h3 {
margin-bottom: 0.5rem;
}
/* Responsive utilities */
@media (max-width: 768px) {
.form-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.button-group {
flex-direction: column;
}
.button-group .p-button {
width: 100%;
}
}

View File

@@ -0,0 +1,81 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import PrimeVue from 'primevue/config'
import ToastService from 'primevue/toastservice'
import ConfirmationService from 'primevue/confirmationservice'
import App from './App.vue'
import router from './router'
// PrimeVue components
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import InputNumber from 'primevue/inputnumber'
import Dropdown from 'primevue/dropdown'
import Calendar from 'primevue/calendar'
import Textarea from 'primevue/textarea'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Dialog from 'primevue/dialog'
import Toast from 'primevue/toast'
import ConfirmDialog from 'primevue/confirmdialog'
import FileUpload from 'primevue/fileupload'
import Image from 'primevue/image'
import Tag from 'primevue/tag'
import Card from 'primevue/card'
import TabView from 'primevue/tabview'
import TabPanel from 'primevue/tabpanel'
import Checkbox from 'primevue/checkbox'
import RadioButton from 'primevue/radiobutton'
import ProgressSpinner from 'primevue/progressspinner'
import Badge from 'primevue/badge'
import Toolbar from 'primevue/toolbar'
import Divider from 'primevue/divider'
// PrimeVue styles
import 'primevue/resources/themes/lara-light-blue/theme.css'
import 'primevue/resources/primevue.min.css'
import 'primeicons/primeicons.css'
// Custom styles
import './assets/css/main.css'
const app = createApp(App)
// Pinia store
app.use(createPinia())
// Router
app.use(router)
// PrimeVue
app.use(PrimeVue, { ripple: true })
app.use(ToastService)
app.use(ConfirmationService)
// Register PrimeVue components globally
app.component('Button', Button)
app.component('InputText', InputText)
app.component('InputNumber', InputNumber)
app.component('Dropdown', Dropdown)
app.component('Calendar', Calendar)
app.component('Textarea', Textarea)
app.component('DataTable', DataTable)
app.component('Column', Column)
app.component('Dialog', Dialog)
app.component('Toast', Toast)
app.component('ConfirmDialog', ConfirmDialog)
app.component('FileUpload', FileUpload)
app.component('Image', Image)
app.component('Tag', Tag)
app.component('Card', Card)
app.component('TabView', TabView)
app.component('TabPanel', TabPanel)
app.component('Checkbox', Checkbox)
app.component('RadioButton', RadioButton)
app.component('ProgressSpinner', ProgressSpinner)
app.component('Badge', Badge)
app.component('Toolbar', Toolbar)
app.component('Divider', Divider)
app.mount('#app')

View File

@@ -0,0 +1,49 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'ReceiptsList',
component: () => import('../views/receipts/ReceiptsListView.vue'),
meta: { title: 'Lista Bonuri' }
},
{
path: '/create',
name: 'ReceiptCreate',
component: () => import('../views/receipts/ReceiptCreateView.vue'),
meta: { title: 'Bon Nou' }
},
{
path: '/receipt/:id',
name: 'ReceiptDetail',
component: () => import('../views/receipts/ReceiptDetailView.vue'),
meta: { title: 'Detalii Bon' }
},
{
path: '/receipt/:id/edit',
name: 'ReceiptEdit',
component: () => import('../views/receipts/ReceiptCreateView.vue'),
meta: { title: 'Editare Bon' }
},
{
path: '/approval',
name: 'ReceiptApproval',
component: () => import('../views/receipts/ReceiptApprovalView.vue'),
meta: { title: 'Aprobare Bonuri' }
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// Update page title
router.beforeEach((to, from, next) => {
document.title = to.meta.title
? `${to.meta.title} | Data Entry`
: 'Data Entry - Bonuri Fiscale'
next()
})
export default router

View File

@@ -0,0 +1,365 @@
import { defineStore } from 'pinia'
import axios from 'axios'
const api = axios.create({
baseURL: '/api/receipts',
headers: {
'Content-Type': 'application/json',
},
})
export const useReceiptsStore = defineStore('receipts', {
state: () => ({
receipts: [],
currentReceipt: null,
pendingReceipts: [],
stats: null,
loading: false,
error: null,
pagination: {
page: 1,
pageSize: 20,
total: 0,
pages: 1,
},
filters: {
status: null,
search: '',
dateFrom: null,
dateTo: null,
},
// Nomenclatures
partners: [],
accounts: [],
cashRegisters: [],
expenseTypes: [],
}),
getters: {
hasReceipts: (state) => state.receipts.length > 0,
hasPendingReceipts: (state) => state.pendingReceipts.length > 0,
pendingCount: (state) => state.pendingReceipts.length,
},
actions: {
// ============ Receipts CRUD ============
async fetchReceipts() {
this.loading = true
this.error = null
try {
const params = {
page: this.pagination.page,
page_size: this.pagination.pageSize,
}
if (this.filters.status) {
params.status = this.filters.status
}
if (this.filters.search) {
params.search = this.filters.search
}
if (this.filters.dateFrom) {
params.date_from = this.filters.dateFrom
}
if (this.filters.dateTo) {
params.date_to = this.filters.dateTo
}
const response = await api.get('/', { params })
this.receipts = response.data.items
this.pagination.total = response.data.total
this.pagination.pages = response.data.pages
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to fetch receipts'
throw error
} finally {
this.loading = false
}
},
async fetchReceiptById(id) {
this.loading = true
this.error = null
try {
const response = await api.get(`/${id}`)
this.currentReceipt = response.data
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to fetch receipt'
throw error
} finally {
this.loading = false
}
},
async createReceipt(data) {
this.loading = true
this.error = null
try {
const response = await api.post('/', data)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to create receipt'
throw error
} finally {
this.loading = false
}
},
async updateReceipt(id, data) {
this.loading = true
this.error = null
try {
const response = await api.put(`/${id}`, data)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to update receipt'
throw error
} finally {
this.loading = false
}
},
async deleteReceipt(id) {
this.loading = true
this.error = null
try {
await api.delete(`/${id}`)
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to delete receipt'
throw error
} finally {
this.loading = false
}
},
// ============ Workflow Actions ============
async submitReceipt(id) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/submit`)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to submit receipt'
throw error
} finally {
this.loading = false
}
},
async approveReceipt(id) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/approve`)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to approve receipt'
throw error
} finally {
this.loading = false
}
},
async rejectReceipt(id, reason) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/reject`, { reason })
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to reject receipt'
throw error
} finally {
this.loading = false
}
},
async resubmitReceipt(id) {
this.loading = true
this.error = null
try {
const response = await api.post(`/${id}/resubmit`)
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to resubmit receipt'
throw error
} finally {
this.loading = false
}
},
// ============ Pending Receipts ============
async fetchPendingReceipts() {
this.loading = true
this.error = null
try {
const response = await api.get('/pending')
this.pendingReceipts = response.data
return response.data
} catch (error) {
this.error = error.response?.data?.detail || 'Failed to fetch pending receipts'
throw error
} finally {
this.loading = false
}
},
// ============ Attachments ============
async uploadAttachment(receiptId, file) {
const formData = new FormData()
formData.append('file', file)
try {
const response = await api.post(`/${receiptId}/attachments`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to upload attachment')
}
},
async deleteAttachment(attachmentId) {
try {
await api.delete(`/attachments/${attachmentId}`)
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to delete attachment')
}
},
getAttachmentUrl(attachmentId) {
return `/api/receipts/attachments/${attachmentId}/download`
},
// ============ Accounting Entries ============
async fetchEntries(receiptId) {
try {
const response = await api.get(`/${receiptId}/entries`)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to fetch entries')
}
},
async updateEntries(receiptId, entries) {
try {
const response = await api.put(`/${receiptId}/entries`, { entries })
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to update entries')
}
},
async regenerateEntries(receiptId) {
try {
const response = await api.post(`/${receiptId}/entries/regenerate`)
return response.data
} catch (error) {
throw new Error(error.response?.data?.detail || 'Failed to regenerate entries')
}
},
// ============ Nomenclatures ============
async fetchPartners(search = '') {
try {
const response = await api.get('/nomenclature/partners', {
params: { search },
})
this.partners = response.data
return response.data
} catch (error) {
console.error('Failed to fetch partners:', error)
return []
}
},
async fetchAccounts(prefix = '') {
try {
const response = await api.get('/nomenclature/accounts', {
params: { prefix },
})
this.accounts = response.data
return response.data
} catch (error) {
console.error('Failed to fetch accounts:', error)
return []
}
},
async fetchCashRegisters() {
try {
const response = await api.get('/nomenclature/cash-registers')
this.cashRegisters = response.data
return response.data
} catch (error) {
console.error('Failed to fetch cash registers:', error)
return []
}
},
async fetchExpenseTypes() {
try {
const response = await api.get('/nomenclature/expense-types')
this.expenseTypes = response.data
return response.data
} catch (error) {
console.error('Failed to fetch expense types:', error)
return []
}
},
async fetchAllNomenclatures() {
await Promise.all([
this.fetchPartners(),
this.fetchCashRegisters(),
this.fetchExpenseTypes(),
])
},
// ============ Stats ============
async fetchStats() {
try {
const response = await api.get('/stats')
this.stats = response.data
return response.data
} catch (error) {
console.error('Failed to fetch stats:', error)
return null
}
},
// ============ Filters & Pagination ============
setFilters(filters) {
this.filters = { ...this.filters, ...filters }
this.pagination.page = 1
},
clearFilters() {
this.filters = {
status: null,
search: '',
dateFrom: null,
dateTo: null,
}
this.pagination.page = 1
},
setPage(page) {
this.pagination.page = page
},
clearCurrentReceipt() {
this.currentReceipt = null
},
},
})

View File

@@ -0,0 +1,47 @@
// Constants for the application
export const EXPENSE_TYPES = {
FUEL: 'Combustibil',
MATERIALS: 'Materiale consumabile',
OFFICE: 'Rechizite birou',
PHONE: 'Telefonie / Internet',
PARKING: 'Parcare',
FOOD: 'Alimentatie',
TRANSPORT: 'Transport',
OTHER: 'Altele',
}
export const RECEIPT_TYPES = {
bon_fiscal: 'Bon Fiscal',
chitanta: 'Chitanta',
}
export const RECEIPT_DIRECTIONS = {
cheltuiala: 'Cheltuiala',
incasare: 'Incasare',
}
export const RECEIPT_STATUSES = {
draft: { label: 'Ciorna', class: 'status-draft', severity: 'info' },
pending_review: { label: 'In asteptare', class: 'status-pending', severity: 'warning' },
approved: { label: 'Aprobat', class: 'status-approved', severity: 'success' },
rejected: { label: 'Respins', class: 'status-rejected', severity: 'danger' },
synced: { label: 'Sincronizat', class: 'status-synced', severity: 'success' },
}
export const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('ro-RO')
}
export const formatDateTime = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('ro-RO')
}
export const formatAmount = (amount, currency = 'RON') => {
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency,
}).format(amount)
}

View File

@@ -0,0 +1,488 @@
<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

@@ -0,0 +1,535 @@
<template>
<div class="receipt-create-view">
<div class="roa-card">
<div class="roa-card-header">
<h2 class="roa-card-title">
<i class="pi pi-plus-circle"></i>
{{ isEditMode ? 'Editare Bon Fiscal' : 'Bon Fiscal Nou' }}
</h2>
<Button
label="Inapoi"
icon="pi pi-arrow-left"
severity="secondary"
@click="$router.push('/')"
/>
</div>
<form @submit.prevent="saveReceipt">
<!-- Upload Section -->
<div class="upload-section">
<h3>
<i class="pi pi-camera"></i>
Poza Bon (obligatoriu)
</h3>
<FileUpload
ref="fileUpload"
mode="advanced"
:multiple="true"
accept="image/*,application/pdf"
:maxFileSize="10000000"
@select="onFileSelect"
@remove="onFileRemove"
:auto="false"
:showUploadButton="false"
:showCancelButton="false"
>
<template #empty>
<div class="upload-area">
<i class="pi pi-cloud-upload" style="font-size: 3rem; color: #667eea;"></i>
<p>Trage fisierele aici sau click pentru a selecta</p>
<p style="font-size: 0.8rem; color: #888;">
Formate acceptate: JPG, PNG, PDF (max 10MB)
</p>
</div>
</template>
</FileUpload>
<!-- Existing attachments (edit mode) -->
<div v-if="existingAttachments.length" class="image-preview-grid">
<div
v-for="att in existingAttachments"
:key="att.id"
class="image-preview-item"
>
<img
v-if="att.mime_type?.startsWith('image/')"
:src="store.getAttachmentUrl(att.id)"
:alt="att.filename"
/>
<div v-else class="pdf-preview">
<i class="pi pi-file-pdf" style="font-size: 3rem;"></i>
<span>{{ att.filename }}</span>
</div>
<Button
icon="pi pi-times"
severity="danger"
rounded
class="remove-btn"
@click="removeExistingAttachment(att.id)"
/>
</div>
</div>
</div>
<Divider />
<!-- Receipt Details -->
<h3>
<i class="pi pi-info-circle"></i>
Detalii Bon
</h3>
<div class="form-grid">
<div class="form-field">
<label>Tip Document *</label>
<div class="radio-group">
<div class="radio-item">
<RadioButton
v-model="form.receipt_type"
value="bon_fiscal"
inputId="type_bon"
/>
<label for="type_bon">Bon Fiscal</label>
</div>
<div class="radio-item">
<RadioButton
v-model="form.receipt_type"
value="chitanta"
inputId="type_chitanta"
/>
<label for="type_chitanta">Chitanta</label>
</div>
</div>
</div>
<div class="form-field">
<label>Directie *</label>
<div class="radio-group">
<div class="radio-item">
<RadioButton
v-model="form.direction"
value="cheltuiala"
inputId="dir_cheltuiala"
/>
<label for="dir_cheltuiala">Cheltuiala</label>
</div>
<div class="radio-item">
<RadioButton
v-model="form.direction"
value="incasare"
inputId="dir_incasare"
/>
<label for="dir_incasare">Incasare</label>
</div>
</div>
</div>
<div class="form-field">
<label>Data Bon *</label>
<Calendar
v-model="form.receipt_date"
dateFormat="dd.mm.yy"
showIcon
required
/>
</div>
<div class="form-field">
<label>Suma (RON) *</label>
<InputNumber
v-model="form.amount"
mode="currency"
currency="RON"
locale="ro-RO"
:minFractionDigits="2"
:maxFractionDigits="2"
required
/>
</div>
<div class="form-field">
<label>Furnizor</label>
<Dropdown
v-model="form.partner_id"
:options="partners"
optionLabel="name"
optionValue="id"
placeholder="Selecteaza furnizor"
filter
showClear
@change="onPartnerChange"
/>
</div>
<div class="form-field">
<label>Tip Cheltuiala *</label>
<Dropdown
v-model="form.expense_type_code"
:options="expenseTypes"
optionLabel="name"
optionValue="code"
placeholder="Selecteaza tip"
required
/>
</div>
<div class="form-field">
<label>Casa / Banca *</label>
<Dropdown
v-model="form.cash_register_id"
:options="cashRegisters"
optionLabel="name"
optionValue="id"
placeholder="Selecteaza casa/banca"
@change="onCashRegisterChange"
required
/>
</div>
<div class="form-field">
<label>Numar Bon</label>
<InputText v-model="form.receipt_number" placeholder="Optional" />
</div>
<div class="form-field form-field-full">
<label>Descriere</label>
<Textarea
v-model="form.description"
rows="3"
placeholder="Detalii suplimentare..."
/>
</div>
</div>
<Divider />
<!-- Actions -->
<div class="button-group" style="justify-content: flex-end;">
<Button
type="button"
label="Anuleaza"
icon="pi pi-times"
severity="secondary"
@click="$router.push('/')"
/>
<Button
type="submit"
label="Salveaza Ciorna"
icon="pi pi-save"
:loading="saving"
/>
<Button
v-if="isEditMode && receipt?.status === 'draft'"
type="button"
label="Trimite spre aprobare"
icon="pi pi-send"
severity="success"
:loading="submitting"
@click="submitForReview"
/>
</div>
</form>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useReceiptsStore } from '../../stores/receiptsStore'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const store = useReceiptsStore()
const isEditMode = computed(() => !!route.params.id)
const receiptId = computed(() => route.params.id)
const receipt = ref(null)
const form = ref({
receipt_type: 'bon_fiscal',
direction: 'cheltuiala',
receipt_date: new Date(),
amount: null,
partner_id: null,
partner_name: null,
expense_type_code: null,
cash_register_id: null,
cash_register_name: null,
cash_register_account: null,
receipt_number: '',
description: '',
company_id: 1, // Default company for Phase 1
})
const selectedFiles = ref([])
const existingAttachments = ref([])
const saving = ref(false)
const submitting = ref(false)
const partners = computed(() => store.partners)
const expenseTypes = computed(() => store.expenseTypes)
const cashRegisters = computed(() => store.cashRegisters)
onMounted(async () => {
await store.fetchAllNomenclatures()
if (isEditMode.value) {
await loadReceipt()
}
})
const loadReceipt = async () => {
try {
receipt.value = await store.fetchReceiptById(receiptId.value)
// Populate form
form.value = {
receipt_type: receipt.value.receipt_type,
direction: receipt.value.direction,
receipt_date: new Date(receipt.value.receipt_date),
amount: parseFloat(receipt.value.amount),
partner_id: receipt.value.partner_id,
partner_name: receipt.value.partner_name,
expense_type_code: receipt.value.expense_type_code,
cash_register_id: receipt.value.cash_register_id,
cash_register_name: receipt.value.cash_register_name,
cash_register_account: receipt.value.cash_register_account,
receipt_number: receipt.value.receipt_number || '',
description: receipt.value.description || '',
company_id: receipt.value.company_id,
}
existingAttachments.value = receipt.value.attachments || []
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-a putut incarca bonul',
life: 5000,
})
router.push('/')
}
}
const onPartnerChange = (event) => {
const partner = partners.value.find(p => p.id === event.value)
form.value.partner_name = partner?.name || null
}
const onCashRegisterChange = (event) => {
const cr = cashRegisters.value.find(c => c.id === event.value)
form.value.cash_register_name = cr?.name || null
form.value.cash_register_account = cr?.account_code || null
}
const onFileSelect = (event) => {
selectedFiles.value = [...selectedFiles.value, ...event.files]
}
const onFileRemove = (event) => {
selectedFiles.value = selectedFiles.value.filter(f => f.name !== event.file.name)
}
const removeExistingAttachment = async (attachmentId) => {
try {
await store.deleteAttachment(attachmentId)
existingAttachments.value = existingAttachments.value.filter(a => a.id !== attachmentId)
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Atasamentul a fost sters',
life: 3000,
})
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message,
life: 5000,
})
}
}
const validateForm = () => {
if (!form.value.receipt_date) {
toast.add({
severity: 'warn',
summary: 'Validare',
detail: 'Data bonului este obligatorie',
life: 3000,
})
return false
}
if (!form.value.amount || form.value.amount <= 0) {
toast.add({
severity: 'warn',
summary: 'Validare',
detail: 'Suma trebuie sa fie mai mare decat 0',
life: 3000,
})
return false
}
if (!form.value.expense_type_code) {
toast.add({
severity: 'warn',
summary: 'Validare',
detail: 'Tipul cheltuielii este obligatoriu',
life: 3000,
})
return false
}
if (!form.value.cash_register_id) {
toast.add({
severity: 'warn',
summary: 'Validare',
detail: 'Casa/Banca este obligatorie',
life: 3000,
})
return false
}
return true
}
const saveReceipt = async () => {
if (!validateForm()) return
saving.value = true
try {
const data = {
...form.value,
receipt_date: form.value.receipt_date.toISOString().split('T')[0],
}
let savedReceipt
if (isEditMode.value) {
savedReceipt = await store.updateReceipt(receiptId.value, data)
} else {
savedReceipt = await store.createReceipt(data)
}
// Upload new files
for (const file of selectedFiles.value) {
try {
await store.uploadAttachment(savedReceipt.id, file)
} catch (error) {
toast.add({
severity: 'warn',
summary: 'Atentie',
detail: `Nu s-a putut incarca: ${file.name}`,
life: 5000,
})
}
}
toast.add({
severity: 'success',
summary: 'Succes',
detail: isEditMode.value ? 'Bonul a fost actualizat' : 'Bonul a fost creat',
life: 3000,
})
router.push(`/receipt/${savedReceipt.id}`)
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut salva bonul',
life: 5000,
})
} finally {
saving.value = false
}
}
const submitForReview = async () => {
// First save any changes
if (!validateForm()) return
submitting.value = true
try {
// Save first
await saveReceipt()
// Then submit
const result = await store.submitReceipt(receiptId.value)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost trimis spre aprobare',
life: 3000,
})
router.push('/')
} else {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: result.message,
life: 5000,
})
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut trimite bonul',
life: 5000,
})
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.upload-section {
margin-bottom: 1.5rem;
}
.upload-section h3 {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 1rem;
}
.radio-group {
display: flex;
gap: 1.5rem;
}
.radio-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.pdf-preview {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
background: #f5f5f5;
padding: 1rem;
}
.pdf-preview span {
font-size: 0.75rem;
margin-top: 0.5rem;
text-align: center;
word-break: break-word;
}
</style>

View File

@@ -0,0 +1,524 @@
<template>
<div class="receipt-detail-view">
<!-- Loading -->
<div v-if="loading" class="loading-container">
<ProgressSpinner />
</div>
<template v-else-if="receipt">
<!-- Header Card -->
<div class="roa-card">
<div class="roa-card-header">
<div>
<h2 class="roa-card-title">
<i class="pi pi-receipt"></i>
Bon #{{ receipt.id }}
</h2>
<span :class="['status-badge', getStatusClass(receipt.status)]">
{{ getStatusLabel(receipt.status) }}
</span>
</div>
<div class="button-group">
<Button
label="Inapoi"
icon="pi pi-arrow-left"
severity="secondary"
@click="$router.push('/')"
/>
<Button
v-if="receipt.status === 'draft'"
label="Editeaza"
icon="pi pi-pencil"
@click="$router.push(`/receipt/${receipt.id}/edit`)"
/>
<Button
v-if="receipt.status === 'draft'"
label="Trimite spre aprobare"
icon="pi pi-send"
severity="success"
@click="submitReceipt"
:loading="submitting"
/>
<Button
v-if="receipt.status === 'rejected'"
label="Re-trimite"
icon="pi pi-refresh"
severity="warning"
@click="resubmitReceipt"
:loading="submitting"
/>
</div>
</div>
<!-- Rejection Reason -->
<div v-if="receipt.rejection_reason" class="rejection-alert">
<i class="pi pi-exclamation-triangle"></i>
<div>
<strong>Motiv respingere:</strong>
<p>{{ receipt.rejection_reason }}</p>
<small>Respins de {{ receipt.reviewed_by }} la {{ formatDateTime(receipt.reviewed_at) }}</small>
</div>
</div>
</div>
<div class="detail-grid">
<!-- Receipt Details -->
<div class="roa-card">
<h3>
<i class="pi pi-info-circle"></i>
Detalii Bon
</h3>
<div class="detail-list">
<div class="detail-item">
<span class="label">Tip Document</span>
<span class="value">
{{ receipt.receipt_type === 'bon_fiscal' ? 'Bon Fiscal' : 'Chitanta' }}
</span>
</div>
<div class="detail-item">
<span class="label">Directie</span>
<span class="value">
{{ receipt.direction === 'cheltuiala' ? 'Cheltuiala' : 'Incasare' }}
</span>
</div>
<div class="detail-item">
<span class="label">Data</span>
<span class="value">{{ formatDate(receipt.receipt_date) }}</span>
</div>
<div class="detail-item">
<span class="label">Suma</span>
<span class="value amount">{{ formatAmount(receipt.amount) }}</span>
</div>
<div class="detail-item">
<span class="label">Furnizor</span>
<span class="value">{{ receipt.partner_name || '-' }}</span>
</div>
<div class="detail-item">
<span class="label">Tip Cheltuiala</span>
<span class="value">{{ getExpenseTypeName(receipt.expense_type_code) }}</span>
</div>
<div class="detail-item">
<span class="label">Casa/Banca</span>
<span class="value">{{ receipt.cash_register_name || '-' }}</span>
</div>
<div class="detail-item" v-if="receipt.receipt_number">
<span class="label">Numar Bon</span>
<span class="value">{{ receipt.receipt_number }}</span>
</div>
<div class="detail-item" v-if="receipt.description">
<span class="label">Descriere</span>
<span class="value">{{ receipt.description }}</span>
</div>
</div>
<Divider />
<div class="detail-list">
<div class="detail-item">
<span class="label">Creat de</span>
<span class="value">{{ receipt.created_by }}</span>
</div>
<div class="detail-item">
<span class="label">Creat la</span>
<span class="value">{{ formatDateTime(receipt.created_at) }}</span>
</div>
<div class="detail-item" v-if="receipt.submitted_at">
<span class="label">Trimis la</span>
<span class="value">{{ formatDateTime(receipt.submitted_at) }}</span>
</div>
<div class="detail-item" v-if="receipt.reviewed_by">
<span class="label">Revizuit de</span>
<span class="value">{{ receipt.reviewed_by }}</span>
</div>
<div class="detail-item" v-if="receipt.reviewed_at">
<span class="label">Revizuit la</span>
<span class="value">{{ formatDateTime(receipt.reviewed_at) }}</span>
</div>
</div>
</div>
<!-- Attachments -->
<div class="roa-card">
<h3>
<i class="pi pi-images"></i>
Atasamente ({{ receipt.attachments?.length || 0 }})
</h3>
<div v-if="receipt.attachments?.length" class="attachments-grid">
<div
v-for="att in receipt.attachments"
:key="att.id"
class="attachment-item"
>
<template v-if="att.mime_type?.startsWith('image/')">
<Image
:src="store.getAttachmentUrl(att.id)"
:alt="att.filename"
preview
class="attachment-image"
/>
</template>
<template v-else>
<a
:href="store.getAttachmentUrl(att.id)"
target="_blank"
class="pdf-link"
>
<i class="pi pi-file-pdf"></i>
{{ att.filename }}
</a>
</template>
</div>
</div>
<div v-else class="empty-state">
<i class="pi pi-image"></i>
<p>Niciun atasament</p>
</div>
</div>
</div>
<!-- Accounting Entries -->
<div class="roa-card">
<h3>
<i class="pi pi-book"></i>
Note Contabile
</h3>
<div v-if="receipt.entries?.length" class="entries-table-container">
<table class="entries-table">
<thead>
<tr>
<th>Tip</th>
<th>Cont</th>
<th>Denumire Cont</th>
<th style="text-align: right;">Suma</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in receipt.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>
<tfoot>
<tr>
<td colspan="3" style="text-align: right;"><strong>Total Debit:</strong></td>
<td class="debit" style="text-align: right;">
<strong>{{ formatAmount(totalDebit) }}</strong>
</td>
</tr>
<tr>
<td colspan="3" style="text-align: right;"><strong>Total Credit:</strong></td>
<td class="credit" style="text-align: right;">
<strong>{{ formatAmount(totalCredit) }}</strong>
</td>
</tr>
</tfoot>
</table>
<div v-if="!isBalanced" class="balance-warning">
<i class="pi pi-exclamation-triangle"></i>
Atentie: Notele contabile nu sunt echilibrate!
</div>
</div>
<div v-else class="empty-state">
<i class="pi pi-book"></i>
<p>Notele contabile vor fi generate la trimiterea spre aprobare</p>
</div>
</div>
</template>
<!-- Not Found -->
<div v-else class="empty-state">
<i class="pi pi-exclamation-circle"></i>
<h3>Bonul nu a fost gasit</h3>
<Button label="Inapoi la lista" @click="$router.push('/')" />
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useReceiptsStore } from '../../stores/receiptsStore'
import { EXPENSE_TYPES } from '../../utils/constants'
const route = useRoute()
const router = useRouter()
const toast = useToast()
const store = useReceiptsStore()
const receipt = ref(null)
const loading = ref(true)
const submitting = ref(false)
const totalDebit = computed(() => {
if (!receipt.value?.entries) return 0
return receipt.value.entries
.filter(e => e.entry_type === 'debit')
.reduce((sum, e) => sum + parseFloat(e.amount), 0)
})
const totalCredit = computed(() => {
if (!receipt.value?.entries) return 0
return receipt.value.entries
.filter(e => e.entry_type === 'credit')
.reduce((sum, e) => sum + parseFloat(e.amount), 0)
})
const isBalanced = computed(() => {
return Math.abs(totalDebit.value - totalCredit.value) < 0.01
})
onMounted(async () => {
await loadReceipt()
})
const loadReceipt = async () => {
loading.value = true
try {
receipt.value = await store.fetchReceiptById(route.params.id)
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: 'Nu s-a putut incarca bonul',
life: 5000,
})
} finally {
loading.value = false
}
}
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('ro-RO')
}
const formatDateTime = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('ro-RO')
}
const formatAmount = (amount) => {
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
}).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: 'Ciorna',
pending_review: 'In asteptare',
approved: 'Aprobat',
rejected: 'Respins',
synced: 'Sincronizat',
}
return labels[status] || status
}
const getExpenseTypeName = (code) => {
return EXPENSE_TYPES[code] || code || '-'
}
const submitReceipt = async () => {
submitting.value = true
try {
const result = await store.submitReceipt(receipt.value.id)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost trimis spre aprobare',
life: 3000,
})
await loadReceipt()
} else {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: result.message,
life: 5000,
})
}
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut trimite bonul',
life: 5000,
})
} finally {
submitting.value = false
}
}
const resubmitReceipt = async () => {
submitting.value = true
try {
const result = await store.resubmitReceipt(receipt.value.id)
if (result.success) {
toast.add({
severity: 'success',
summary: 'Succes',
detail: 'Bonul a fost re-trimis spre aprobare',
life: 3000,
})
await loadReceipt()
} 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 re-trimite bonul',
life: 5000,
})
} finally {
submitting.value = false
}
}
</script>
<style scoped>
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 768px) {
.detail-grid {
grid-template-columns: 1fr;
}
}
.detail-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.detail-item {
display: flex;
justify-content: space-between;
padding: 0.5rem 0;
border-bottom: 1px solid #f0f0f0;
}
.detail-item .label {
color: #666;
font-size: 0.9rem;
}
.detail-item .value {
font-weight: 500;
}
.detail-item .value.amount {
font-size: 1.1rem;
color: #333;
}
.rejection-alert {
display: flex;
gap: 1rem;
padding: 1rem;
background: #fff3e0;
border-radius: 8px;
margin-top: 1rem;
}
.rejection-alert i {
font-size: 1.5rem;
color: #f57c00;
}
.rejection-alert p {
margin: 0.5rem 0;
}
.rejection-alert small {
color: #666;
}
.attachments-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 1rem;
}
.attachment-item {
border-radius: 8px;
overflow: hidden;
}
.attachment-image {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
}
.pdf-link {
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
background: #f5f5f5;
border-radius: 8px;
text-decoration: none;
color: #333;
}
.pdf-link i {
font-size: 3rem;
color: #d32f2f;
margin-bottom: 0.5rem;
}
.entries-table-container {
overflow-x: auto;
}
.balance-warning {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
padding: 0.75rem;
background: #fff3e0;
border-radius: 8px;
color: #f57c00;
}
</style>

View File

@@ -0,0 +1,339 @@
<template>
<div class="receipts-list-view">
<!-- Stats Cards -->
<div class="stats-grid" v-if="stats">
<div class="stat-card">
<div class="stat-value">{{ stats.draft?.count || 0 }}</div>
<div class="stat-label">Ciorne</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.pending_review?.count || 0 }}</div>
<div class="stat-label">In asteptare</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.approved?.count || 0 }}</div>
<div class="stat-label">Aprobate</div>
</div>
<div class="stat-card">
<div class="stat-value">{{ stats.rejected?.count || 0 }}</div>
<div class="stat-label">Respinse</div>
</div>
</div>
<!-- Main Card -->
<div class="roa-card">
<div class="roa-card-header">
<h2 class="roa-card-title">
<i class="pi pi-list"></i>
Lista Bonuri Fiscale
</h2>
<Button
label="Bon Nou"
icon="pi pi-plus"
@click="$router.push('/create')"
/>
</div>
<!-- Filters -->
<div class="filters-section">
<div class="form-grid">
<div class="form-field">
<label>Status</label>
<Dropdown
v-model="filters.status"
:options="statusOptions"
optionLabel="label"
optionValue="value"
placeholder="Toate"
showClear
@change="onFilterChange"
/>
</div>
<div class="form-field">
<label>Cautare</label>
<InputText
v-model="filters.search"
placeholder="Furnizor, descriere..."
@keyup.enter="onFilterChange"
/>
</div>
<div class="form-field">
<label>De la data</label>
<Calendar
v-model="filters.dateFrom"
dateFormat="dd.mm.yy"
showIcon
@date-select="onFilterChange"
/>
</div>
<div class="form-field">
<label>Pana la data</label>
<Calendar
v-model="filters.dateTo"
dateFormat="dd.mm.yy"
showIcon
@date-select="onFilterChange"
/>
</div>
</div>
<div class="button-group" style="margin-top: 1rem;">
<Button
label="Filtreaza"
icon="pi pi-search"
@click="onFilterChange"
/>
<Button
label="Reseteaza"
icon="pi pi-times"
severity="secondary"
@click="clearFilters"
/>
</div>
</div>
<Divider />
<!-- Loading -->
<div v-if="loading" class="loading-container">
<ProgressSpinner />
</div>
<!-- Empty State -->
<div v-else-if="!receipts.length" class="empty-state">
<i class="pi pi-inbox"></i>
<h3>Niciun bon gasit</h3>
<p>Creaza primul bon fiscal folosind butonul "Bon Nou"</p>
</div>
<!-- Data Table -->
<div v-else class="data-table-container">
<DataTable
:value="receipts"
:paginator="true"
:rows="pagination.pageSize"
:totalRecords="pagination.total"
:lazy="true"
@page="onPageChange"
responsiveLayout="scroll"
stripedRows
>
<Column field="receipt_date" header="Data" style="width: 100px">
<template #body="{ data }">
{{ formatDate(data.receipt_date) }}
</template>
</Column>
<Column field="receipt_type" header="Tip" style="width: 100px">
<template #body="{ data }">
<Tag :value="data.receipt_type === 'bon_fiscal' ? 'Bon' : 'Chitanta'" />
</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="status" header="Status" style="width: 130px">
<template #body="{ data }">
<span :class="['status-badge', getStatusClass(data.status)]">
{{ getStatusLabel(data.status) }}
</span>
</template>
</Column>
<Column field="attachments" header="Atasamente" style="width: 100px">
<template #body="{ data }">
<Badge :value="data.attachments?.length || 0" />
</template>
</Column>
<Column header="Actiuni" style="width: 150px">
<template #body="{ data }">
<div class="button-group">
<Button
icon="pi pi-eye"
severity="info"
text
rounded
@click="viewReceipt(data.id)"
v-tooltip="'Vizualizeaza'"
/>
<Button
v-if="data.status === 'draft'"
icon="pi pi-pencil"
severity="warning"
text
rounded
@click="editReceipt(data.id)"
v-tooltip="'Editeaza'"
/>
<Button
v-if="data.status === 'draft'"
icon="pi pi-trash"
severity="danger"
text
rounded
@click="confirmDelete(data)"
v-tooltip="'Sterge'"
/>
</div>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import { useReceiptsStore } from '../../stores/receiptsStore'
const router = useRouter()
const toast = useToast()
const confirm = useConfirm()
const store = useReceiptsStore()
const filters = ref({
status: null,
search: '',
dateFrom: null,
dateTo: null,
})
const statusOptions = [
{ label: 'Ciorna', value: 'draft' },
{ label: 'In asteptare', value: 'pending_review' },
{ label: 'Aprobat', value: 'approved' },
{ label: 'Respins', value: 'rejected' },
]
const receipts = computed(() => store.receipts)
const loading = computed(() => store.loading)
const pagination = computed(() => store.pagination)
const stats = computed(() => store.stats)
onMounted(async () => {
await store.fetchStats()
await store.fetchReceipts()
})
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('ro-RO')
}
const formatAmount = (amount) => {
return new Intl.NumberFormat('ro-RO', {
style: 'currency',
currency: 'RON',
}).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: 'Ciorna',
pending_review: 'In asteptare',
approved: 'Aprobat',
rejected: 'Respins',
synced: 'Sincronizat',
}
return labels[status] || status
}
const onFilterChange = async () => {
store.setFilters({
status: filters.value.status,
search: filters.value.search,
dateFrom: filters.value.dateFrom
? filters.value.dateFrom.toISOString().split('T')[0]
: null,
dateTo: filters.value.dateTo
? filters.value.dateTo.toISOString().split('T')[0]
: null,
})
await store.fetchReceipts()
}
const clearFilters = async () => {
filters.value = {
status: null,
search: '',
dateFrom: null,
dateTo: null,
}
store.clearFilters()
await store.fetchReceipts()
}
const onPageChange = async (event) => {
store.setPage(event.page + 1)
await store.fetchReceipts()
}
const viewReceipt = (id) => {
router.push(`/receipt/${id}`)
}
const editReceipt = (id) => {
router.push(`/receipt/${id}/edit`)
}
const confirmDelete = (receipt) => {
confirm.require({
message: `Sigur doriti sa stergeti acest bon?`,
header: 'Confirmare stergere',
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 sters',
life: 3000,
})
await store.fetchReceipts()
await store.fetchStats()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Eroare',
detail: error.message || 'Nu s-a putut sterge bonul',
life: 5000,
})
}
},
})
}
</script>
<style scoped>
.filters-section {
margin-bottom: 1rem;
}
</style>

View File

@@ -0,0 +1,29 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 3010,
proxy: {
'/api': {
target: 'http://localhost:8003',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:8003',
changeOrigin: true,
}
}
},
build: {
outDir: 'dist',
sourcemap: true,
}
})