Files
roa2web-service-auto/reports-app/frontend/src/views/TrialBalanceView.vue
Marius Mutu de24a79db5 feat: Add totals from all filtered records to invoices, treasury, and trial balance
Previously, totals were computed client-side from only the current page data,
which gave incorrect results when paginating. Now the backend calculates totals
across ALL filtered records and returns them in the API response.

- Invoice: Add total_sold_all field for sum of all filtered invoice balances
- Treasury: Add sold_precedent_all, total_incasari_all, total_plati_all, sold_final_all
- Trial Balance: Add 6-column totals (debit/credit for each balance type)
- Frontend stores and views updated to use backend totals

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-09 15:45:24 +02:00

743 lines
20 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?.name || "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="form-actions">
<Button
icon="pi pi-filter-slash"
label="Resetează Filtre"
class="p-button-outlined p-button-secondary"
@click="clearFilters"
/>
<Button
icon="pi pi-file-excel"
label="Export Excel"
class="p-button-outlined p-button-success"
@click="exportExcel"
:disabled="!trialBalanceStore.hasData"
/>
<Button
icon="pi pi-file-pdf"
label="Export PDF"
class="p-button-outlined p-button-danger"
@click="exportPDF"
:disabled="!trialBalanceStore.hasData"
/>
<Button
icon="pi pi-refresh"
label="Actualizează"
:loading="trialBalanceStore.isLoading"
@click="refreshData"
/>
</div>
</div>
</template>
</Card>
<!-- Summary Totals - 2 rows (Debit/Credit) for visual balance verification -->
<!-- Totaluri din TOATE înregistrările filtrate (nu doar pagina curentă) -->
<div v-if="companyStore.selectedCompany && trialBalanceStore.hasData" class="totals-table-container">
<table class="totals-table">
<thead>
<tr>
<th></th>
<th>Sold Precedent</th>
<th>Rulaj Lunar</th>
<th>Sold Final</th>
</tr>
</thead>
<tbody>
<tr>
<td class="row-label">Debit</td>
<td class="numeric">{{ formatCurrency(trialBalanceStore.totals.total_sold_precedent_debit) }}</td>
<td class="numeric">{{ formatCurrency(trialBalanceStore.totals.total_rulaj_lunar_debit) }}</td>
<td class="numeric">{{ formatCurrency(trialBalanceStore.totals.total_sold_final_debit) }}</td>
</tr>
<tr>
<td class="row-label">Credit</td>
<td class="numeric">{{ formatCurrency(trialBalanceStore.totals.total_sold_precedent_credit) }}</td>
<td class="numeric">{{ formatCurrency(trialBalanceStore.totals.total_rulaj_lunar_credit) }}</td>
<td class="numeric">{{ formatCurrency(trialBalanceStore.totals.total_sold_final_credit) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- 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"
:striped-rows="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="table-empty">
<i class="pi pi-info-circle table-empty-icon"></i>
<p class="table-empty-message">
Nu au fost găsite date pentru perioada selectată
</p>
</div>
</template>
<template #loading>
<div class="loading-state">
<ProgressSpinner />
<p>Se încarcă balanța de verificare...</p>
</div>
</template>
<Column
field="cont"
header="Cont"
sortable
:style="{ width: '8%' }"
>
<template #body="slotProps">
<strong>{{ slotProps.data.cont }}</strong>
</template>
</Column>
<Column
field="denumire"
header="Denumire Cont"
sortable
:style="{ width: '20%' }"
/>
<Column
field="sold_precedent_debit"
header="Sold Prec. D"
sortable
:style="{ width: '10%' }"
>
<template #body="slotProps">
<div class="text-right">
{{ formatCurrency(slotProps.data.sold_precedent_debit) }}
</div>
</template>
</Column>
<Column
field="sold_precedent_credit"
header="Sold Prec. C"
sortable
:style="{ width: '10%' }"
>
<template #body="slotProps">
<div class="text-right">
{{ formatCurrency(slotProps.data.sold_precedent_credit) }}
</div>
</template>
</Column>
<Column
field="rulaj_lunar_debit"
header="Rulaj D"
sortable
:style="{ width: '10%' }"
>
<template #body="slotProps">
<div class="text-right">
{{ formatCurrency(slotProps.data.rulaj_lunar_debit) }}
</div>
</template>
</Column>
<Column
field="rulaj_lunar_credit"
header="Rulaj C"
sortable
:style="{ width: '10%' }"
>
<template #body="slotProps">
<div class="text-right">
{{ formatCurrency(slotProps.data.rulaj_lunar_credit) }}
</div>
</template>
</Column>
<Column
field="sold_final_debit"
header="Sold Final D"
sortable
:style="{ width: '11%' }"
>
<template #body="slotProps">
<div class="text-right">
{{ formatCurrency(slotProps.data.sold_final_debit) }}
</div>
</template>
</Column>
<Column
field="sold_final_credit"
header="Sold Final C"
sortable
:style="{ width: '11%' }"
>
<template #body="slotProps">
<div class="text-right">
{{ formatCurrency(slotProps.data.sold_final_credit) }}
</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";
import { useAccountingPeriodStore } from "../stores/accountingPeriod";
import { exportToExcel, exportToPDF } from "../utils/exportUtils";
const toast = useToast();
const companyStore = useCompanyStore();
const trialBalanceStore = useTrialBalanceStore();
const periodStore = useAccountingPeriodStore();
// State
const selectedCompanyId = ref(companyStore.selectedCompany?.id_firma || null);
const localFilters = ref({
cont: "",
denumire: "",
});
// Computed
const currentPeriodText = computed(() => {
// Use the global period store
return periodStore.selectedPeriod?.display_name || "";
});
// 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,
);
};
// Export methods - Fetch ALL data (not just current page)
const fetchAllTrialBalanceData = async () => {
if (!companyStore.selectedCompany) return [];
try {
const params = {
company: companyStore.selectedCompany.id_firma,
luna: trialBalanceStore.filters.luna,
an: trialBalanceStore.filters.an,
page: 1,
page_size: 999999, // Get all data
sort_by: trialBalanceStore.sorting.sortBy,
sort_order: trialBalanceStore.sorting.sortOrder,
};
// Add optional filters
if (trialBalanceStore.filters.cont) {
params.cont_filter = trialBalanceStore.filters.cont;
}
if (trialBalanceStore.filters.denumire) {
params.denumire_filter = trialBalanceStore.filters.denumire;
}
const apiService = (await import("../services/api")).apiService;
const response = await apiService.get("/trial-balance/", { params });
if (response.data.success) {
return response.data.data.items || [];
}
return [];
} catch (error) {
console.error("Failed to fetch all trial balance data:", error);
return [];
}
};
const exportExcel = async () => {
if (!trialBalanceStore.hasData) {
toast.add({
severity: "warn",
summary: "Nu există date",
detail: "Nu există date de exportat",
life: 3000,
});
return;
}
// Fetch ALL data for export (not just current page)
toast.add({
severity: "info",
summary: "Se pregătește exportul",
detail: "Se încarcă toate datele...",
life: 2000,
});
const allData = await fetchAllTrialBalanceData();
if (allData.length === 0) {
toast.add({
severity: "error",
summary: "Eroare",
detail: "Nu s-au putut prelua datele pentru export",
life: 3000,
});
return;
}
// Prepare data for export - Use raw numbers (not formatted) so Excel treats them as numbers
const exportData = allData.map((row) => ({
Cont: row.cont,
Denumire: row.denumire,
"Sold Precedent D": parseFloat(row.sold_precedent_debit) || 0,
"Sold Precedent C": parseFloat(row.sold_precedent_credit) || 0,
"Rulaj Lunar D": parseFloat(row.rulaj_lunar_debit) || 0,
"Rulaj Lunar C": parseFloat(row.rulaj_lunar_credit) || 0,
"Sold Final D": parseFloat(row.sold_final_debit) || 0,
"Sold Final C": parseFloat(row.sold_final_credit) || 0,
}));
const result = exportToExcel(
exportData,
`balanta_verificare_${currentPeriodText.value.replace(/\s+/g, "_")}`,
"Balanță de Verificare",
);
if (result.success) {
toast.add({
severity: "success",
summary: "Export reușit",
detail: `${allData.length} înregistrări exportate cu succes`,
life: 3000,
});
} else {
toast.add({
severity: "error",
summary: "Eroare la export",
detail: "Nu s-a putut genera fișierul Excel",
life: 3000,
});
}
};
const exportPDF = async () => {
if (!trialBalanceStore.hasData) {
toast.add({
severity: "warn",
summary: "Nu există date",
detail: "Nu există date de exportat",
life: 3000,
});
return;
}
// Fetch ALL data for export (not just current page)
toast.add({
severity: "info",
summary: "Se pregătește exportul",
detail: "Se încarcă toate datele...",
life: 2000,
});
const allData = await fetchAllTrialBalanceData();
if (allData.length === 0) {
toast.add({
severity: "error",
summary: "Eroare",
detail: "Nu s-au putut prelua datele pentru export",
life: 3000,
});
return;
}
// Prepare data for export
const exportData = allData.map((row) => ({
cont: row.cont,
denumire: row.denumire,
sold_precedent_debit: row.sold_precedent_debit,
sold_precedent_credit: row.sold_precedent_credit,
rulaj_lunar_debit: row.rulaj_lunar_debit,
rulaj_lunar_credit: row.rulaj_lunar_credit,
sold_final_debit: row.sold_final_debit,
sold_final_credit: row.sold_final_credit,
}));
// Define columns for PDF with proper configuration
// A4 landscape width: ~297mm total, margins 8mm left+right = 281mm usable
// Use 'auto' width to fill entire page width
const columns = [
{ field: "cont", header: "Cont", type: "text", width: "auto" },
{ field: "denumire", header: "Denumire Cont", type: "text", width: "auto" },
{
field: "sold_precedent_debit",
header: "Sold Prec. D",
type: "number",
width: "auto",
},
{
field: "sold_precedent_credit",
header: "Sold Prec. C",
type: "number",
width: "auto",
},
{
field: "rulaj_lunar_debit",
header: "Rulaj D",
type: "number",
width: "auto",
},
{
field: "rulaj_lunar_credit",
header: "Rulaj C",
type: "number",
width: "auto",
},
{
field: "sold_final_debit",
header: "Sold Final D",
type: "number",
width: "auto",
},
{
field: "sold_final_credit",
header: "Sold Final C",
type: "number",
width: "auto",
},
];
const result = exportToPDF(
exportData,
columns,
`balanta-verificare-${currentPeriodText.value.replace(/\s+/g, "-")}`,
{
companyName: companyStore.selectedCompany?.name || "",
title: "Balanta de Verificare",
period: currentPeriodText.value,
},
);
if (result.success) {
toast.add({
severity: "success",
summary: "Export reușit",
detail: `${allData.length} înregistrări exportate cu succes`,
life: 3000,
});
} else {
toast.add({
severity: "error",
summary: "Eroare la export",
detail: "Nu s-a putut genera fișierul PDF",
life: 3000,
});
}
};
// Lifecycle
onMounted(async () => {
// Load companies if not loaded
if (!companyStore.hasCompanies) {
await companyStore.loadCompanies();
}
// FIX: Sync period from global periodStore BEFORE loading data
// This ensures Trial Balance shows the correct period when navigating
// from other views (e.g., Invoices with November selected)
if (periodStore.selectedPeriod) {
trialBalanceStore.filters.luna = periodStore.selectedPeriod.luna;
trialBalanceStore.filters.an = periodStore.selectedPeriod.an;
}
// 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();
}
},
);
// Watch for period changes - sync luna/an with trial balance store
watch(
() => periodStore.selectedPeriod,
async (newPeriod) => {
if (newPeriod && companyStore.selectedCompany) {
await trialBalanceStore.changePeriod(
newPeriod.luna,
newPeriod.an,
companyStore.selectedCompany.id_firma
);
}
},
);
</script>
<style scoped>
/* ===== Page-Specific Styles Only ===== */
/* Uses shared CSS: dashboard.css (.page-header, .page-title, .page-subtitle) */
/* Uses shared CSS: forms.css (.form-actions) */
/* Uses shared CSS: tables.css (.table-empty, .loading-state) */
/* Uses shared CSS: primevue-overrides.css (DataTable striped rows, hover) */
/* Page Container */
.trial-balance {
max-width: 1400px;
margin: 0 auto;
padding: var(--space-xl);
}
/* Card Spacing */
.company-selection-card,
.filters-card,
.table-card {
margin-bottom: var(--space-xl);
}
/* Search field takes 2 columns in form grid */
.search-col {
grid-column: span 2;
}
/* Text alignment utility - page specific */
.text-right {
text-align: right;
}
/* Totals Table for Trial Balance - 2 rows (Debit/Credit) */
.totals-table-container {
margin-bottom: var(--space-lg);
display: flex;
justify-content: flex-end;
}
.totals-table {
border-collapse: collapse;
font-size: 0.9rem;
background: var(--surface-card);
border-radius: var(--border-radius);
overflow: hidden;
box-shadow: var(--shadow-sm);
}
.totals-table th,
.totals-table td {
padding: 0.5rem 1rem;
text-align: right;
}
.totals-table th {
background: var(--surface-100);
font-weight: 600;
color: var(--text-color-secondary);
}
.totals-table .row-label {
text-align: left;
font-weight: 600;
background: var(--surface-50);
}
.totals-table .numeric {
font-family: var(--font-mono, 'Roboto Mono', monospace);
font-variant-numeric: tabular-nums;
}
.totals-table tbody tr:first-child td {
border-bottom: 1px solid var(--surface-border);
}
/* Responsive */
@media (max-width: 768px) {
.trial-balance {
padding: var(--space-md);
}
.search-col {
grid-column: span 1;
}
}
</style>