mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 11:01:45 +00:00
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:
24
src/app/(dashboard)/transactions/import/page.tsx
Normal file
24
src/app/(dashboard)/transactions/import/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
jsonb,
|
||||
numeric,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
smallint,
|
||||
text,
|
||||
timestamp,
|
||||
@@ -622,6 +623,8 @@ export const transactions = pgTable(
|
||||
}),
|
||||
seriesId: uuid("series_id"),
|
||||
transferId: uuid("transfer_id"),
|
||||
ofxFitId: text("ofx_fit_id"),
|
||||
importBatchId: text("import_batch_id"),
|
||||
},
|
||||
(table) => ({
|
||||
// Í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.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 NewUser = typeof user.$inferInsert;
|
||||
export type Account = typeof account.$inferSelect;
|
||||
@@ -880,3 +906,4 @@ export type ApiToken = typeof apiTokens.$inferSelect;
|
||||
export type NewApiToken = typeof apiTokens.$inferInsert;
|
||||
export type InboxItem = typeof inboxItems.$inferSelect;
|
||||
export type NewInboxItem = typeof inboxItems.$inferInsert;
|
||||
export type ImportCategoryMapping = typeof importCategoryMappings.$inferSelect;
|
||||
|
||||
62
src/features/transactions/actions/category-memory-action.ts
Normal file
62
src/features/transactions/actions/category-memory-action.ts
Normal 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`,
|
||||
},
|
||||
});
|
||||
}
|
||||
176
src/features/transactions/actions/import-action.ts
Normal file
176
src/features/transactions/actions/import-action.ts
Normal 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 };
|
||||
}
|
||||
188
src/features/transactions/components/import/global-fields.tsx
Normal file
188
src/features/transactions/components/import/global-fields.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
340
src/features/transactions/components/import/import-page.tsx
Normal file
340
src/features/transactions/components/import/import-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/features/transactions/components/import/import-steps.tsx
Normal file
70
src/features/transactions/components/import/import-steps.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
239
src/features/transactions/components/import/review-table.tsx
Normal file
239
src/features/transactions/components/import/review-table.tsx
Normal 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">
|
||||
Já importada
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Esta transação já 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>
|
||||
);
|
||||
}
|
||||
138
src/features/transactions/components/import/upload-zone.tsx
Normal file
138
src/features/transactions/components/import/upload-zone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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" : ""} />
|
||||
|
||||
3
src/features/transactions/lib/import-utils.ts
Normal file
3
src/features/transactions/lib/import-utils.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function normalizeDescriptionKey(description: string): string {
|
||||
return description.toLowerCase().trim().replace(/\s+/g, " ");
|
||||
}
|
||||
59
src/shared/lib/import/ofx-parser.ts
Normal file
59
src/shared/lib/import/ofx-parser.ts
Normal 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 };
|
||||
}
|
||||
15
src/shared/lib/import/types.ts
Normal file
15
src/shared/lib/import/types.ts
Normal 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[];
|
||||
};
|
||||
142
src/shared/lib/import/xls-parser.ts
Normal file
142
src/shared/lib/import/xls-parser.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user