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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-11 17:30:51 +02:00
parent 5823cedb94
commit 21c12ddb0f
45 changed files with 7524 additions and 0 deletions

View File

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