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:
13
data-entry-app/frontend/index.html
Normal file
13
data-entry-app/frontend/index.html
Normal 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>
|
||||
27
data-entry-app/frontend/package.json
Normal file
27
data-entry-app/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
129
data-entry-app/frontend/src/App.vue
Normal file
129
data-entry-app/frontend/src/App.vue
Normal 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>
|
||||
275
data-entry-app/frontend/src/assets/css/main.css
Normal file
275
data-entry-app/frontend/src/assets/css/main.css
Normal 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%;
|
||||
}
|
||||
}
|
||||
81
data-entry-app/frontend/src/main.js
Normal file
81
data-entry-app/frontend/src/main.js
Normal 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')
|
||||
49
data-entry-app/frontend/src/router/index.js
Normal file
49
data-entry-app/frontend/src/router/index.js
Normal 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
|
||||
365
data-entry-app/frontend/src/stores/receiptsStore.js
Normal file
365
data-entry-app/frontend/src/stores/receiptsStore.js
Normal 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
|
||||
},
|
||||
},
|
||||
})
|
||||
47
data-entry-app/frontend/src/utils/constants.js
Normal file
47
data-entry-app/frontend/src/utils/constants.js
Normal 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)
|
||||
}
|
||||
@@ -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>
|
||||
535
data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue
Normal file
535
data-entry-app/frontend/src/views/receipts/ReceiptCreateView.vue
Normal 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>
|
||||
524
data-entry-app/frontend/src/views/receipts/ReceiptDetailView.vue
Normal file
524
data-entry-app/frontend/src/views/receipts/ReceiptDetailView.vue
Normal 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>
|
||||
339
data-entry-app/frontend/src/views/receipts/ReceiptsListView.vue
Normal file
339
data-entry-app/frontend/src/views/receipts/ReceiptsListView.vue
Normal 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>
|
||||
29
data-entry-app/frontend/vite.config.js
Normal file
29
data-entry-app/frontend/vite.config.js
Normal 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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user