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>
437 lines
11 KiB
Vue
437 lines
11 KiB
Vue
<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>
|