feat: Implement unified Vue SPA with granular service control
Consolidate Reports and Data Entry apps into a single Vue.js SPA with: Architecture: - Module-based structure with lazy-loaded routes (@reports, @data-entry) - Error boundaries per module to prevent cascade failures - Dual API proxy in Vite for microservices (reports:8001, data-entry:8003) - Pinia store factories for shared auth, company, and period stores - Vite path aliases for clear module boundaries (@shared, @reports, @data-entry) Service Management: - Granular service control scripts (backend-reports.sh, backend-data-entry.sh, bot.sh, frontend.sh) - 87% faster frontend restart: 7s vs 53s full restart - 38% faster full startup: 33s vs 53s via parallel backend initialization - Enhanced start-dev.sh with proper service timeouts (OCR: 30s, Vite: 15s, Bot: 10s) - status.sh for comprehensive health checks Features: - Auto-select first company on login with period auto-load - Hamburger menu with feature toggle support - JWT token auto-injection via axios interceptors - Unified header with company/period selectors - IIS web.config for production deployment with multi-API routing UX Improvements: - Vue watchers for reactive company/period loading - Lazy store initialization with graceful error handling - Period persistence per user+company in localStorage - Feature flags for optional modules Deployment: - Single IIS site serves unified frontend with API proxy rules - Maintains separate backend processes for microservices - Windows line ending fixes (.env CRLF → LF conversion) Stats: 112 files changed, 38,342 insertions(+), 2,342 deletions(-) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
0
src/modules/reports/utils/__init__.py
Normal file
0
src/modules/reports/utils/__init__.py
Normal file
861
src/modules/reports/utils/exportUtils.js
Normal file
861
src/modules/reports/utils/exportUtils.js
Normal file
@@ -0,0 +1,861 @@
|
||||
import * as XLSX from "xlsx";
|
||||
import { jsPDF } from "jspdf";
|
||||
import autoTable from "jspdf-autotable";
|
||||
|
||||
/**
|
||||
* Format currency values for export
|
||||
*/
|
||||
const formatCurrency = (value) => {
|
||||
if (value == null || value === "-") return "-";
|
||||
return new Intl.NumberFormat("ro-RO", {
|
||||
style: "currency",
|
||||
currency: "RON",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export data to Excel
|
||||
* @param {Array} data - Array of objects to export
|
||||
* @param {String} filename - Name of the file (without extension)
|
||||
* @param {String} sheetName - Name of the Excel sheet
|
||||
*/
|
||||
export const exportToExcel = (data, filename, sheetName = "Sheet1") => {
|
||||
try {
|
||||
const ws = XLSX.utils.json_to_sheet(data);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, sheetName);
|
||||
XLSX.writeFile(
|
||||
wb,
|
||||
`${filename}_${new Date().toISOString().split("T")[0]}.xlsx`,
|
||||
);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Excel export failed:", error);
|
||||
return { success: false, error };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format number for PDF export
|
||||
*/
|
||||
const formatNumberForPDF = (value) => {
|
||||
if (value == null || value === "" || value === "-") return "-";
|
||||
const num = parseFloat(value);
|
||||
if (isNaN(num)) return "-";
|
||||
return new Intl.NumberFormat("ro-RO", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(num);
|
||||
};
|
||||
|
||||
/**
|
||||
* Export data to PDF
|
||||
* @param {Array} data - Array of objects to export
|
||||
* @param {Array} columns - Column definitions [{field: 'key', header: 'Display Name', type: 'text|number|currency', width: 30}]
|
||||
* @param {String} filename - Name of the file (without extension)
|
||||
* @param {Object} header - Header configuration {companyName: '', title: '', period: '', subtitle2: '', initialBalances: [], totalInitialBalance: 0}
|
||||
*/
|
||||
export const exportToPDF = (data, columns, filename, header) => {
|
||||
try {
|
||||
// Check if data exists
|
||||
if (!data || data.length === 0) {
|
||||
console.error("No data to export");
|
||||
return { success: false, error: "No data available" };
|
||||
}
|
||||
|
||||
// Check if jsPDF is properly imported
|
||||
if (typeof jsPDF === "undefined") {
|
||||
console.error("jsPDF not properly imported");
|
||||
return { success: false, error: "PDF library not available" };
|
||||
}
|
||||
|
||||
const doc = new jsPDF("landscape", "mm", "a4");
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const marginLeft = 8;
|
||||
const marginRight = 8;
|
||||
const contentWidth = pageWidth - marginLeft - marginRight;
|
||||
|
||||
// Check if there are initial balances to display
|
||||
const hasInitialBalances = header.initialBalances && header.initialBalances.length > 0;
|
||||
|
||||
// Function to add header (called for each page)
|
||||
const addHeader = () => {
|
||||
// Line 1: Company name (left aligned, bold, larger font)
|
||||
doc.setFontSize(13);
|
||||
doc.setFont(undefined, "bold");
|
||||
const companyName = header.companyName || "N/A";
|
||||
doc.text(companyName, marginLeft, 15);
|
||||
|
||||
// Line 2: Title "Balanta de Verificare" (centered)
|
||||
doc.setFontSize(14);
|
||||
doc.setFont(undefined, "bold");
|
||||
const titleWidth = doc.getTextWidth(header.title || "");
|
||||
const titleX = marginLeft + (contentWidth - titleWidth) / 2;
|
||||
doc.text(header.title || "", titleX, 24);
|
||||
|
||||
// Line 3: Period (centered, below title)
|
||||
doc.setFontSize(11);
|
||||
doc.setFont(undefined, "normal");
|
||||
const periodText = header.period || "";
|
||||
const periodWidth = doc.getTextWidth(periodText);
|
||||
const periodX = marginLeft + (contentWidth - periodWidth) / 2;
|
||||
doc.text(periodText, periodX, 32);
|
||||
|
||||
// Line 4: Subtitle2 - filters (left aligned, below period) - optional
|
||||
let currentY = 32;
|
||||
if (header.subtitle2) {
|
||||
currentY = 39;
|
||||
doc.setFontSize(10);
|
||||
doc.setFont(undefined, "normal");
|
||||
doc.text(header.subtitle2, marginLeft, currentY);
|
||||
}
|
||||
|
||||
// Initial Balances section - rendered just before table, closer to it
|
||||
// This is handled in didDrawPage for first page only
|
||||
};
|
||||
|
||||
// Prepare table data and track total rows
|
||||
const tableColumns = columns.map((col) => col.header);
|
||||
const totalRowIndices = new Set(); // Track which rows are totals
|
||||
|
||||
const grandTotalRowIndices = new Set(); // Track grand total rows
|
||||
|
||||
const tableRows = data.map((row, rowIndex) => {
|
||||
// Track total rows for special styling
|
||||
if (row._isTotal) {
|
||||
totalRowIndices.add(rowIndex);
|
||||
}
|
||||
if (row._isGrandTotal) {
|
||||
grandTotalRowIndices.add(rowIndex);
|
||||
}
|
||||
|
||||
return columns.map((col) => {
|
||||
const value = row[col.field];
|
||||
if (col.type === "currency") {
|
||||
return formatCurrency(value);
|
||||
} else if (col.type === "number") {
|
||||
return formatNumberForPDF(value);
|
||||
}
|
||||
return value || "-";
|
||||
});
|
||||
});
|
||||
|
||||
// Function to add footer (called for each page)
|
||||
const addFooter = (pageNum, totalPages) => {
|
||||
const footerY = pageHeight - 10; // 10mm from bottom
|
||||
|
||||
// Left side: Generation date
|
||||
doc.setFontSize(8);
|
||||
doc.setFont(undefined, "normal");
|
||||
doc.text(
|
||||
`Generat: ${new Date().toLocaleString("ro-RO")}`,
|
||||
marginLeft,
|
||||
footerY,
|
||||
);
|
||||
|
||||
// Right side: Page numbers
|
||||
const pageText = `Pagina ${pageNum} din ${totalPages}`;
|
||||
const pageTextWidth = doc.getTextWidth(pageText);
|
||||
doc.text(pageText, pageWidth - marginRight - pageTextWidth, footerY);
|
||||
};
|
||||
|
||||
// Check if autoTable is available
|
||||
if (typeof autoTable === "function") {
|
||||
// Build column styles - jspdf-autotable uses numeric keys
|
||||
const columnStyles = {};
|
||||
|
||||
// Calculate optimal column widths
|
||||
// Total usable width: pageWidth - marginLeft - marginRight
|
||||
const totalWidth = pageWidth - marginLeft - marginRight; // ~281mm for A4 landscape
|
||||
|
||||
// Define width allocation (proportional) - support custom widths from columns
|
||||
const widthAllocations = {};
|
||||
|
||||
columns.forEach((col, index) => {
|
||||
// Use custom width if provided, otherwise auto
|
||||
if (col.width && typeof col.width === "number") {
|
||||
widthAllocations[index] = totalWidth * col.width;
|
||||
} else if (col.width === "auto") {
|
||||
widthAllocations[index] = "auto";
|
||||
} else {
|
||||
// Default width allocation for Trial Balance (8 columns)
|
||||
const defaultWidths = {
|
||||
0: totalWidth * 0.07, // Cont: ~20mm
|
||||
1: totalWidth * 0.33, // Denumire: ~93mm
|
||||
2: totalWidth * 0.1, // Sume Prec D: ~28mm
|
||||
3: totalWidth * 0.1, // Sume Prec C: ~28mm
|
||||
4: totalWidth * 0.1, // Rulaj D: ~28mm
|
||||
5: totalWidth * 0.1, // Rulaj C: ~28mm
|
||||
6: totalWidth * 0.1, // Sold Final D: ~28mm
|
||||
7: totalWidth * 0.1, // Sold Final C: ~28mm
|
||||
};
|
||||
widthAllocations[index] = defaultWidths[index] || "auto";
|
||||
}
|
||||
});
|
||||
|
||||
columns.forEach((col, index) => {
|
||||
columnStyles[index] = {
|
||||
cellWidth: widthAllocations[index],
|
||||
};
|
||||
|
||||
// Set alignment based on type
|
||||
if (col.type === "number" || col.type === "currency") {
|
||||
columnStyles[index].halign = "right";
|
||||
} else if (col.type === "text") {
|
||||
// All text columns aligned left (including Cont)
|
||||
columnStyles[index].halign = "left";
|
||||
}
|
||||
});
|
||||
|
||||
// Start table lower based on header content
|
||||
let tableStartY = 36;
|
||||
if (header.subtitle2) tableStartY = 43;
|
||||
if (hasInitialBalances) {
|
||||
// Initial balances rendered close to table (just 3mm above table header)
|
||||
const balancesCount = header.initialBalances.length;
|
||||
const hasTotal = balancesCount > 1;
|
||||
const balancesHeight = (balancesCount * 5) + (hasTotal ? 7 : 0);
|
||||
// Base position after header content
|
||||
const baseY = header.subtitle2 ? 43 : 36;
|
||||
tableStartY = baseY + balancesHeight + 5; // balances + small gap before table
|
||||
}
|
||||
|
||||
// Function to draw initial balances (called only on first page)
|
||||
const drawInitialBalances = (tableY) => {
|
||||
if (!hasInitialBalances) return;
|
||||
|
||||
const valueRightEdge = pageWidth - marginRight;
|
||||
const balancesCount = header.initialBalances.length;
|
||||
const hasTotal = balancesCount > 1;
|
||||
const balancesHeight = (balancesCount * 5) + (hasTotal ? 7 : 0);
|
||||
|
||||
// Start position: just above table header (3mm gap)
|
||||
let y = tableY - 3 - (hasTotal ? 7 : 0) - (balancesCount * 5);
|
||||
|
||||
doc.setFont(undefined, "normal");
|
||||
doc.setFontSize(9);
|
||||
|
||||
// Draw each balance line: "AccountName sold precedent: VALUE"
|
||||
header.initialBalances.forEach((item) => {
|
||||
const value = formatNumberForPDF(item.sold);
|
||||
const valueWidth = doc.getTextWidth(value);
|
||||
const label = `${item.accountName} sold precedent:`;
|
||||
|
||||
doc.text(label, valueRightEdge - valueWidth - doc.getTextWidth(" sold precedent:") - doc.getTextWidth(item.accountName) - 2, y);
|
||||
doc.text(value, valueRightEdge - valueWidth, y);
|
||||
y += 5;
|
||||
});
|
||||
|
||||
// Total only if multiple accounts
|
||||
if (hasTotal) {
|
||||
// Separator line
|
||||
doc.setDrawColor(150, 150, 150);
|
||||
doc.line(valueRightEdge - 40, y - 2, valueRightEdge, y - 2);
|
||||
|
||||
// Total line
|
||||
doc.setFont(undefined, "bold");
|
||||
const totalValue = formatNumberForPDF(header.totalInitialBalance || 0);
|
||||
const totalValueWidth = doc.getTextWidth(totalValue);
|
||||
const totalLabel = "TOTAL sold precedent:";
|
||||
const totalLabelWidth = doc.getTextWidth(totalLabel);
|
||||
|
||||
doc.text(totalLabel, valueRightEdge - totalValueWidth - 3 - totalLabelWidth, y + 2);
|
||||
doc.text(totalValue, valueRightEdge - totalValueWidth, y + 2);
|
||||
}
|
||||
};
|
||||
|
||||
let isFirstPage = true;
|
||||
|
||||
// Add table using autoTable (call as function, not method)
|
||||
autoTable(doc, {
|
||||
head: [tableColumns],
|
||||
body: tableRows,
|
||||
startY: tableStartY,
|
||||
styles: {
|
||||
fontSize: 9,
|
||||
cellPadding: 2.5,
|
||||
valign: "middle",
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.1,
|
||||
overflow: "linebreak",
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [41, 128, 185],
|
||||
textColor: 255,
|
||||
fontStyle: "bold",
|
||||
halign: "center",
|
||||
fontSize: 9,
|
||||
cellPadding: 2.5,
|
||||
},
|
||||
alternateRowStyles: {
|
||||
fillColor: [248, 248, 248],
|
||||
},
|
||||
columnStyles: columnStyles,
|
||||
margin: {
|
||||
left: marginLeft,
|
||||
right: marginRight,
|
||||
top: tableStartY,
|
||||
bottom: 15,
|
||||
},
|
||||
tableWidth: pageWidth - marginLeft - marginRight, // Use full page width
|
||||
theme: "grid",
|
||||
didDrawPage: function () {
|
||||
// Add header to each page
|
||||
addHeader();
|
||||
// Draw initial balances only on first page
|
||||
if (isFirstPage && hasInitialBalances) {
|
||||
drawInitialBalances(tableStartY);
|
||||
isFirstPage = false;
|
||||
}
|
||||
},
|
||||
didParseCell: function (data) {
|
||||
// Force alignment based on column type (body cells only)
|
||||
if (data.section === "body") {
|
||||
const rowIndex = data.row.index;
|
||||
const colIndex = data.column.index;
|
||||
const column = columns[colIndex];
|
||||
|
||||
// Style grand total rows (bold, darker gray background)
|
||||
if (grandTotalRowIndices.has(rowIndex)) {
|
||||
data.cell.styles.fontStyle = "bold";
|
||||
data.cell.styles.fillColor = [200, 200, 200]; // Darker gray
|
||||
data.cell.styles.fontSize = 10;
|
||||
}
|
||||
// Style class total rows (bold, light gray background)
|
||||
else if (totalRowIndices.has(rowIndex)) {
|
||||
data.cell.styles.fontStyle = "bold";
|
||||
data.cell.styles.fillColor = [235, 235, 235]; // Light gray
|
||||
}
|
||||
|
||||
if (column) {
|
||||
if (column.type === "number" || column.type === "currency") {
|
||||
data.cell.styles.halign = "right";
|
||||
} else if (column.type === "text") {
|
||||
if (colIndex === 0) {
|
||||
data.cell.styles.halign = "center";
|
||||
} else {
|
||||
data.cell.styles.halign = "left";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
willDrawCell: function (data) {
|
||||
// Draw double line above grand total row
|
||||
if (data.section === "body" && grandTotalRowIndices.has(data.row.index)) {
|
||||
const doc = data.doc;
|
||||
doc.setDrawColor(100, 100, 100);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.line(data.cell.x, data.cell.y, data.cell.x + data.cell.width, data.cell.y);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Add footer to all pages AFTER table generation
|
||||
const totalPages = doc.internal.getNumberOfPages();
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
doc.setPage(i);
|
||||
addFooter(i, totalPages);
|
||||
}
|
||||
} else {
|
||||
// Fallback mode (autoTable NOT available)
|
||||
// Add header on first page
|
||||
addHeader();
|
||||
|
||||
// Fallback: manual table creation
|
||||
let yPos = 45;
|
||||
|
||||
// Draw headers
|
||||
doc.setFontSize(8);
|
||||
doc.setFont(undefined, "bold");
|
||||
tableColumns.forEach((header, index) => {
|
||||
doc.text(header, 14 + index * 35, yPos);
|
||||
});
|
||||
|
||||
// Draw rows
|
||||
doc.setFont(undefined, "normal");
|
||||
doc.setFontSize(7);
|
||||
tableRows.forEach((row, rowIndex) => {
|
||||
yPos += 7;
|
||||
row.forEach((cell, cellIndex) => {
|
||||
doc.text(String(cell), 14 + cellIndex * 35, yPos);
|
||||
});
|
||||
});
|
||||
|
||||
// Add footer in fallback mode
|
||||
addFooter(1, 1);
|
||||
}
|
||||
|
||||
// Save PDF
|
||||
doc.save(`${filename}_${new Date().toISOString().split("T")[0]}.pdf`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("PDF export error details:", error);
|
||||
return { success: false, error: error.message || "PDF generation failed" };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Export General Totals table
|
||||
*/
|
||||
export const exportGeneralTotals = (summaryData) => {
|
||||
const data = [
|
||||
{
|
||||
Tip: "Clienți",
|
||||
"Total Facturat": summaryData?.clienti_total_facturat || 0,
|
||||
"Total Încasat": summaryData?.clienti_total_incasat || 0,
|
||||
"Sold Net": summaryData?.clienti_sold_total || 0,
|
||||
"Sold În Termen": summaryData?.clienti_sold_in_termen || 0,
|
||||
"Sold Restant": summaryData?.clienti_sold_restant || 0,
|
||||
},
|
||||
{
|
||||
Tip: "Furnizori",
|
||||
"Total Facturat": summaryData?.furnizori_total_facturat || 0,
|
||||
"Total Achitat": summaryData?.furnizori_total_achitat || 0,
|
||||
"Sold Net": summaryData?.furnizori_sold_total || 0,
|
||||
"Sold În Termen": summaryData?.furnizori_sold_in_termen || 0,
|
||||
"Sold Restant": summaryData?.furnizori_sold_restant || 0,
|
||||
},
|
||||
{
|
||||
Tip: "Trezorerie",
|
||||
"Total Facturat": "-",
|
||||
"Total Încasat/Achitat": "-",
|
||||
"Sold Net": summaryData?.trezorerie_sold || 0,
|
||||
"Sold În Termen": "-",
|
||||
"Sold Restant": "-",
|
||||
},
|
||||
];
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Export Sold Net Breakdown table
|
||||
*/
|
||||
export const exportSoldNetBreakdown = (summaryData) => {
|
||||
const data = [
|
||||
{
|
||||
Categorie: "Clienți - Restant",
|
||||
TOTAL: summaryData?.clienti_sold_restant || 0,
|
||||
"7 zile": summaryData?.clienti_restant_7 || 0,
|
||||
"14 zile": summaryData?.clienti_restant_14 || 0,
|
||||
"30 zile": summaryData?.clienti_restant_30 || 0,
|
||||
"60 zile": summaryData?.clienti_restant_60 || 0,
|
||||
"90 zile": summaryData?.clienti_restant_90 || 0,
|
||||
"90+ zile": summaryData?.clienti_restant_over_90 || 0,
|
||||
},
|
||||
{
|
||||
Categorie: "Furnizori - Restant",
|
||||
TOTAL: summaryData?.furnizori_sold_restant || 0,
|
||||
"7 zile": summaryData?.furnizori_restant_7 || 0,
|
||||
"14 zile": summaryData?.furnizori_restant_14 || 0,
|
||||
"30 zile": summaryData?.furnizori_restant_30 || 0,
|
||||
"60 zile": summaryData?.furnizori_restant_60 || 0,
|
||||
"90 zile": summaryData?.furnizori_restant_90 || 0,
|
||||
"90+ zile": summaryData?.furnizori_restant_over_90 || 0,
|
||||
},
|
||||
];
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Export Bank Cash Register to PDF with grouped format
|
||||
* Matches the Romanian standard format with:
|
||||
* - Bank name + Sold precedent on same line
|
||||
* - Daily totals (Total zi)
|
||||
* - Cumulative totals (Total cumulat)
|
||||
*
|
||||
* @param {Array} data - Array of register entries
|
||||
* @param {Object} header - Header configuration
|
||||
* @param {String} filename - Output filename
|
||||
*/
|
||||
export const exportBankCashRegisterPDF = (data, header, filename) => {
|
||||
try {
|
||||
if (!data || data.length === 0) {
|
||||
console.error("No data to export");
|
||||
return { success: false, error: "No data available" };
|
||||
}
|
||||
|
||||
const doc = new jsPDF("landscape", "mm", "a4");
|
||||
const pageWidth = doc.internal.pageSize.getWidth();
|
||||
const pageHeight = doc.internal.pageSize.getHeight();
|
||||
const marginLeft = 8;
|
||||
const marginRight = 8;
|
||||
const contentWidth = pageWidth - marginLeft - marginRight;
|
||||
|
||||
// Remove diacritics helper
|
||||
const removeDiacritics = (text) => {
|
||||
if (!text) return "";
|
||||
return text
|
||||
.replace(/[ăâ]/gi, (m) => (m === m.toLowerCase() ? "a" : "A"))
|
||||
.replace(/[î]/gi, (m) => (m === m.toLowerCase() ? "i" : "I"))
|
||||
.replace(/[ș]/gi, (m) => (m === m.toLowerCase() ? "s" : "S"))
|
||||
.replace(/[ț]/gi, (m) => (m === m.toLowerCase() ? "t" : "T"));
|
||||
};
|
||||
|
||||
// Truncate text helper (limit explicatia to 100 chars)
|
||||
const truncateText = (text, maxLength = 100) => {
|
||||
if (!text) return "";
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + "...";
|
||||
};
|
||||
|
||||
// Group data by bank account (bancasa)
|
||||
const groupedByBank = {};
|
||||
const initialBalances = {};
|
||||
|
||||
data.forEach((row) => {
|
||||
const bankName = row.nume_cont_bancar || "Necunoscut";
|
||||
if (!groupedByBank[bankName]) {
|
||||
groupedByBank[bankName] = [];
|
||||
initialBalances[bankName] = 0;
|
||||
}
|
||||
|
||||
if (!row.dataact) {
|
||||
// Initial balance row (null date) - sold precedent
|
||||
initialBalances[bankName] = parseFloat(row.sold) || 0;
|
||||
} else {
|
||||
// Transaction row with date
|
||||
groupedByBank[bankName].push(row);
|
||||
}
|
||||
});
|
||||
|
||||
// Table columns definition
|
||||
const tableColumns = [
|
||||
"Data act",
|
||||
"Nr.act",
|
||||
"Explicatia",
|
||||
"Incasari",
|
||||
"Plati",
|
||||
"Sold",
|
||||
];
|
||||
|
||||
const columnWidths = {
|
||||
0: contentWidth * 0.10, // Data act
|
||||
1: contentWidth * 0.08, // Nr.act
|
||||
2: contentWidth * 0.42, // Explicatia
|
||||
3: contentWidth * 0.13, // Incasari
|
||||
4: contentWidth * 0.13, // Plati
|
||||
5: contentWidth * 0.14, // Sold
|
||||
};
|
||||
|
||||
const columnStyles = {};
|
||||
Object.keys(columnWidths).forEach((idx) => {
|
||||
columnStyles[idx] = { cellWidth: columnWidths[idx] };
|
||||
if (idx >= 3) {
|
||||
columnStyles[idx].halign = "right";
|
||||
}
|
||||
});
|
||||
|
||||
let currentY = 15;
|
||||
let pageNum = 1;
|
||||
|
||||
// Function to add page header
|
||||
const addPageHeader = () => {
|
||||
// Company name (left)
|
||||
doc.setFontSize(12);
|
||||
doc.setFont(undefined, "bold");
|
||||
doc.text(removeDiacritics(header.companyName || ""), marginLeft, 12);
|
||||
|
||||
// Luna: MM / YYYY (right)
|
||||
doc.setFontSize(10);
|
||||
doc.setFont(undefined, "normal");
|
||||
const lunaText = `Luna: ${header.luna || ""} / ${header.an || ""}`;
|
||||
const lunaWidth = doc.getTextWidth(lunaText);
|
||||
doc.text(lunaText, pageWidth - marginRight - lunaWidth, 12);
|
||||
|
||||
// Title centered
|
||||
doc.setFontSize(13);
|
||||
doc.setFont(undefined, "bold");
|
||||
const titleWidth = doc.getTextWidth(header.title || "");
|
||||
doc.text(header.title || "", marginLeft + (contentWidth - titleWidth) / 2, 20);
|
||||
};
|
||||
|
||||
// Function to check if we need a new page (for tables spanning multiple pages within a bank)
|
||||
const checkNewPage = (neededHeight = 20) => {
|
||||
if (currentY + neededHeight > pageHeight - 15) {
|
||||
doc.addPage();
|
||||
pageNum++;
|
||||
addPageHeader();
|
||||
currentY = 28;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Process each bank account - each on a new page (sorted alphabetically)
|
||||
const bankNames = Object.keys(groupedByBank).sort((a, b) => a.localeCompare(b, 'ro'));
|
||||
|
||||
bankNames.forEach((bankName, bankIndex) => {
|
||||
const bankRows = groupedByBank[bankName];
|
||||
const soldPrecedent = initialBalances[bankName] || 0;
|
||||
|
||||
// Start each bank/casa on a new page (except first one which is already on page 1)
|
||||
if (bankIndex > 0) {
|
||||
doc.addPage();
|
||||
pageNum++;
|
||||
}
|
||||
|
||||
// Add full page header (company, title, luna/an)
|
||||
addPageHeader();
|
||||
currentY = 28;
|
||||
|
||||
// Bank/Casa header: "Banca: NAME" (left) + "Sold precedent: VALUE" (right)
|
||||
doc.setFontSize(10);
|
||||
doc.setFont(undefined, "bold");
|
||||
const bankLabel = header.isBanca ? "Banca:" : "Casa:";
|
||||
const bankHeaderText = `${bankLabel} ${removeDiacritics(bankName)}`;
|
||||
doc.text(bankHeaderText, marginLeft, currentY);
|
||||
|
||||
const soldPrecedentText = `Sold precedent: ${formatNumberForPDF(soldPrecedent)}`;
|
||||
const soldPrecedentWidth = doc.getTextWidth(soldPrecedentText);
|
||||
doc.text(soldPrecedentText, pageWidth - marginRight - soldPrecedentWidth, currentY);
|
||||
|
||||
currentY += 6;
|
||||
|
||||
// Handle case when there are no transactions (only initial balance)
|
||||
if (bankRows.length === 0) {
|
||||
// Draw empty table with header only
|
||||
autoTable(doc, {
|
||||
head: [tableColumns],
|
||||
body: [],
|
||||
startY: currentY,
|
||||
styles: {
|
||||
fontSize: 8,
|
||||
cellPadding: 1.5,
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.1,
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [41, 128, 185],
|
||||
textColor: 255,
|
||||
fontStyle: "bold",
|
||||
halign: "center",
|
||||
fontSize: 8,
|
||||
},
|
||||
columnStyles: columnStyles,
|
||||
margin: { left: marginLeft, right: marginRight },
|
||||
tableWidth: contentWidth,
|
||||
theme: "grid",
|
||||
});
|
||||
|
||||
currentY = doc.lastAutoTable.finalY;
|
||||
|
||||
// Show total with sold precedent (no transactions)
|
||||
const totalRows = [
|
||||
[
|
||||
"",
|
||||
"",
|
||||
"Total:",
|
||||
formatNumberForPDF(0),
|
||||
formatNumberForPDF(0),
|
||||
formatNumberForPDF(soldPrecedent),
|
||||
],
|
||||
];
|
||||
|
||||
const totalsStartY = currentY;
|
||||
|
||||
autoTable(doc, {
|
||||
body: totalRows,
|
||||
startY: currentY,
|
||||
styles: {
|
||||
fontSize: 8,
|
||||
cellPadding: 1.5,
|
||||
fontStyle: "bold",
|
||||
lineWidth: 0,
|
||||
},
|
||||
columnStyles: columnStyles,
|
||||
margin: { left: marginLeft, right: marginRight },
|
||||
tableWidth: contentWidth,
|
||||
theme: "plain",
|
||||
});
|
||||
|
||||
// Draw outer border for totals box
|
||||
const totalsEndY = doc.lastAutoTable.finalY;
|
||||
doc.setDrawColor(200, 200, 200);
|
||||
doc.setLineWidth(0.1);
|
||||
doc.rect(marginLeft, totalsStartY, contentWidth, totalsEndY - totalsStartY);
|
||||
|
||||
currentY = doc.lastAutoTable.finalY + 3;
|
||||
} else {
|
||||
// Group bank rows by date
|
||||
const groupedByDate = {};
|
||||
bankRows.forEach((row) => {
|
||||
const dateKey = row.dataact;
|
||||
if (!groupedByDate[dateKey]) {
|
||||
groupedByDate[dateKey] = [];
|
||||
}
|
||||
groupedByDate[dateKey].push(row);
|
||||
});
|
||||
|
||||
// Cumulative totals for the bank
|
||||
let cumulativeIncasari = 0;
|
||||
let cumulativePlati = 0;
|
||||
let lastSold = soldPrecedent;
|
||||
|
||||
const dates = Object.keys(groupedByDate).sort();
|
||||
|
||||
dates.forEach((dateKey, dateIndex) => {
|
||||
const dateRows = groupedByDate[dateKey];
|
||||
const dateFormatted = dateKey
|
||||
? new Date(dateKey).toLocaleDateString("ro-RO")
|
||||
: "";
|
||||
|
||||
checkNewPage(30);
|
||||
|
||||
// Prepare rows for this date
|
||||
const tableRows = [];
|
||||
let dailyIncasari = 0;
|
||||
let dailyPlati = 0;
|
||||
|
||||
dateRows.forEach((row) => {
|
||||
const incasari = parseFloat(row.incasari) || 0;
|
||||
const plati = parseFloat(row.plati) || 0;
|
||||
|
||||
dailyIncasari += incasari;
|
||||
dailyPlati += plati;
|
||||
lastSold = parseFloat(row.sold) || lastSold;
|
||||
|
||||
tableRows.push([
|
||||
dateFormatted,
|
||||
row.nract || "",
|
||||
truncateText(removeDiacritics(row.explicatia || row.nume || ""), 100),
|
||||
formatNumberForPDF(incasari),
|
||||
formatNumberForPDF(plati),
|
||||
formatNumberForPDF(row.sold),
|
||||
]);
|
||||
});
|
||||
|
||||
cumulativeIncasari += dailyIncasari;
|
||||
cumulativePlati += dailyPlati;
|
||||
|
||||
// Draw table for this date group
|
||||
autoTable(doc, {
|
||||
head: dateIndex === 0 ? [tableColumns] : [],
|
||||
body: tableRows,
|
||||
startY: currentY,
|
||||
styles: {
|
||||
fontSize: 8,
|
||||
cellPadding: 1.5,
|
||||
lineColor: [200, 200, 200],
|
||||
lineWidth: 0.1,
|
||||
overflow: "linebreak",
|
||||
},
|
||||
headStyles: {
|
||||
fillColor: [41, 128, 185],
|
||||
textColor: 255,
|
||||
fontStyle: "bold",
|
||||
halign: "center",
|
||||
fontSize: 8,
|
||||
},
|
||||
columnStyles: columnStyles,
|
||||
margin: { left: marginLeft, right: marginRight },
|
||||
tableWidth: contentWidth,
|
||||
theme: "grid",
|
||||
showHead: dateIndex === 0 ? "firstPage" : "never",
|
||||
});
|
||||
|
||||
currentY = doc.lastAutoTable.finalY;
|
||||
|
||||
// Daily total + Cumulative total rows in same box
|
||||
checkNewPage(16);
|
||||
|
||||
const totalRows = [
|
||||
[
|
||||
"",
|
||||
"",
|
||||
`Total zi: ${dateFormatted}`,
|
||||
formatNumberForPDF(dailyIncasari),
|
||||
formatNumberForPDF(dailyPlati),
|
||||
"Sold",
|
||||
],
|
||||
[
|
||||
"",
|
||||
"",
|
||||
"Total cumulat:",
|
||||
formatNumberForPDF(cumulativeIncasari),
|
||||
formatNumberForPDF(cumulativePlati),
|
||||
formatNumberForPDF(lastSold),
|
||||
],
|
||||
];
|
||||
|
||||
const totalsStartY = currentY;
|
||||
|
||||
autoTable(doc, {
|
||||
body: totalRows,
|
||||
startY: currentY,
|
||||
styles: {
|
||||
fontSize: 8,
|
||||
cellPadding: 1.5,
|
||||
fontStyle: "bold",
|
||||
lineWidth: 0,
|
||||
},
|
||||
columnStyles: columnStyles,
|
||||
margin: { left: marginLeft, right: marginRight },
|
||||
tableWidth: contentWidth,
|
||||
theme: "plain",
|
||||
});
|
||||
|
||||
// Draw outer border for totals box (no internal lines)
|
||||
const totalsEndY = doc.lastAutoTable.finalY;
|
||||
doc.setDrawColor(200, 200, 200);
|
||||
doc.setLineWidth(0.1);
|
||||
doc.rect(marginLeft, totalsStartY, contentWidth, totalsEndY - totalsStartY);
|
||||
|
||||
currentY = doc.lastAutoTable.finalY + 3;
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// Add footer to all pages (Generat: DATE on left, Pagina X din Y on right)
|
||||
const totalPages = doc.internal.getNumberOfPages();
|
||||
const generatedText = `Generat: ${new Date().toLocaleString("ro-RO")}`;
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
doc.setPage(i);
|
||||
doc.setFontSize(8);
|
||||
doc.setFont(undefined, "normal");
|
||||
const footerY = pageHeight - 8;
|
||||
|
||||
// Left: Generated date
|
||||
doc.text(generatedText, marginLeft, footerY);
|
||||
|
||||
// Right: Page number
|
||||
const pageText = `Pagina ${i} din ${totalPages}`;
|
||||
const pageTextWidth = doc.getTextWidth(pageText);
|
||||
doc.text(pageText, pageWidth - marginRight - pageTextWidth, footerY);
|
||||
}
|
||||
|
||||
doc.save(`${filename}_${new Date().toISOString().split("T")[0]}.pdf`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Bank Cash Register PDF export error:", error);
|
||||
return { success: false, error: error.message || "PDF generation failed" };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Export Trend Data
|
||||
*/
|
||||
export const exportTrendData = (trendsData, period, chartType) => {
|
||||
if (!trendsData || !trendsData.labels || !trendsData.datasets) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const data = trendsData.labels.map((label, index) => {
|
||||
const row = { Perioada: label };
|
||||
|
||||
trendsData.datasets.forEach((dataset) => {
|
||||
const value = dataset.data[index];
|
||||
row[dataset.label] = value || 0;
|
||||
});
|
||||
|
||||
return row;
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
0
src/modules/reports/utils/index.js
Normal file
0
src/modules/reports/utils/index.js
Normal file
Reference in New Issue
Block a user