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:
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>
|
||||
Reference in New Issue
Block a user