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`,
},
});
}