feat: Add Trial Balance (Balanță de Verificare) feature

Comprehensive implementation of Trial Balance page with filtering,
pagination, and sorting capabilities.

Backend Changes:
- Added Pydantic models for Trial Balance (trial_balance.py)
  - TrialBalanceItem: Individual balance record
  - TrialBalanceFilters: Filter parameters
  - TrialBalancePagination: Pagination metadata
  - TrialBalanceResponse: Complete API response
- Created FastAPI router (/api/trial-balance) with:
  - Filtering by account number (cont) and description (denumire)
  - Pagination support (configurable page size)
  - Sorting on all columns (ascendent/descendent)
  - Company-based access control via JWT
  - Query against Oracle VBAL table
- Registered router in main.py

Frontend Changes:
- Created Pinia store (trialBalanceStore.js) with:
  - State management for trial balance data
  - Filters (luna, an, cont, denumire)
  - Pagination controls
  - Sorting functionality
  - Error handling and loading states
- Built TrialBalanceView.vue component featuring:
  - PrimeVue DataTable with responsive design
  - Period display (month/year)
  - Dual input filters (account number + description)
  - Debounced search (500ms)
  - Clear filters functionality
  - Formatted currency display (Romanian locale)
  - Balance columns (Debit/Credit) for:
    - Sold Precedent (Previous Balance)
    - Rulaj Lunar (Monthly Movement)
    - Sold Final (Final Balance)
  - Loading spinner and empty state
  - Mobile-friendly responsive layout
- Added route: /trial-balance with auth guard
- Added menu item in HamburgerMenu (Navigation section)
  - Icon: pi-calculator
  - Label: "Balanță de Verificare"

Technical Details:
- Follows established CSS architecture (no :deep(), uses design tokens)
- Consistent with InvoicesView patterns
- Implements proper error handling
- Uses Oracle NVL for null value handling
- ROW_NUMBER pagination for Oracle compatibility

Testing: Manual testing required (Phase 5)

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-20 00:35:45 +02:00
parent 2b2002bbe8
commit 0b00b66ed5
7 changed files with 936 additions and 1 deletions

View File

@@ -46,6 +46,17 @@
<span>Bank & Cash</span>
</router-link>
</li>
<li class="menu-item">
<router-link
to="/trial-balance"
class="menu-link"
:class="{ active: $route.name === 'TrialBalance' }"
@click="closeMenu"
>
<i class="menu-icon pi pi-calculator"></i>
<span>Balanță de Verificare</span>
</router-link>
</li>
</ul>
</div>

View File

@@ -8,6 +8,7 @@ import InvoicesView from "../views/InvoicesView.vue";
import BankCashRegisterView from "../views/BankCashRegisterView.vue";
import TelegramView from "../views/TelegramView.vue";
import CacheStatsView from "../views/CacheStatsView.vue";
import TrialBalanceView from "../views/TrialBalanceView.vue";
const routes = [
{
@@ -68,6 +69,15 @@ const routes = [
title: "Cache Statistics - ROA Reports",
},
},
{
path: "/trial-balance",
name: "TrialBalance",
component: TrialBalanceView,
meta: {
requiresAuth: true,
title: "Balanță de Verificare - ROA Reports",
},
},
{
path: "/:pathMatch(.*)*",
name: "NotFound",

View File

@@ -0,0 +1,191 @@
/**
* Pinia Store for Trial Balance (Balanță de Verificare)
*/
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { apiService } from "../services/api";
export const useTrialBalanceStore = defineStore("trialBalance", () => {
// State
const trialBalanceData = ref([]);
const isLoading = ref(false);
const error = ref(null);
const filters = ref({
luna: new Date().getMonth() + 1, // Current month (1-12)
an: new Date().getFullYear(), // Current year
cont: "",
denumire: "",
});
const pagination = ref({
currentPage: 1,
pageSize: 50,
totalItems: 0,
totalPages: 0,
});
const sorting = ref({
sortBy: "CONT",
sortOrder: "asc",
});
// Getters
const hasData = computed(() => trialBalanceData.value.length > 0);
const currentPeriod = computed(() => {
return {
luna: filters.value.luna,
an: filters.value.an,
};
});
// Actions
const fetchTrialBalance = async (companyCode) => {
if (!companyCode) {
error.value = "Company code is required";
return { success: false, error: error.value };
}
isLoading.value = true;
error.value = null;
try {
const params = {
company: companyCode,
luna: filters.value.luna,
an: filters.value.an,
page: pagination.value.currentPage,
page_size: pagination.value.pageSize,
sort_by: sorting.value.sortBy,
sort_order: sorting.value.sortOrder,
};
// Add optional filters
if (filters.value.cont) {
params.cont_filter = filters.value.cont;
}
if (filters.value.denumire) {
params.denumire_filter = filters.value.denumire;
}
const response = await apiService.get("/trial-balance/", { params });
if (response.data.success) {
trialBalanceData.value = response.data.data.items || [];
// Update pagination
const paginationData = response.data.data.pagination;
pagination.value = {
currentPage: paginationData.current_page,
pageSize: paginationData.page_size,
totalItems: paginationData.total_items,
totalPages: paginationData.total_pages,
};
return { success: true };
} else {
throw new Error("Invalid response format");
}
} catch (err) {
error.value =
err.response?.data?.detail || "Failed to load trial balance data";
console.error("Failed to load trial balance:", err);
return { success: false, error: error.value };
} finally {
isLoading.value = false;
}
};
const applyFilters = async (newFilters, companyCode) => {
filters.value = { ...filters.value, ...newFilters };
pagination.value.currentPage = 1; // Reset to first page when filtering
await fetchTrialBalance(companyCode);
};
const clearFilters = async (companyCode) => {
filters.value = {
luna: new Date().getMonth() + 1,
an: new Date().getFullYear(),
cont: "",
denumire: "",
};
pagination.value.currentPage = 1;
await fetchTrialBalance(companyCode);
};
const changePage = async (page, companyCode) => {
pagination.value.currentPage = page;
await fetchTrialBalance(companyCode);
};
const changePageSize = async (pageSize, companyCode) => {
pagination.value.pageSize = pageSize;
pagination.value.currentPage = 1; // Reset to first page
await fetchTrialBalance(companyCode);
};
const sort = async (sortBy, sortOrder, companyCode) => {
sorting.value = { sortBy, sortOrder };
pagination.value.currentPage = 1; // Reset to first page when sorting
await fetchTrialBalance(companyCode);
};
const changePeriod = async (luna, an, companyCode) => {
filters.value.luna = luna;
filters.value.an = an;
pagination.value.currentPage = 1;
await fetchTrialBalance(companyCode);
};
const clearError = () => {
error.value = null;
};
const reset = () => {
trialBalanceData.value = [];
isLoading.value = false;
error.value = null;
filters.value = {
luna: new Date().getMonth() + 1,
an: new Date().getFullYear(),
cont: "",
denumire: "",
};
pagination.value = {
currentPage: 1,
pageSize: 50,
totalItems: 0,
totalPages: 0,
};
sorting.value = {
sortBy: "CONT",
sortOrder: "asc",
};
};
return {
// State
trialBalanceData,
isLoading,
error,
filters,
pagination,
sorting,
// Getters
hasData,
currentPeriod,
// Actions
fetchTrialBalance,
applyFilters,
clearFilters,
changePage,
changePageSize,
sort,
changePeriod,
clearError,
reset,
};
});

View File

@@ -0,0 +1,436 @@
<template>
<div class="app-container">
<div class="trial-balance">
<!-- Page Header -->
<div class="page-header">
<h1 class="page-title">
<i class="pi pi-calculator"></i>
Balanță de Verificare
</h1>
<p class="page-subtitle">
{{ currentPeriodText }} - {{ companyStore.selectedCompany?.firma || "Selectați companie" }}
</p>
</div>
<!-- Company Selection -->
<Card v-if="!companyStore.selectedCompany" class="company-selection-card">
<template #content>
<div class="company-selection">
<p class="text-color-secondary mb-3">
Selectați o companie pentru a vizualiza balanța de verificare:
</p>
<Dropdown
v-model="selectedCompanyId"
:options="companyStore.companyListFormatted"
option-label="displayName"
option-value="id_firma"
placeholder="Alegeți compania"
class="w-full"
@change="handleCompanyChange"
/>
</div>
</template>
</Card>
<!-- Filters Section -->
<Card v-if="companyStore.selectedCompany" class="filters-card">
<template #content>
<div class="form">
<div class="form-row">
<!-- Cont Filter -->
<div class="form-col">
<div class="form-group">
<label class="form-label">Număr Cont</label>
<InputText
v-model="localFilters.cont"
placeholder="Ex: 512, 4111"
class="w-full"
@input="handleFilterChange"
/>
</div>
</div>
<!-- Denumire Filter -->
<div class="form-col search-col">
<div class="form-group">
<label class="form-label">Denumire Cont</label>
<InputText
v-model="localFilters.denumire"
placeholder="Căutare după denumire..."
class="w-full"
@input="handleSearchChange"
/>
</div>
</div>
</div>
<div class="filters-actions">
<Button
icon="pi pi-filter-slash"
label="Resetează Filtre"
class="p-button-outlined p-button-secondary"
@click="clearFilters"
/>
<Button
icon="pi pi-refresh"
label="Actualizează"
:loading="trialBalanceStore.isLoading"
@click="refreshData"
/>
</div>
</div>
</template>
</Card>
<!-- Trial Balance Table -->
<Card v-if="companyStore.selectedCompany" class="table-card">
<template #content>
<DataTable
:value="trialBalanceStore.trialBalanceData"
:loading="trialBalanceStore.isLoading"
:paginator="true"
:rows="trialBalanceStore.pagination.pageSize"
:total-records="trialBalanceStore.pagination.totalItems"
:lazy="true"
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink CurrentPageReport RowsPerPageDropdown"
:rows-per-page-options="[25, 50, 100]"
current-page-report-template="Afișare {first} - {last} din {totalRecords} înregistrări"
responsive-layout="scroll"
@page="onPageChange"
@sort="onSort"
>
<template #empty>
<div class="no-data">
<i class="pi pi-info-circle"></i>
<p>Nu au fost găsite date pentru perioada selectată</p>
</div>
</template>
<template #loading>
<div class="loading-table">
<ProgressSpinner />
<p>Se încarcă balanța de verificare...</p>
</div>
</template>
<Column field="cont" header="Cont" sortable :style="{ width: '10%' }">
<template #body="slotProps">
<strong>{{ slotProps.data.cont }}</strong>
</template>
</Column>
<Column field="dcont" header="Denumire Cont" sortable :style="{ width: '25%' }" />
<Column header="Sold Precedent" :style="{ width: '15%' }">
<template #body="slotProps">
<div class="balance-group">
<div class="balance-item balance-debit">
<small>D:</small> {{ formatCurrency(slotProps.data.sold_precedent_debit) }}
</div>
<div class="balance-item balance-credit">
<small>C:</small> {{ formatCurrency(slotProps.data.sold_precedent_credit) }}
</div>
</div>
</template>
</Column>
<Column header="Rulaj Lunar" :style="{ width: '15%' }">
<template #body="slotProps">
<div class="balance-group">
<div class="balance-item balance-debit">
<small>D:</small> {{ formatCurrency(slotProps.data.rulaj_lunar_debit) }}
</div>
<div class="balance-item balance-credit">
<small>C:</small> {{ formatCurrency(slotProps.data.rulaj_lunar_credit) }}
</div>
</div>
</template>
</Column>
<Column header="Sold Final" :style="{ width: '15%' }">
<template #body="slotProps">
<div class="balance-group">
<div class="balance-item balance-debit">
<small>D:</small> {{ formatCurrency(slotProps.data.sold_final_debit) }}
</div>
<div class="balance-item balance-credit">
<small>C:</small> {{ formatCurrency(slotProps.data.sold_final_credit) }}
</div>
</div>
</template>
</Column>
</DataTable>
</template>
</Card>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from "vue";
import { useToast } from "primevue/usetoast";
import { useCompanyStore } from "../stores/companies";
import { useTrialBalanceStore } from "../stores/trialBalance";
const toast = useToast();
const companyStore = useCompanyStore();
const trialBalanceStore = useTrialBalanceStore();
// State
const selectedCompanyId = ref(companyStore.selectedCompany?.id_firma || null);
const localFilters = ref({
cont: "",
denumire: "",
});
// Computed
const currentPeriodText = computed(() => {
const months = [
"Ianuarie", "Februarie", "Martie", "Aprilie", "Mai", "Iunie",
"Iulie", "August", "Septembrie", "Octombrie", "Noiembrie", "Decembrie"
];
const monthName = months[trialBalanceStore.filters.luna - 1] || "";
return `${monthName} ${trialBalanceStore.filters.an}`;
});
// Methods
const formatCurrency = (amount) => {
if (!amount || amount === 0) return "0,00";
return new Intl.NumberFormat("ro-RO", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
const handleCompanyChange = async () => {
if (!selectedCompanyId.value) return;
const company = companyStore.getCompanyById(selectedCompanyId.value);
if (company) {
companyStore.setSelectedCompany(company);
await loadTrialBalance();
}
};
const handleFilterChange = async () => {
await applyFilters();
};
const handleSearchChange = (() => {
let timeout;
return () => {
clearTimeout(timeout);
timeout = setTimeout(async () => {
await applyFilters();
}, 500);
};
})();
const applyFilters = async () => {
if (!companyStore.selectedCompany) return;
await trialBalanceStore.applyFilters(
{
cont: localFilters.value.cont,
denumire: localFilters.value.denumire,
},
companyStore.selectedCompany.id_firma
);
};
const clearFilters = async () => {
localFilters.value = {
cont: "",
denumire: "",
};
await trialBalanceStore.clearFilters(companyStore.selectedCompany.id_firma);
};
const refreshData = async () => {
await loadTrialBalance();
toast.add({
severity: "success",
summary: "Actualizare reușită",
detail: "Balanța de verificare a fost actualizată cu succes",
life: 3000,
});
};
const loadTrialBalance = async () => {
if (!companyStore.selectedCompany) return;
try {
await trialBalanceStore.fetchTrialBalance(
companyStore.selectedCompany.id_firma
);
} catch (error) {
console.error("Failed to load trial balance:", error);
toast.add({
severity: "error",
summary: "Eroare",
detail: "Nu s-a putut încărca balanța de verificare",
life: 5000,
});
}
};
const onPageChange = async (event) => {
if (!companyStore.selectedCompany) return;
await trialBalanceStore.changePage(
event.page + 1,
companyStore.selectedCompany.id_firma
);
};
const onSort = async (event) => {
if (!companyStore.selectedCompany) return;
const sortBy = event.sortField?.toUpperCase() || "CONT";
const sortOrder = event.sortOrder === 1 ? "asc" : "desc";
await trialBalanceStore.sort(
sortBy,
sortOrder,
companyStore.selectedCompany.id_firma
);
};
// Lifecycle
onMounted(async () => {
// Load companies if not loaded
if (!companyStore.hasCompanies) {
await companyStore.loadCompanies();
}
// Load trial balance if company is selected
if (companyStore.selectedCompany) {
await loadTrialBalance();
}
});
// Watch for company changes
watch(
() => companyStore.selectedCompany,
async (newCompany) => {
if (newCompany) {
await loadTrialBalance();
}
}
);
</script>
<style scoped>
.trial-balance {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.page-header {
margin-bottom: 2rem;
}
.page-title {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-color);
margin: 0 0 0.5rem 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.page-subtitle {
font-size: 1.1rem;
color: var(--text-color-secondary);
margin: 0;
}
.company-selection-card,
.filters-card {
margin-bottom: 2rem;
}
.search-col {
grid-column: span 2;
}
.filters-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
padding-top: 1rem;
border-top: 1px solid var(--surface-border);
}
.table-card {
margin-bottom: 2rem;
}
.balance-group {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.balance-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.balance-item small {
font-weight: 600;
min-width: 1.2rem;
}
.balance-debit {
color: var(--text-color);
}
.balance-credit {
color: var(--text-color-secondary);
}
.no-data,
.loading-table {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-color-secondary);
}
.no-data i,
.loading-table i {
font-size: 2rem;
margin-bottom: 0.5rem;
}
/* Responsive design */
@media (max-width: 768px) {
.trial-balance {
padding: 1rem;
}
.page-title {
font-size: 2rem;
}
.search-col {
grid-column: span 1;
}
.filters-actions {
flex-direction: column;
}
.balance-group {
font-size: 0.85rem;
}
}
</style>