feat: importação de extratos OFX/XLS com memória de categorias

Adiciona fluxo completo de importação de extratos bancários:
- Upload e parsing de arquivos OFX e XLS/XLSX
- Tela de revisão com virtualização (@tanstack/react-virtual)
- Detecção automática de categoria por histórico de uso
- Deduplicação por FITID (OFX) e importBatchId
- Tabela `import_category_mappings` para persistir mapeamentos
- Botão de acesso ao fluxo na tabela de transações

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Felipe Coutinho
2026-03-21 14:04:30 +00:00
parent deb7c775f8
commit a20fe255f3
22 changed files with 6897 additions and 152 deletions

View File

@@ -0,0 +1,3 @@
ALTER TABLE "lancamentos" ADD COLUMN "ofx_fit_id" text;--> statement-breakpoint
ALTER TABLE "lancamentos" ADD COLUMN "import_batch_id" text;--> statement-breakpoint
CREATE UNIQUE INDEX "lancamentos_ofx_fit_id_user_id_idx" ON "lancamentos" USING btree ("user_id","ofx_fit_id") WHERE ofx_fit_id IS NOT NULL;

View File

@@ -0,0 +1,7 @@
CREATE TABLE "import_category_mappings" (
"user_id" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
"description_key" text NOT NULL,
"category_id" uuid NOT NULL REFERENCES "categorias"("id") ON DELETE CASCADE,
"updated_at" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("user_id", "description_key")
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,153 +1,167 @@
{ {
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "7", "version": "7",
"when": 1762993507299, "when": 1762993507299,
"tag": "0000_flashy_manta", "tag": "0000_flashy_manta",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "7", "version": "7",
"when": 1765199006435, "when": 1765199006435,
"tag": "0001_young_mister_fear", "tag": "0001_young_mister_fear",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 2, "idx": 2,
"version": "7", "version": "7",
"when": 1765200545692, "when": 1765200545692,
"tag": "0002_slimy_flatman", "tag": "0002_slimy_flatman",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 3, "idx": 3,
"version": "7", "version": "7",
"when": 1767102605526, "when": 1767102605526,
"tag": "0003_green_korg", "tag": "0003_green_korg",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 4, "idx": 4,
"version": "7", "version": "7",
"when": 1767104066872, "when": 1767104066872,
"tag": "0004_acoustic_mach_iv", "tag": "0004_acoustic_mach_iv",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 5, "idx": 5,
"version": "7", "version": "7",
"when": 1767106121811, "when": 1767106121811,
"tag": "0005_adorable_bruce_banner", "tag": "0005_adorable_bruce_banner",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 6, "idx": 6,
"version": "7", "version": "7",
"when": 1767107487318, "when": 1767107487318,
"tag": "0006_youthful_mister_fear", "tag": "0006_youthful_mister_fear",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 7, "idx": 7,
"version": "7", "version": "7",
"when": 1767118780033, "when": 1767118780033,
"tag": "0007_sturdy_kate_bishop", "tag": "0007_sturdy_kate_bishop",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 8, "idx": 8,
"version": "7", "version": "7",
"when": 1767125796314, "when": 1767125796314,
"tag": "0008_fat_stick", "tag": "0008_fat_stick",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 9, "idx": 9,
"version": "7", "version": "7",
"when": 1768925100873, "when": 1768925100873,
"tag": "0009_add_dashboard_widgets", "tag": "0009_add_dashboard_widgets",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 10, "idx": 10,
"version": "7", "version": "7",
"when": 1769369834242, "when": 1769369834242,
"tag": "0010_lame_psynapse", "tag": "0010_lame_psynapse",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 11, "idx": 11,
"version": "7", "version": "7",
"when": 1769447087678, "when": 1769447087678,
"tag": "0011_remove_unused_inbox_columns", "tag": "0011_remove_unused_inbox_columns",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 12, "idx": 12,
"version": "7", "version": "7",
"when": 1769533200000, "when": 1769533200000,
"tag": "0012_rename_tables_to_portuguese", "tag": "0012_rename_tables_to_portuguese",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 13, "idx": 13,
"version": "7", "version": "7",
"when": 1769523352777, "when": 1769523352777,
"tag": "0013_fancy_rick_jones", "tag": "0013_fancy_rick_jones",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 14, "idx": 14,
"version": "7", "version": "7",
"when": 1769619226903, "when": 1769619226903,
"tag": "0014_yielding_jack_flag", "tag": "0014_yielding_jack_flag",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 15, "idx": 15,
"version": "7", "version": "7",
"when": 1770332054481, "when": 1770332054481,
"tag": "0015_concerned_kat_farrell", "tag": "0015_concerned_kat_farrell",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 16, "idx": 16,
"version": "7", "version": "7",
"when": 1771166328908, "when": 1771166328908,
"tag": "0016_complete_randall", "tag": "0016_complete_randall",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 17, "idx": 17,
"version": "7", "version": "7",
"when": 1772400510326, "when": 1772400510326,
"tag": "0017_previous_warstar", "tag": "0017_previous_warstar",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 18, "idx": 18,
"version": "7", "version": "7",
"when": 1773020417482, "when": 1773020417482,
"tag": "0018_rainy_epoch", "tag": "0018_rainy_epoch",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 19, "idx": 19,
"version": "7", "version": "7",
"when": 1773699152928, "when": 1773699152928,
"tag": "0019_ordinary_wild_pack", "tag": "0019_ordinary_wild_pack",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 20, "idx": 20,
"version": "7", "version": "7",
"when": 1773841892114, "when": 1773841892114,
"tag": "0020_add-budget-invoice-unique-constraints", "tag": "0020_add-budget-invoice-unique-constraints",
"breakpoints": true "breakpoints": true
} },
] {
"idx": 21,
"version": "7",
"when": 1774033320053,
"tag": "0021_careful_malcolm_colcord",
"breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1748000000000,
"tag": "0022_import-category-mappings",
"breakpoints": true
}
]
} }

View File

@@ -57,6 +57,7 @@
"@radix-ui/react-tooltip": "1.2.8", "@radix-ui/react-tooltip": "1.2.8",
"@remixicon/react": "4.9.0", "@remixicon/react": "4.9.0",
"@tanstack/react-table": "8.21.3", "@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "^3.13.23",
"@vercel/analytics": "^2.0.1", "@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0", "@vercel/speed-insights": "^2.0.0",
"ai": "^6.0.127", "ai": "^6.0.127",

20
pnpm-lock.yaml generated
View File

@@ -95,6 +95,9 @@ importers:
'@tanstack/react-table': '@tanstack/react-table':
specifier: 8.21.3 specifier: 8.21.3
version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@tanstack/react-virtual':
specifier: ^3.13.23
version: 3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@vercel/analytics': '@vercel/analytics':
specifier: ^2.0.1 specifier: ^2.0.1
version: 2.0.1(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) version: 2.0.1(next@16.1.7(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)
@@ -2188,10 +2191,19 @@ packages:
react: '>=16.8' react: '>=16.8'
react-dom: '>=16.8' react-dom: '>=16.8'
'@tanstack/react-virtual@3.13.23':
resolution: {integrity: sha512-XnMRnHQ23piOVj2bzJqHrRrLg4r+F86fuBcwteKfbIjJrtGxb4z7tIvPVAe4B+4UVwo9G4Giuz5fmapcrnZ0OQ==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/table-core@8.21.3': '@tanstack/table-core@8.21.3':
resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==}
engines: {node: '>=12'} engines: {node: '>=12'}
'@tanstack/virtual-core@3.13.23':
resolution: {integrity: sha512-zSz2Z2HNyLjCplANTDyl3BcdQJc2k1+yyFoKhNRmCr7V7dY8o8q5m8uFTI1/Pg1kL+Hgrz6u3Xo6eFUB7l66cg==}
'@types/canvas-confetti@1.9.0': '@types/canvas-confetti@1.9.0':
resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==} resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
@@ -5251,8 +5263,16 @@ snapshots:
react: 19.2.4 react: 19.2.4
react-dom: 19.2.4(react@19.2.4) react-dom: 19.2.4(react@19.2.4)
'@tanstack/react-virtual@3.13.23(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@tanstack/virtual-core': 3.13.23
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@tanstack/table-core@8.21.3': {} '@tanstack/table-core@8.21.3': {}
'@tanstack/virtual-core@3.13.23': {}
'@types/canvas-confetti@1.9.0': {} '@types/canvas-confetti@1.9.0': {}
'@types/d3-array@3.2.2': {} '@types/d3-array@3.2.2': {}

View File

@@ -0,0 +1,24 @@
import { ImportPage } from "@/features/transactions/components/import/import-page";
import { fetchTransactionFilterSources } from "@/features/transactions/queries";
import { buildOptionSets, buildSluggedFilters } from "@/features/transactions/page-helpers";
import { getUserId } from "@/shared/lib/auth/server";
export default async function Page() {
const userId = await getUserId();
const filterSources = await fetchTransactionFilterSources(userId);
const sluggedFilters = buildSluggedFilters(filterSources);
const { payerOptions, accountOptions, cardOptions, categoryOptions, defaultPayerId } =
buildOptionSets({ ...sluggedFilters, payerRows: filterSources.payerRows });
return (
<main className="flex flex-col gap-6">
<ImportPage
payerOptions={payerOptions}
accountOptions={accountOptions}
cardOptions={cardOptions}
categoryOptions={categoryOptions}
defaultPayerId={defaultPayerId}
/>
</main>
);
}

View File

@@ -8,6 +8,7 @@ import {
jsonb, jsonb,
numeric, numeric,
pgTable, pgTable,
primaryKey,
smallint, smallint,
text, text,
timestamp, timestamp,
@@ -622,6 +623,8 @@ export const transactions = pgTable(
}), }),
seriesId: uuid("series_id"), seriesId: uuid("series_id"),
transferId: uuid("transfer_id"), transferId: uuid("transfer_id"),
ofxFitId: text("ofx_fit_id"),
importBatchId: text("import_batch_id"),
}, },
(table) => ({ (table) => ({
// Índice composto mais importante: userId + period (usado em quase todas as queries do dashboard) // Índice composto mais importante: userId + period (usado em quase todas as queries do dashboard)
@@ -663,6 +666,10 @@ export const transactions = pgTable(
table.cardId, table.cardId,
table.period, table.period,
), ),
// Dedup OFX: garante FITID único por usuário
ofxFitIdUserIdIdx: uniqueIndex("lancamentos_ofx_fit_id_user_id_idx")
.on(table.userId, table.ofxFitId)
.where(sql`ofx_fit_id IS NOT NULL`),
}), }),
); );
@@ -857,6 +864,25 @@ export const installmentAnticipationsRelations = relations(
}), }),
); );
export const importCategoryMappings = pgTable(
"import_category_mappings",
{
userId: text("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
descriptionKey: text("description_key").notNull(),
categoryId: uuid("category_id")
.notNull()
.references(() => categories.id, { onDelete: "cascade" }),
updatedAt: timestamp("updated_at", { mode: "date", withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => ({
pk: primaryKey({ columns: [table.userId, table.descriptionKey] }),
}),
);
export type User = typeof user.$inferSelect; export type User = typeof user.$inferSelect;
export type NewUser = typeof user.$inferInsert; export type NewUser = typeof user.$inferInsert;
export type Account = typeof account.$inferSelect; export type Account = typeof account.$inferSelect;
@@ -880,3 +906,4 @@ export type ApiToken = typeof apiTokens.$inferSelect;
export type NewApiToken = typeof apiTokens.$inferInsert; export type NewApiToken = typeof apiTokens.$inferInsert;
export type InboxItem = typeof inboxItems.$inferSelect; export type InboxItem = typeof inboxItems.$inferSelect;
export type NewInboxItem = typeof inboxItems.$inferInsert; export type NewInboxItem = typeof inboxItems.$inferInsert;
export type ImportCategoryMapping = typeof importCategoryMappings.$inferSelect;

View File

@@ -0,0 +1,62 @@
"use server";
import { and, eq, inArray, sql } from "drizzle-orm";
import { importCategoryMappings } from "@/db/schema";
import { normalizeDescriptionKey } from "@/features/transactions/lib/import-utils";
import { getUserId } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
// Retorna um map de descriptionKey → categoryId para as descrições fornecidas
export async function fetchCategoryMappings(
descriptions: string[],
): Promise<Record<string, string>> {
const userId = await getUserId();
const keys = descriptions.map(normalizeDescriptionKey).filter(Boolean);
if (keys.length === 0) return {};
const rows = await db
.select({
descriptionKey: importCategoryMappings.descriptionKey,
categoryId: importCategoryMappings.categoryId,
})
.from(importCategoryMappings)
.where(
and(
eq(importCategoryMappings.userId, userId),
inArray(importCategoryMappings.descriptionKey, keys),
),
);
return Object.fromEntries(rows.map((r) => [r.descriptionKey, r.categoryId]));
}
// Salva/atualiza mapeamentos description → category após uma importação
export async function saveCategoryMappings(
rows: { description: string; categoryId: string | null }[],
): Promise<void> {
const userId = await getUserId();
const toUpsert = rows
.filter((r) => r.categoryId !== null)
.map((r) => ({
userId,
descriptionKey: normalizeDescriptionKey(r.description),
categoryId: r.categoryId as string,
updatedAt: new Date(),
}))
.filter((r) => r.descriptionKey.length > 0);
if (toUpsert.length === 0) return;
await db
.insert(importCategoryMappings)
.values(toUpsert)
.onConflictDoUpdate({
target: [importCategoryMappings.userId, importCategoryMappings.descriptionKey],
set: {
categoryId: sql`excluded.category_id`,
updatedAt: sql`excluded.updated_at`,
},
});
}

View File

@@ -0,0 +1,176 @@
"use server";
import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod";
import { transactions } from "@/db/schema";
import {
validateCartaoOwnership,
validateContaOwnership,
validatePagadorOwnership,
} from "@/features/transactions/actions/core";
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { getUserId } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db";
import { uuidSchema } from "@/shared/lib/schemas/common";
import { parseLocalDateString } from "@/shared/utils/date";
const importRowSchema = z.object({
externalId: z.string().nullable(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Data inválida."),
amount: z.number().positive(),
description: z.string().min(1, "Descrição obrigatória."),
transactionType: z.enum(["income", "expense"]),
categoryId: uuidSchema("Category").nullable().optional(),
});
const importSchema = z.object({
rows: z.array(importRowSchema).min(1, "Selecione ao menos uma transação."),
payerId: uuidSchema("Payer").nullable().optional(),
accountId: uuidSchema("FinancialAccount").nullable().optional(),
cardId: uuidSchema("Cartão").nullable().optional(),
paymentMethod: z.string().min(1),
invoicePeriod: z.string().regex(/^\d{4}-\d{2}$/, "Período inválido.").nullable().optional(),
});
export type ImportRow = z.infer<typeof importRowSchema>;
export type ImportInput = z.infer<typeof importSchema>;
type ImportResult =
| { success: true; imported: number; skipped: number; importBatchId: string }
| { success: false; error: string };
// Retorna os externalIds que já existem para o usuário (para marcar duplicatas)
export async function checkDuplicateFitIds(
fitIds: string[],
): Promise<string[]> {
const userId = await getUserId();
const ids = fitIds.filter(Boolean);
if (ids.length === 0) return [];
const rows = await db
.select({ ofxFitId: transactions.ofxFitId })
.from(transactions)
.where(
and(
eq(transactions.userId, userId),
inArray(transactions.ofxFitId, ids),
),
);
return rows.map((r) => r.ofxFitId).filter((id): id is string => id !== null);
}
export async function importTransactionsAction(
input: ImportInput,
): Promise<ImportResult> {
const userId = await getUserId();
const parsed = importSchema.safeParse(input);
if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message ?? "Dados inválidos." };
}
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } = parsed.data;
// Valida ownership
const [payerOk, accountOk, cardOk] = await Promise.all([
validatePagadorOwnership(userId, payerId),
validateContaOwnership(userId, accountId),
validateCartaoOwnership(userId, cardId),
]);
if (!payerOk) return { success: false, error: "Pagador não encontrado." };
if (!accountOk) return { success: false, error: "Conta não encontrada." };
if (!cardOk) return { success: false, error: "Cartão não encontrado." };
if (rows.length === 0) {
return { success: true, imported: 0, skipped: 0, importBatchId: "" };
}
const importBatchId = crypto.randomUUID();
// Cartão de crédito: fatura pode ainda não ter sido paga
const isSettled = paymentMethod !== "Cartão de crédito";
const records = rows.map((row) => {
const purchaseDate = parseLocalDateString(row.date);
const period = invoicePeriod ?? `${purchaseDate.getFullYear()}-${String(purchaseDate.getMonth() + 1).padStart(2, "0")}`;
return {
name: row.description,
transactionType: row.transactionType === "income" ? "Receita" : "Despesa",
condition: "À vista" as const,
paymentMethod,
amount: (row.transactionType === "expense" ? -row.amount : row.amount).toFixed(2),
purchaseDate,
period,
isSettled,
userId,
payerId: payerId ?? null,
accountId: accountId ?? null,
cardId: cardId ?? null,
categoryId: row.categoryId ?? null,
ofxFitId: row.externalId,
importBatchId,
};
});
// onConflictDoNothing usa o uniqueIndex (userId, ofxFitId) WHERE ofxFitId IS NOT NULL
// eliminando o SELECT prévio de checkDuplicateFitIds
const inserted = await db
.insert(transactions)
.values(records)
.onConflictDoNothing()
.returning({ id: transactions.id });
await revalidateForEntity("transactions", userId);
return {
success: true,
imported: inserted.length,
skipped: records.length - inserted.length,
importBatchId,
};
}
export async function deleteTransactionByFitId(
fitId: string,
): Promise<{ success: boolean; error?: string }> {
if (!fitId) return { success: false, error: "FITID inválido." };
const userId = await getUserId();
await db
.delete(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.ofxFitId, fitId),
),
);
await revalidateForEntity("transactions", userId);
return { success: true };
}
export async function undoImportAction(
importBatchId: string,
): Promise<{ success: boolean; error?: string }> {
if (!importBatchId) return { success: false, error: "Batch inválido." };
const userId = await getUserId();
await db
.delete(transactions)
.where(
and(
eq(transactions.userId, userId),
eq(transactions.importBatchId, importBatchId),
),
);
await revalidateForEntity("transactions", userId);
return { success: true };
}

View File

@@ -0,0 +1,188 @@
"use client";
import {
AccountCardSelectContent,
CategorySelectContent,
PayerSelectContent,
} from "@/features/transactions/components/select-items";
import type { SelectOption } from "@/features/transactions/components/types";
import { PeriodPicker } from "@/shared/components/period-picker";
import { Label } from "@/shared/components/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
export type AccountCardValue = `card:${string}` | `account:${string}`;
export function encodeAccountCard(
type: "card" | "account",
id: string,
): AccountCardValue {
return `${type}:${id}` as AccountCardValue;
}
export function decodeAccountCard(value: string): {
type: "card" | "account";
id: string;
} | null {
if (value.startsWith("card:")) return { type: "card", id: value.slice(5) };
if (value.startsWith("account:")) return { type: "account", id: value.slice(8) };
return null;
}
interface GlobalFieldsProps {
accountOptions: SelectOption[];
cardOptions: SelectOption[];
payerOptions: SelectOption[];
categoryOptions: SelectOption[];
accountCardValue: string | null;
payerId: string | null;
invoicePeriod: string | null;
onAccountCardChange: (value: string | null) => void;
onPayerChange: (value: string | null) => void;
onInvoicePeriodChange: (value: string | null) => void;
onBulkCategoryChange: (categoryId: string) => void;
}
export function GlobalFields({
accountOptions,
cardOptions,
payerOptions,
categoryOptions,
accountCardValue,
payerId,
invoicePeriod,
onAccountCardChange,
onPayerChange,
onInvoicePeriodChange,
onBulkCategoryChange,
}: GlobalFieldsProps) {
const isCard = accountCardValue?.startsWith("card:") ?? false;
const expenseCategories = categoryOptions.filter((o) => o.group === "despesa");
const incomeCategories = categoryOptions.filter((o) => o.group === "receita");
return (
<div className="flex flex-col gap-2">
<p className="text-muted-foreground text-sm">
Aplicado a todos os lançamentos importados.
</p>
<div className="flex flex-wrap gap-4">
<div className="flex min-w-44 flex-col gap-1.5">
<Label>Conta / Cartão</Label>
<Select
value={accountCardValue ?? ""}
onValueChange={(v) => onAccountCardChange(v || null)}
>
<SelectTrigger>
<SelectValue placeholder="Selecionar conta ou cartão…" />
</SelectTrigger>
<SelectContent>
{cardOptions.length > 0 && (
<SelectGroup>
<SelectLabel>Cartões</SelectLabel>
{cardOptions.map((opt) => (
<SelectItem key={opt.value} value={`card:${opt.value}`}>
<AccountCardSelectContent
label={opt.label}
logo={opt.logo}
isCartao
/>
</SelectItem>
))}
</SelectGroup>
)}
{cardOptions.length > 0 && accountOptions.length > 0 && (
<SelectSeparator />
)}
{accountOptions.length > 0 && (
<SelectGroup>
<SelectLabel>Contas</SelectLabel>
{accountOptions.map((opt) => (
<SelectItem key={opt.value} value={`account:${opt.value}`}>
<AccountCardSelectContent
label={opt.label}
logo={opt.logo}
isCartao={false}
/>
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
<div className="flex min-w-44 flex-col gap-1.5">
<Label>Pagador</Label>
<Select
value={payerId ?? ""}
onValueChange={(v) => onPayerChange(v || null)}
>
<SelectTrigger>
<SelectValue placeholder="Selecionar pagador…" />
</SelectTrigger>
<SelectContent>
{payerOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<PayerSelectContent label={opt.label} avatarUrl={opt.avatarUrl} />
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex min-w-44 flex-col gap-1.5">
<Label>Categoria</Label>
<Select onValueChange={onBulkCategoryChange}>
<SelectTrigger>
<SelectValue placeholder="Aplicar a todas selecionadas…" />
</SelectTrigger>
<SelectContent>
{expenseCategories.length > 0 && (
<SelectGroup>
<SelectLabel>Despesa</SelectLabel>
{expenseCategories.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<CategorySelectContent label={opt.label} icon={opt.icon} />
</SelectItem>
))}
</SelectGroup>
)}
{expenseCategories.length > 0 && incomeCategories.length > 0 && (
<SelectSeparator />
)}
{incomeCategories.length > 0 && (
<SelectGroup>
<SelectLabel>Receita</SelectLabel>
{incomeCategories.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<CategorySelectContent label={opt.label} icon={opt.icon} />
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
{isCard && (
<div className="flex min-w-44 flex-col gap-1.5">
<Label>Fatura</Label>
<PeriodPicker
value={invoicePeriod ?? ""}
onChange={(v) => onInvoicePeriodChange(v || null)}
placeholder="Selecionar fatura…"
/>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,340 @@
"use client";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
fetchCategoryMappings,
saveCategoryMappings,
} from "@/features/transactions/actions/category-memory-action";
import { normalizeDescriptionKey } from "@/features/transactions/lib/import-utils";
import {
checkDuplicateFitIds,
deleteTransactionByFitId,
importTransactionsAction,
undoImportAction,
} from "@/features/transactions/actions/import-action";
import {
decodeAccountCard,
encodeAccountCard,
GlobalFields,
} from "@/features/transactions/components/import/global-fields";
import { ImportSteps } from "@/features/transactions/components/import/import-steps";
import { ImportSummary } from "@/features/transactions/components/import/import-summary";
import {
type ReviewRow,
ReviewTable,
} from "@/features/transactions/components/import/review-table";
import { UploadZone } from "@/features/transactions/components/import/upload-zone";
import type { SelectOption } from "@/features/transactions/components/types";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Skeleton } from "@/shared/components/ui/skeleton";
import type { ImportStatement } from "@/shared/lib/import/types";
interface ImportPageProps {
payerOptions: SelectOption[];
accountOptions: SelectOption[];
cardOptions: SelectOption[];
categoryOptions: SelectOption[];
defaultPayerId: string | null;
}
export function ImportPage({
payerOptions,
accountOptions,
cardOptions,
categoryOptions,
defaultPayerId,
}: ImportPageProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const [isChecking, setIsChecking] = useState(false);
const [statement, setStatement] = useState<ImportStatement | null>(null);
const [rows, setRows] = useState<ReviewRow[]>([]);
const [payerId, setPayerId] = useState<string | null>(defaultPayerId);
const [accountCardValue, setAccountCardValue] = useState<string | null>(null);
const [invoicePeriod, setInvoicePeriod] = useState<string | null>(null);
const handleParsed = useCallback(async (stmt: ImportStatement) => {
setStatement(stmt);
setIsChecking(true);
try {
const fitIds = stmt.transactions
.map((t) => t.externalId)
.filter((id): id is string => id !== null);
const [duplicates, categoryMappings] = await Promise.all([
checkDuplicateFitIds(fitIds).then((ids) => new Set(ids)),
fetchCategoryMappings(stmt.transactions.map((t) => t.description)),
]);
setRows(
stmt.transactions.map((t) => ({
...t,
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
selected: t.externalId ? !duplicates.has(t.externalId) : true,
categoryId: categoryMappings[normalizeDescriptionKey(t.description)] ?? null,
})),
);
} finally {
setIsChecking(false);
}
}, []);
// Pré-seleciona cartão ou conta com base no tipo detectado no OFX
useEffect(() => {
if (!statement || accountCardValue) return;
if (statement.isCreditCard && cardOptions[0]) {
setAccountCardValue(encodeAccountCard("card", cardOptions[0].value));
} else if (!statement.isCreditCard && accountOptions[0]) {
setAccountCardValue(
encodeAccountCard("account", accountOptions[0].value),
);
}
}, [statement, cardOptions, accountOptions, accountCardValue]);
const toggleRow = (index: number) => {
setRows((prev) =>
prev.map((r, i) => (i === index ? { ...r, selected: !r.selected } : r)),
);
};
const toggleAll = (selected: boolean) => {
setRows((prev) => prev.map((r) => ({ ...r, selected })));
};
const handleCategoryChange = (index: number, categoryId: string | null) => {
setRows((prev) =>
prev.map((r, i) => (i === index ? { ...r, categoryId } : r)),
);
};
const handleUndoDuplicate = async (index: number) => {
const row = rows[index];
if (!row?.externalId) return;
const result = await deleteTransactionByFitId(row.externalId);
if (!result.success) {
toast.error("Não foi possível desfazer a importação anterior.");
return;
}
setRows((prev) =>
prev.map((r, i) =>
i === index ? { ...r, isDuplicate: false, selected: true } : r,
),
);
toast.success("Importação anterior removida.");
};
const handleDescriptionChange = (index: number, description: string) => {
setRows((prev) =>
prev.map((r, i) => (i === index ? { ...r, description } : r)),
);
};
const handleBulkCategoryChange = (categoryId: string) => {
setRows((prev) => prev.map((r) => (r.selected ? { ...r, categoryId } : r)));
};
const isCard = accountCardValue?.startsWith("card:") ?? false;
const { selectedRows, duplicateCount, uncategorizedCount } = useMemo(() => {
const selected = rows.filter((r) => r.selected);
return {
selectedRows: selected,
duplicateCount: rows.filter((r) => r.isDuplicate).length,
uncategorizedCount: selected.filter((r) => !r.categoryId).length,
};
}, [rows]);
const canImport =
selectedRows.length > 0 &&
!!accountCardValue &&
uncategorizedCount === 0 &&
(!isCard || !!invoicePeriod) &&
!isPending;
const handleImport = () => {
if (!statement || !canImport) return;
const decoded = decodeAccountCard(accountCardValue!);
const cardId = decoded?.type === "card" ? decoded.id : null;
const accountId = decoded?.type === "account" ? decoded.id : null;
const paymentMethod =
decoded?.type === "card" ? "Cartão de crédito" : "Pix";
startTransition(async () => {
const result = await importTransactionsAction({
rows: selectedRows.map((r) => ({
externalId: r.externalId,
date: r.date,
amount: r.amount,
description: r.description,
transactionType: r.transactionType,
categoryId: r.categoryId,
})),
payerId,
accountId,
cardId,
paymentMethod,
invoicePeriod,
});
if (!result.success) {
toast.error(result.error);
return;
}
// Salva mapeamentos description → category (fire-and-forget)
saveCategoryMappings(
selectedRows.map((r) => ({ description: r.description, categoryId: r.categoryId })),
);
const { importBatchId } = result;
const msg =
result.skipped > 0
? `${result.imported} importados, ${result.skipped} duplicatas ignoradas.`
: `${result.imported} lançamentos importados.`;
router.push("/transactions");
toast.success(msg, {
duration: 8000,
action: importBatchId
? {
label: "Desfazer",
onClick: async () => {
const undo = await undoImportAction(importBatchId);
if (undo.success) {
toast.success("Importação desfeita.");
} else {
toast.error("Não foi possível desfazer.");
}
},
}
: undefined,
});
});
};
const currentStep = !statement ? "upload" : isPending ? "done" : "review";
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-4">
<div>
<CardTitle>Importar extrato</CardTitle>
<CardDescription>
Importe transações a partir de um arquivo .ofx ou planilha .xlsx exportado pelo seu banco.
</CardDescription>
</div>
<ImportSteps current={currentStep} />
</div>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-6">
{!statement || isChecking ? (
<>
{!statement && <UploadZone onParsed={handleParsed} />}
{isChecking && (
<div className="flex flex-col gap-3">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<div className="flex flex-col gap-2 rounded-lg border p-4">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
</div>
)}
</>
) : (
<>
<ImportSummary
statement={statement}
total={rows.length}
selected={selectedRows.length}
duplicates={duplicateCount}
uncategorized={uncategorizedCount}
/>
<GlobalFields
accountOptions={accountOptions}
cardOptions={cardOptions}
payerOptions={payerOptions}
categoryOptions={categoryOptions}
accountCardValue={accountCardValue}
payerId={payerId}
invoicePeriod={invoicePeriod}
onAccountCardChange={setAccountCardValue}
onPayerChange={setPayerId}
onInvoicePeriodChange={setInvoicePeriod}
onBulkCategoryChange={handleBulkCategoryChange}
/>
<ReviewTable
rows={rows}
categoryOptions={categoryOptions}
onToggle={toggleRow}
onToggleAll={toggleAll}
onCategoryChange={handleCategoryChange}
onDescriptionChange={handleDescriptionChange}
onUndoDuplicate={handleUndoDuplicate}
/>
{/* Sticky footer */}
<div className="sticky bottom-0 -mx-6 border-t bg-background px-6 py-4">
<div className="flex items-center justify-between gap-4">
<Button
variant="outline"
onClick={() => {
setStatement(null);
setRows([]);
setAccountCardValue(null);
setInvoicePeriod(null);
}}
>
Trocar arquivo
</Button>
<div className="flex items-center gap-3">
{!accountCardValue ? (
<p className="text-muted-foreground text-sm">
Selecione uma conta ou cartão para continuar.
</p>
) : uncategorizedCount > 0 ? (
<p className="text-muted-foreground text-sm">
{uncategorizedCount} lançamento
{uncategorizedCount !== 1 ? "s" : ""} sem categoria.
</p>
) : isCard && !invoicePeriod ? (
<p className="text-muted-foreground text-sm">
Selecione a fatura para continuar.
</p>
) : null}
<Button onClick={handleImport} disabled={!canImport}>
{isPending
? "Importando…"
: `Importar ${selectedRows.length} lançamento${selectedRows.length !== 1 ? "s" : ""}`}
</Button>
</div>
</div>
</div>
</>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,70 @@
import { RiCheckLine } from "@remixicon/react";
import { cn } from "@/shared/utils/ui";
type Step = "upload" | "review" | "done";
const STEPS: { key: Step; label: string }[] = [
{ key: "upload", label: "Upload" },
{ key: "review", label: "Revisar" },
{ key: "done", label: "Concluído" },
];
const STEP_ORDER: Step[] = ["upload", "review", "done"];
interface ImportStepsProps {
current: Step;
}
export function ImportSteps({ current }: ImportStepsProps) {
const currentIndex = STEP_ORDER.indexOf(current);
return (
<div className="flex items-center gap-0">
{STEPS.map((step, index) => {
const stepIndex = STEP_ORDER.indexOf(step.key);
const isCompleted = stepIndex < currentIndex;
const isActive = stepIndex === currentIndex;
return (
<div key={step.key} className="flex items-center">
<div className="flex items-center gap-2">
<div
className={cn(
"flex size-6 items-center justify-center rounded-full border text-xs font-medium transition-colors",
isCompleted &&
"border-primary bg-primary text-primary-foreground",
isActive && "border-primary text-primary",
!isCompleted && !isActive && "border-muted-foreground/30 text-muted-foreground",
)}
>
{isCompleted ? (
<RiCheckLine className="size-3.5" />
) : (
<span>{index + 1}</span>
)}
</div>
<span
className={cn(
"text-sm",
isActive && "font-medium text-foreground",
!isActive && "text-muted-foreground",
)}
>
{step.label}
</span>
</div>
{index < STEPS.length - 1 && (
<div
className={cn(
"mx-3 h-px w-10 transition-colors",
stepIndex < currentIndex ? "bg-primary" : "bg-border",
)}
/>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { RiCalendarLine } from "@remixicon/react";
import { Badge } from "@/shared/components/ui/badge";
import { Card } from "@/shared/components/ui/card";
import type { ImportStatement } from "@/shared/lib/import/types";
import { formatDate } from "@/shared/utils/date";
interface ImportSummaryProps {
statement: ImportStatement;
total: number;
selected: number;
duplicates: number;
uncategorized: number;
}
export function ImportSummary({
statement,
total,
selected,
duplicates,
uncategorized,
}: ImportSummaryProps) {
return (
<Card className="flex flex-col gap-1 p-5 text-sm bg-linear-to-br from-primary/5 to-transparent">
{/* Linha 1: título */}
<div className="flex items-center gap-2">
<span className="font-medium">{statement.source}</span>
{statement.isCreditCard && (
<Badge variant="outline">Cartão de crédito</Badge>
)}
</div>
{/* Linha 2: metadados */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground">
{statement.period && (
<span className="flex items-center gap-1">
<RiCalendarLine className="size-3.5 shrink-0" />
{formatDate(statement.period.from)} {" "}
{formatDate(statement.period.to)}
</span>
)}
<span>
<span className="font-medium text-foreground">{selected}</span>/
{total} selecionadas
</span>
{duplicates > 0 && (
<span className="text-amber-600 dark:text-amber-400">
{duplicates} duplicata{duplicates !== 1 ? "s" : ""}
</span>
)}
{uncategorized > 0 ? (
<span>{uncategorized} sem categoria</span>
) : (
selected > 0 && (
<span className="text-emerald-600 dark:text-emerald-400">
todas categorizadas
</span>
)
)}
</div>
</Card>
);
}

View File

@@ -0,0 +1,239 @@
"use client";
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { CategorySelectContent } from "@/features/transactions/components/select-items";
import type { SelectOption } from "@/features/transactions/components/types";
import MoneyValues from "@/shared/components/money-values";
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
import { Checkbox } from "@/shared/components/ui/checkbox";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/components/ui/tooltip";
import type { ImportedTransaction } from "@/shared/lib/import/types";
import { formatDate } from "@/shared/utils/date";
export type ReviewRow = ImportedTransaction & {
selected: boolean;
isDuplicate: boolean;
categoryId: string | null;
};
interface ReviewTableProps {
rows: ReviewRow[];
categoryOptions: SelectOption[];
onToggle: (index: number) => void;
onToggleAll: (selected: boolean) => void;
onCategoryChange: (index: number, categoryId: string | null) => void;
onDescriptionChange: (index: number, description: string) => void;
onUndoDuplicate: (index: number) => void;
}
export function ReviewTable({
rows,
categoryOptions,
onToggle,
onToggleAll,
onCategoryChange,
onDescriptionChange,
onUndoDuplicate,
}: ReviewTableProps) {
const allSelected = rows.every((r) => r.selected);
const someSelected = rows.some((r) => r.selected);
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 44,
overscan: 8,
});
const virtualRows = virtualizer.getVirtualItems();
const totalSize = virtualizer.getTotalSize();
const paddingTop = virtualRows.length > 0 ? (virtualRows[0]?.start ?? 0) : 0;
const paddingBottom =
virtualRows.length > 0
? totalSize - (virtualRows[virtualRows.length - 1]?.end ?? 0)
: 0;
return (
<TooltipProvider>
<div
ref={parentRef}
className="max-h-[480px] overflow-auto rounded-lg border"
>
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
<TableHead className="w-10">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => onToggleAll(!!v)}
aria-label="Selecionar todas"
data-state={
!allSelected && someSelected
? "indeterminate"
: undefined
}
/>
</TableHead>
<TableHead className="w-24">Data</TableHead>
<TableHead>Descrição</TableHead>
<TableHead className="w-44">Categoria</TableHead>
<TableHead className="w-20">Tipo</TableHead>
<TableHead className="w-28 text-right">Valor</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paddingTop > 0 && (
<TableRow>
<TableCell
colSpan={6}
style={{ height: paddingTop, padding: 0 }}
/>
</TableRow>
)}
{virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]!;
const index = virtualRow.index;
return (
<TableRow
key={row.externalId ?? `${row.date}-${index}`}
className={
row.isDuplicate && !row.selected ? "opacity-50" : ""
}
>
<TableCell>
<Checkbox
checked={row.selected}
onCheckedChange={() => onToggle(index)}
aria-label={`Selecionar ${row.description}`}
/>
</TableCell>
<TableCell className="text-muted-foreground text-sm tabular-nums">
{formatDate(row.date)}
</TableCell>
<TableCell className="max-w-[200px] text-sm">
<input
type="text"
value={row.description}
onChange={(e) =>
onDescriptionChange(index, e.target.value)
}
className="w-full bg-transparent text-sm outline-none focus:rounded focus:ring-1 focus:ring-ring"
/>
{row.isDuplicate && (
<div className="mt-0.5 flex items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default rounded-sm bg-muted px-1.5 py-0.5 text-muted-foreground text-xs">
importada
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Esta transação foi importada anteriormente.
</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => onUndoDuplicate(index)}
className="rounded-sm px-1 py-0.5 text-xs text-primary underline-offset-2 hover:underline"
>
desfazer
</button>
</TooltipTrigger>
<TooltipContent>
<p>
Remover a importação anterior e marcar para
reimportar.
</p>
</TooltipContent>
</Tooltip>
</div>
)}
</TableCell>
<TableCell>
<Select
value={row.categoryId ?? ""}
onValueChange={(v) => onCategoryChange(index, v || null)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Categoria…" />
</SelectTrigger>
<SelectContent>
{categoryOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<CategorySelectContent
label={opt.label}
icon={opt.icon}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell>
<TransactionTypeBadge
kind={
row.transactionType === "income"
? "Receita"
: "Despesa"
}
/>
</TableCell>
<TableCell className="text-right tabular-nums text-sm">
<MoneyValues
amount={
row.transactionType === "expense"
? -row.amount
: row.amount
}
showPositiveSign={row.transactionType === "income"}
className={
row.transactionType === "income"
? "text-success"
: "text-foreground"
}
/>
</TableCell>
</TableRow>
);
})}
{paddingBottom > 0 && (
<TableRow>
<TableCell
colSpan={6}
style={{ height: paddingBottom, padding: 0 }}
/>
</TableRow>
)}
</TableBody>
</Table>
</div>
</TooltipProvider>
);
}

View File

@@ -0,0 +1,138 @@
"use client";
import { RiDownloadLine, RiUploadCloud2Line } from "@remixicon/react";
import { useRef, useState } from "react";
import { parseOfx } from "@/shared/lib/import/ofx-parser";
import type { ImportStatement } from "@/shared/lib/import/types";
import { generateXlsTemplate, parseXls } from "@/shared/lib/import/xls-parser";
interface UploadZoneProps {
onParsed: (statement: ImportStatement) => void;
}
export function UploadZone({ onParsed }: UploadZoneProps) {
const [error, setError] = useState<string | null>(null);
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleFile = (file: File) => {
setError(null);
const isOfx = /\.(ofx|qfx)$/i.test(file.name);
const isXls = /\.(xlsx|xls)$/i.test(file.name);
if (!isOfx && !isXls) {
setError("Formato não suportado. Use .ofx, .qfx, .xlsx ou .xls.");
return;
}
if (isOfx) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const content = e.target?.result as string;
const statement = parseOfx(content);
if (statement.transactions.length === 0) {
setError("Nenhuma transação encontrada no arquivo.");
return;
}
onParsed(statement);
} catch {
setError("Não foi possível ler o arquivo. Verifique se é um OFX válido.");
}
};
reader.readAsText(file, "windows-1252");
} else {
const reader = new FileReader();
reader.onload = (e) => {
try {
const buffer = e.target?.result as ArrayBuffer;
const statement = parseXls(buffer);
onParsed(statement);
} catch (err) {
setError(
err instanceof Error
? err.message
: "Não foi possível ler a planilha.",
);
}
};
reader.readAsArrayBuffer(file);
}
};
const handleDownloadTemplate = () => {
const bytes = generateXlsTemplate();
const blob = new Blob([bytes], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "modelo-lancamentos.xlsx";
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="flex flex-col gap-3">
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (file) handleFile(file);
}}
className={`flex flex-col items-center justify-center gap-4 rounded-xl border-2 border-dashed p-24 transition-colors ${
dragging
? "border-primary bg-primary/5"
: "border-border hover:border-primary/50 hover:bg-muted/50"
}`}
>
<RiUploadCloud2Line className="text-muted-foreground size-14" />
<div className="text-center">
<p className="font-medium text-sm">
Arraste um arquivo aqui ou clique para selecionar
</p>
<p className="mt-1 text-muted-foreground text-xs">
.ofx · .qfx · .xlsx · .xls
</p>
</div>
</button>
<input
ref={inputRef}
type="file"
accept=".ofx,.qfx,.xlsx,.xls"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) handleFile(file);
e.target.value = "";
}}
/>
<div className="flex items-center justify-between">
{error ? (
<p className="text-destructive text-sm">{error}</p>
) : (
<span />
)}
<button
type="button"
onClick={handleDownloadTemplate}
className="flex items-center gap-1.5 text-muted-foreground text-xs underline-offset-2 hover:text-foreground hover:underline"
>
<RiDownloadLine className="size-3.5" />
Baixar modelo .xlsx
</button>
</div>
</div>
);
}

View File

@@ -15,6 +15,7 @@ import {
RiFileCopyLine, RiFileCopyLine,
RiFileList2Line, RiFileList2Line,
RiFlashlightFill, RiFlashlightFill,
RiFileExcel2Line,
RiGroupLine, RiGroupLine,
RiHistoryLine, RiHistoryLine,
RiMoreFill, RiMoreFill,
@@ -984,6 +985,22 @@ export function TransactionsTable({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
) : null} ) : null}
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => router.push("/transactions/import")}
variant="outline"
size="icon"
className="hidden size-9 sm:inline-flex"
>
<RiFileExcel2Line className="size-4" />
<span className="sr-only">Importar extrato</span>
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Importar extrato</p>
</TooltipContent>
</Tooltip>
</div> </div>
) : ( ) : (
<span className={showFilters ? "hidden sm:block" : ""} /> <span className={showFilters ? "hidden sm:block" : ""} />

View File

@@ -0,0 +1,3 @@
export function normalizeDescriptionKey(description: string): string {
return description.toLowerCase().trim().replace(/\s+/g, " ");
}

View File

@@ -0,0 +1,59 @@
import type { ImportStatement, ImportedTransaction } from "./types";
// Extrai o valor de uma tag leaf do OFX SGML: <TAG>valor
function getField(block: string, tag: string): string | null {
const match = block.match(new RegExp(`<${tag}>([^<\n\r]+)`));
return match?.[1]?.trim() ?? null;
}
// Converte data OFX "20260320000000[-3:BRT]" para "YYYY-MM-DD"
function parseOfxDate(raw: string): string {
const match = raw.match(/^(\d{4})(\d{2})(\d{2})/);
if (!match) throw new Error(`Data OFX inválida: ${raw}`);
return `${match[1]}-${match[2]}-${match[3]}`;
}
export function parseOfx(content: string): ImportStatement {
// Remove o header SGML (tudo antes de <OFX>)
const ofxStart = content.indexOf("<OFX>");
const xml = ofxStart >= 0 ? content.slice(ofxStart) : content;
// Banco
const source = getField(xml, "ORG") ?? "Desconhecido";
const accountNumber = getField(xml, "ACCTID");
// Período
const dtStart = getField(xml, "DTSTART");
const dtEnd = getField(xml, "DTEND");
const period =
dtStart && dtEnd
? { from: parseOfxDate(dtStart), to: parseOfxDate(dtEnd) }
: null;
// Transações
const blocks = xml.match(/<STMTTRN>[\s\S]*?<\/STMTTRN>/g) ?? [];
const transactions: ImportedTransaction[] = blocks.map((block) => {
const trnType = getField(block, "TRNTYPE") ?? "DEBIT";
const dtPosted = getField(block, "DTPOSTED") ?? "";
const trnAmt = getField(block, "TRNAMT") ?? "0";
const fitId = getField(block, "FITID");
const memo = getField(block, "MEMO");
const name = getField(block, "NAME");
const amount = Number.parseFloat(trnAmt.replace(",", "."));
const transactionType =
amount > 0 || trnType === "CREDIT" ? "income" : "expense";
return {
externalId: fitId,
date: parseOfxDate(dtPosted),
amount: Math.abs(amount),
description: memo ?? name ?? "",
transactionType,
};
});
const isCreditCard = xml.includes("<CREDITCARDMSGSRSV1>");
return { source, accountNumber, period, isCreditCard, transactions };
}

View File

@@ -0,0 +1,15 @@
export type ImportedTransaction = {
externalId: string | null; // FITID do OFX
date: string; // YYYY-MM-DD
amount: number; // positivo = receita, negativo = despesa
description: string; // MEMO ou NAME
transactionType: "income" | "expense";
};
export type ImportStatement = {
source: string; // nome do banco (ORG)
accountNumber: string | null; // ACCTID
period: { from: string; to: string } | null; // YYYY-MM-DD
isCreditCard: boolean; // true = CREDITCARDMSGSRSV1
transactions: ImportedTransaction[];
};

View File

@@ -0,0 +1,142 @@
import * as XLSX from "xlsx";
import type { ImportStatement, ImportedTransaction } from "@/shared/lib/import/types";
function parseDateValue(value: unknown): string | null {
if (value == null || value === "") return null;
// Excel date serial number
if (typeof value === "number") {
const date = XLSX.SSF.parse_date_code(value);
if (!date) return null;
const y = date.y;
const m = String(date.m).padStart(2, "0");
const d = String(date.d).padStart(2, "0");
return `${y}-${m}-${d}`;
}
const str = String(value).trim();
// DD/MM/YYYY
const dmyMatch = str.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
if (dmyMatch) {
return `${dmyMatch[3]}-${dmyMatch[2].padStart(2, "0")}-${dmyMatch[1].padStart(2, "0")}`;
}
// YYYY-MM-DD
const isoMatch = str.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (isoMatch) return str;
return null;
}
function parseAmountValue(value: unknown): number | null {
if (value == null || value === "") return null;
if (typeof value === "number") return Math.abs(value);
const num = Number.parseFloat(
String(value)
.replace(",", ".")
.replace(/[^\d.-]/g, ""),
);
return Number.isNaN(num) ? null : Math.abs(num);
}
export function parseXls(buffer: ArrayBuffer): ImportStatement {
const workbook = XLSX.read(new Uint8Array(buffer), {
type: "array",
cellDates: false,
});
if (!workbook.SheetNames.length) {
throw new Error("Arquivo sem abas.");
}
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
if (!sheet) {
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(
`Planilha vazia ou sem dados (${rows.length} linha(s) encontrada(s)).`,
);
}
const transactions: ImportedTransaction[] = [];
for (let i = 1; i < rows.length; i++) {
const row = rows[i] as unknown[];
if (!row || row.every((cell) => cell == null || cell === "")) continue;
const date = parseDateValue(row[0]);
const description = row[1] != null ? String(row[1]).trim() : "";
const amount = parseAmountValue(row[2]);
const typeRaw = row[3] != null ? String(row[3]).toLowerCase().trim() : "";
const transactionType = typeRaw === "receita" ? "income" : "expense";
if (!date || !description || amount === null || amount <= 0) continue;
transactions.push({
externalId: null,
date,
amount,
description,
transactionType,
});
}
if (transactions.length === 0) {
throw new Error("Nenhuma transação válida encontrada na planilha.");
}
const dates = transactions.map((t) => t.date).sort();
const period = { from: dates[0], to: dates[dates.length - 1] };
return {
source: "Planilha",
accountNumber: null,
period,
isCreditCard: false,
transactions,
};
}
export function generateXlsTemplate(): ArrayBuffer {
const wb = XLSX.utils.book_new();
const data = [
["Data", "Descrição", "Valor", "Tipo"],
["01/03/2026", "Ingressos São Januário", 160, "despesa"],
["01/03/2026", "Salário", 3000.0, "receita"],
["01/03/2026", "Posto do Vasco da Gama", 89.9, "despesa"],
];
const ws = XLSX.utils.aoa_to_sheet(data);
ws["!cols"] = [{ wch: 14 }, { wch: 32 }, { wch: 12 }, { wch: 10 }];
// Dropdown para coluna Tipo (D2:D1000)
if (!ws["!dataValidations"]) ws["!dataValidations"] = [];
(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;
}