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,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,
RiFileList2Line,
RiFlashlightFill,
RiFileExcel2Line,
RiGroupLine,
RiHistoryLine,
RiMoreFill,
@@ -984,6 +985,22 @@ export function TransactionsTable({
</TooltipContent>
</Tooltip>
) : 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>
) : (
<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, " ");
}