mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
Compare commits
2 Commits
v2.3.2
...
85f6dcfc22
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85f6dcfc22 | ||
|
|
df996df93d |
@@ -4,6 +4,8 @@ import type { NextConfig } from "next";
|
|||||||
// Carregar variáveis de ambiente explicitamente
|
// Carregar variáveis de ambiente explicitamente
|
||||||
dotenv.config();
|
dotenv.config();
|
||||||
|
|
||||||
|
const isDev = process.env.NODE_ENV === "development";
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: "standalone",
|
output: "standalone",
|
||||||
cacheComponents: true,
|
cacheComponents: true,
|
||||||
@@ -46,7 +48,7 @@ const nextConfig: NextConfig = {
|
|||||||
key: "Content-Security-Policy",
|
key: "Content-Security-Policy",
|
||||||
value: [
|
value: [
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
"script-src 'self' 'unsafe-inline' https://umami.felipecoutinho.com",
|
`script-src 'self' 'unsafe-inline'${isDev ? " 'unsafe-eval'" : ""} https://umami.felipecoutinho.com`,
|
||||||
"style-src 'self' 'unsafe-inline'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"img-src 'self' https://lh3.googleusercontent.com data: blob:",
|
"img-src 'self' https://lh3.googleusercontent.com data: blob:",
|
||||||
"font-src 'self'",
|
"font-src 'self'",
|
||||||
|
|||||||
@@ -72,6 +72,7 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
|
"exceljs": "^4.4.0",
|
||||||
"jspdf": "^4.2.1",
|
"jspdf": "^4.2.1",
|
||||||
"jspdf-autotable": "^5.0.7",
|
"jspdf-autotable": "^5.0.7",
|
||||||
"next": "16.2.2",
|
"next": "16.2.2",
|
||||||
@@ -87,7 +88,6 @@
|
|||||||
"sonner": "2.0.7",
|
"sonner": "2.0.7",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.5.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"xlsx": "^0.18.5",
|
|
||||||
"zod": "4.3.6"
|
"zod": "4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
646
pnpm-lock.yaml
generated
646
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ interface CategoryReportExportProps {
|
|||||||
filters: FilterState;
|
filters: FilterState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadXlsx = () => import("xlsx");
|
const loadExcelJS = () => import("exceljs");
|
||||||
|
|
||||||
const loadPdfDeps = async () => {
|
const loadPdfDeps = async () => {
|
||||||
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||||
@@ -134,7 +134,7 @@ export function CategoryReportExport({
|
|||||||
const exportToExcel = async () => {
|
const exportToExcel = async () => {
|
||||||
try {
|
try {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
const XLSX = await loadXlsx();
|
const ExcelJS = await loadExcelJS();
|
||||||
|
|
||||||
// Build data array
|
// Build data array
|
||||||
const headers = [
|
const headers = [
|
||||||
@@ -179,20 +179,32 @@ export function CategoryReportExport({
|
|||||||
totalsRow.push(formatCurrency(data.grandTotal));
|
totalsRow.push(formatCurrency(data.grandTotal));
|
||||||
rows.push(totalsRow);
|
rows.push(totalsRow);
|
||||||
|
|
||||||
// Create worksheet
|
// Create workbook and worksheet
|
||||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const ws = workbook.addWorksheet("Relatório de Categorias");
|
||||||
|
|
||||||
|
ws.addRows([headers, ...rows]);
|
||||||
|
|
||||||
// Set column widths
|
// Set column widths
|
||||||
ws["!cols"] = [
|
ws.getColumn(1).width = 20;
|
||||||
{ wch: 20 }, // Category
|
for (let i = 0; i < data.periods.length; i++) {
|
||||||
...data.periods.map(() => ({ wch: 15 })), // Periods
|
ws.getColumn(i + 2).width = 15;
|
||||||
{ wch: 15 }, // Total
|
}
|
||||||
];
|
ws.getColumn(data.periods.length + 2).width = 15;
|
||||||
|
|
||||||
// Create workbook and download
|
// Download
|
||||||
const wb = XLSX.utils.book_new();
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
XLSX.utils.book_append_sheet(wb, ws, "Relatório de Categorias");
|
const blob = new Blob([buffer], {
|
||||||
XLSX.writeFile(wb, getFileName("xlsx"));
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = getFileName("xlsx");
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
toast.success("Relatório exportado em Excel com sucesso!");
|
toast.success("Relatório exportado em Excel com sucesso!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -45,10 +45,10 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
|
|||||||
reader.readAsText(file, "windows-1252");
|
reader.readAsText(file, "windows-1252");
|
||||||
} else {
|
} else {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = async (e) => {
|
||||||
try {
|
try {
|
||||||
const buffer = e.target?.result as ArrayBuffer;
|
const buffer = e.target?.result as ArrayBuffer;
|
||||||
const statement = parseXls(buffer);
|
const statement = await parseXls(buffer);
|
||||||
onParsed(statement);
|
onParsed(statement);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(
|
setError(
|
||||||
@@ -62,8 +62,8 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadTemplate = () => {
|
const handleDownloadTemplate = async () => {
|
||||||
const bytes = generateXlsTemplate();
|
const bytes = await generateXlsTemplate();
|
||||||
const blob = new Blob([bytes], {
|
const blob = new Blob([bytes], {
|
||||||
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ interface LancamentosExportProps {
|
|||||||
exportContext?: TransactionsExportContext;
|
exportContext?: TransactionsExportContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadXlsx = () => import("xlsx");
|
const loadExcelJS = () => import("exceljs");
|
||||||
|
|
||||||
const loadPdfDeps = async () => {
|
const loadPdfDeps = async () => {
|
||||||
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
const [{ default: jsPDF }, { default: autoTable }] = await Promise.all([
|
||||||
@@ -158,7 +158,7 @@ export function TransactionsExport({
|
|||||||
try {
|
try {
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
const transactions = await loadTransactions();
|
const transactions = await loadTransactions();
|
||||||
const XLSX = await loadXlsx();
|
const ExcelJS = await loadExcelJS();
|
||||||
|
|
||||||
const headers = [
|
const headers = [
|
||||||
"Data",
|
"Data",
|
||||||
@@ -188,23 +188,28 @@ export function TransactionsExport({
|
|||||||
rows.push(row);
|
rows.push(row);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const ws = workbook.addWorksheet("Lançamentos");
|
||||||
|
|
||||||
ws["!cols"] = [
|
ws.addRows([headers, ...rows]);
|
||||||
{ wch: 12 }, // Data
|
|
||||||
{ wch: 42 }, // Nome
|
|
||||||
{ wch: 15 }, // Tipo
|
|
||||||
{ wch: 15 }, // Condição
|
|
||||||
{ wch: 20 }, // Pagamento
|
|
||||||
{ wch: 15 }, // Valor
|
|
||||||
{ wch: 20 }, // Category
|
|
||||||
{ wch: 20 }, // Conta/Cartão
|
|
||||||
{ wch: 20 }, // Payer
|
|
||||||
];
|
|
||||||
|
|
||||||
const wb = XLSX.utils.book_new();
|
const colWidths = [12, 42, 15, 15, 20, 15, 20, 20, 20];
|
||||||
XLSX.utils.book_append_sheet(wb, ws, "Lançamentos");
|
colWidths.forEach((w, i) => {
|
||||||
XLSX.writeFile(wb, getFileName("xlsx"));
|
ws.getColumn(i + 1).width = w;
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
|
const blob = new Blob([buffer], {
|
||||||
|
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = getFileName("xlsx");
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
toast.success("Lançamentos exportados em Excel com sucesso!");
|
toast.success("Lançamentos exportados em Excel com sucesso!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,15 +1,34 @@
|
|||||||
import * as XLSX from "xlsx";
|
import ExcelJS from "exceljs";
|
||||||
import type {
|
import type {
|
||||||
ImportedTransaction,
|
ImportedTransaction,
|
||||||
ImportStatement,
|
ImportStatement,
|
||||||
} from "@/shared/lib/import/types";
|
} from "@/shared/lib/import/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converte serial number do Excel (1900 date system) para ano/mês/dia.
|
||||||
|
* Excel trata 1900 como bissexto (serial 60 = 29/02/1900 inexistente).
|
||||||
|
*/
|
||||||
|
function excelSerialToDate(
|
||||||
|
serial: number,
|
||||||
|
): { y: number; m: number; d: number } | null {
|
||||||
|
if (serial < 1) return null;
|
||||||
|
let adjusted = serial;
|
||||||
|
if (serial > 60) adjusted--;
|
||||||
|
const baseDate = new Date(1899, 11, 31);
|
||||||
|
const date = new Date(baseDate.getTime() + adjusted * 86400000);
|
||||||
|
return {
|
||||||
|
y: date.getFullYear(),
|
||||||
|
m: date.getMonth() + 1,
|
||||||
|
d: date.getDate(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parseDateValue(value: unknown): string | null {
|
function parseDateValue(value: unknown): string | null {
|
||||||
if (value == null || value === "") return null;
|
if (value == null || value === "") return null;
|
||||||
|
|
||||||
// Excel date serial number
|
// Excel date serial number
|
||||||
if (typeof value === "number") {
|
if (typeof value === "number") {
|
||||||
const date = XLSX.SSF.parse_date_code(value);
|
const date = excelSerialToDate(value);
|
||||||
if (!date) return null;
|
if (!date) return null;
|
||||||
const y = date.y;
|
const y = date.y;
|
||||||
const m = String(date.m).padStart(2, "0");
|
const m = String(date.m).padStart(2, "0");
|
||||||
@@ -17,6 +36,14 @@ function parseDateValue(value: unknown): string | null {
|
|||||||
return `${y}-${m}-${d}`;
|
return `${y}-${m}-${d}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExcelJS pode retornar Date objects
|
||||||
|
if (value instanceof Date) {
|
||||||
|
const y = value.getFullYear();
|
||||||
|
const m = String(value.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(value.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${d}`;
|
||||||
|
}
|
||||||
|
|
||||||
const str = String(value).trim();
|
const str = String(value).trim();
|
||||||
|
|
||||||
// DD/MM/YYYY
|
// DD/MM/YYYY
|
||||||
@@ -43,54 +70,37 @@ function parseAmountValue(value: unknown): number | null {
|
|||||||
return Number.isNaN(num) ? null : Math.abs(num);
|
return Number.isNaN(num) ? null : Math.abs(num);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseXls(buffer: ArrayBuffer): ImportStatement {
|
export async function parseXls(buffer: ArrayBuffer): Promise<ImportStatement> {
|
||||||
const workbook = XLSX.read(new Uint8Array(buffer), {
|
const workbook = new ExcelJS.Workbook();
|
||||||
type: "array",
|
await workbook.xlsx.load(buffer);
|
||||||
cellDates: false,
|
|
||||||
cellFormula: false,
|
|
||||||
cellNF: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!workbook.SheetNames.length) {
|
if (workbook.worksheets.length === 0) {
|
||||||
throw new Error("Arquivo sem abas.");
|
throw new Error("Arquivo sem abas.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const sheetName = workbook.SheetNames[0];
|
const sheet = workbook.worksheets[0];
|
||||||
const sheet = workbook.Sheets[sheetName];
|
|
||||||
|
|
||||||
if (!sheet) {
|
if (!sheet || sheet.rowCount < 2) {
|
||||||
throw new Error(`Aba "${sheetName}" não encontrada.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const range = sheet["!ref"];
|
|
||||||
if (!range) {
|
|
||||||
throw new Error("Planilha vazia (sem intervalo de células).");
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = XLSX.utils.sheet_to_json<unknown[]>(sheet, {
|
|
||||||
header: 1,
|
|
||||||
defval: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (rows.length < 2) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Planilha vazia ou sem dados (${rows.length} linha(s) encontrada(s)).`,
|
`Planilha vazia ou sem dados (${sheet?.rowCount ?? 0} linha(s) encontrada(s)).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const transactions: ImportedTransaction[] = [];
|
const transactions: ImportedTransaction[] = [];
|
||||||
|
|
||||||
for (let i = 1; i < rows.length; i++) {
|
sheet.eachRow((row, rowNumber) => {
|
||||||
const row = rows[i] as unknown[];
|
if (rowNumber === 1) return; // skip header
|
||||||
if (!row || row.every((cell) => cell == null || cell === "")) continue;
|
|
||||||
|
|
||||||
const date = parseDateValue(row[0]);
|
// ExcelJS row.values é 1-indexed (values[0] é undefined)
|
||||||
const description = row[1] != null ? String(row[1]).trim() : "";
|
const values = row.values as unknown[];
|
||||||
const amount = parseAmountValue(row[2]);
|
const date = parseDateValue(values[1]);
|
||||||
const typeRaw = row[3] != null ? String(row[3]).toLowerCase().trim() : "";
|
const description = values[2] != null ? String(values[2]).trim() : "";
|
||||||
|
const amount = parseAmountValue(values[3]);
|
||||||
|
const typeRaw =
|
||||||
|
values[4] != null ? String(values[4]).toLowerCase().trim() : "";
|
||||||
const transactionType = typeRaw === "receita" ? "income" : "expense";
|
const transactionType = typeRaw === "receita" ? "income" : "expense";
|
||||||
|
|
||||||
if (!date || !description || amount === null || amount <= 0) continue;
|
if (!date || !description || amount === null || amount <= 0) return;
|
||||||
|
|
||||||
transactions.push({
|
transactions.push({
|
||||||
externalId: null,
|
externalId: null,
|
||||||
@@ -99,7 +109,7 @@ export function parseXls(buffer: ArrayBuffer): ImportStatement {
|
|||||||
description,
|
description,
|
||||||
transactionType,
|
transactionType,
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
|
|
||||||
if (transactions.length === 0) {
|
if (transactions.length === 0) {
|
||||||
throw new Error("Nenhuma transação válida encontrada na planilha.");
|
throw new Error("Nenhuma transação válida encontrada na planilha.");
|
||||||
@@ -117,31 +127,31 @@ export function parseXls(buffer: ArrayBuffer): ImportStatement {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function generateXlsTemplate(): ArrayBuffer {
|
export async function generateXlsTemplate(): Promise<ArrayBuffer> {
|
||||||
const wb = XLSX.utils.book_new();
|
const workbook = new ExcelJS.Workbook();
|
||||||
|
const ws = workbook.addWorksheet("Lançamentos");
|
||||||
|
|
||||||
const data = [
|
ws.addRows([
|
||||||
["Data", "Descrição", "Valor", "Tipo"],
|
["Data", "Descrição", "Valor", "Tipo"],
|
||||||
["01/03/2026", "Ingressos São Januário", 160, "despesa"],
|
["01/03/2026", "Ingressos São Januário", 160, "despesa"],
|
||||||
["01/03/2026", "Salário", 3000.0, "receita"],
|
["01/03/2026", "Salário", 3000.0, "receita"],
|
||||||
["01/03/2026", "Posto do Vasco da Gama", 89.9, "despesa"],
|
["01/03/2026", "Posto do Vasco da Gama", 89.9, "despesa"],
|
||||||
];
|
]);
|
||||||
|
|
||||||
const ws = XLSX.utils.aoa_to_sheet(data);
|
ws.getColumn(1).width = 14;
|
||||||
|
ws.getColumn(2).width = 32;
|
||||||
|
ws.getColumn(3).width = 12;
|
||||||
|
ws.getColumn(4).width = 10;
|
||||||
|
|
||||||
ws["!cols"] = [{ wch: 14 }, { wch: 32 }, { wch: 12 }, { wch: 10 }];
|
// Dropdown para coluna Tipo (D2:D100)
|
||||||
|
for (let i = 2; i <= 100; i++) {
|
||||||
|
ws.getCell(`D${i}`).dataValidation = {
|
||||||
|
type: "list",
|
||||||
|
allowBlank: true,
|
||||||
|
formulae: ['"despesa,receita"'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Dropdown para coluna Tipo (D2:D1000)
|
const buffer = await workbook.xlsx.writeBuffer();
|
||||||
if (!ws["!dataValidations"]) ws["!dataValidations"] = [];
|
return buffer as ArrayBuffer;
|
||||||
(ws["!dataValidations"] as object[]).push({
|
|
||||||
type: "list",
|
|
||||||
sqref: "D2:D1000",
|
|
||||||
formula1: '"despesa,receita"',
|
|
||||||
showDropDown: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
XLSX.utils.book_append_sheet(wb, ws, "Lançamentos");
|
|
||||||
|
|
||||||
const raw = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as number[];
|
|
||||||
return new Uint8Array(raw).buffer as ArrayBuffer;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user