style: normalizar formatacao de importacao e suporte

This commit is contained in:
Felipe Coutinho
2026-03-21 19:32:38 +00:00
parent 19a1b1e943
commit 1d36b12109
12 changed files with 119 additions and 73 deletions

View File

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

View File

@@ -48,21 +48,20 @@ const accountBaseSchema = z.object({
.string({ message: "Selecione um logo." }) .string({ message: "Selecione um logo." })
.trim() .trim()
.min(1, "Selecione um logo."), .min(1, "Selecione um logo."),
initialBalance: z initialBalance: z.union([
.union([ z.number(),
z.number(), z
z .string()
.string() .trim()
.trim() .transform((value) =>
.transform((value) => value.length === 0 ? "0" : value.replace(",", "."),
value.length === 0 ? "0" : value.replace(",", "."), )
) .refine(
.refine( (value) => !Number.isNaN(Number.parseFloat(value)),
(value) => !Number.isNaN(Number.parseFloat(value)), "Informe um saldo inicial válido.",
"Informe um saldo inicial válido.", )
) .transform((value) => Number.parseFloat(value)),
.transform((value) => Number.parseFloat(value)), ]),
]),
excludeFromBalance: z excludeFromBalance: z
.union([z.boolean(), z.string()]) .union([z.boolean(), z.string()])
.transform((value) => value === true || value === "true"), .transform((value) => value === true || value === "true"),

View File

@@ -6,7 +6,6 @@ import { normalizeDescriptionKey } from "@/features/transactions/lib/import-util
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
import { db } from "@/shared/lib/db"; import { db } from "@/shared/lib/db";
// Retorna um map de descriptionKey → categoryId para as descrições fornecidas // Retorna um map de descriptionKey → categoryId para as descrições fornecidas
export async function fetchCategoryMappings( export async function fetchCategoryMappings(
descriptions: string[], descriptions: string[],
@@ -53,7 +52,10 @@ export async function saveCategoryMappings(
.insert(importCategoryMappings) .insert(importCategoryMappings)
.values(toUpsert) .values(toUpsert)
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [importCategoryMappings.userId, importCategoryMappings.descriptionKey], target: [
importCategoryMappings.userId,
importCategoryMappings.descriptionKey,
],
set: { set: {
categoryId: sql`excluded.category_id`, categoryId: sql`excluded.category_id`,
updatedAt: sql`excluded.updated_at`, updatedAt: sql`excluded.updated_at`,

View File

@@ -29,7 +29,11 @@ const importSchema = z.object({
accountId: uuidSchema("FinancialAccount").nullable().optional(), accountId: uuidSchema("FinancialAccount").nullable().optional(),
cardId: uuidSchema("Cartão").nullable().optional(), cardId: uuidSchema("Cartão").nullable().optional(),
paymentMethod: z.string().min(1), paymentMethod: z.string().min(1),
invoicePeriod: z.string().regex(/^\d{4}-\d{2}$/, "Período inválido.").nullable().optional(), invoicePeriod: z
.string()
.regex(/^\d{4}-\d{2}$/, "Período inválido.")
.nullable()
.optional(),
}); });
export type ImportRow = z.infer<typeof importRowSchema>; export type ImportRow = z.infer<typeof importRowSchema>;
@@ -51,10 +55,7 @@ export async function checkDuplicateFitIds(
.select({ ofxFitId: transactions.ofxFitId }) .select({ ofxFitId: transactions.ofxFitId })
.from(transactions) .from(transactions)
.where( .where(
and( and(eq(transactions.userId, userId), inArray(transactions.ofxFitId, ids)),
eq(transactions.userId, userId),
inArray(transactions.ofxFitId, ids),
),
); );
return rows.map((r) => r.ofxFitId).filter((id): id is string => id !== null); return rows.map((r) => r.ofxFitId).filter((id): id is string => id !== null);
@@ -67,10 +68,14 @@ export async function importTransactionsAction(
const parsed = importSchema.safeParse(input); const parsed = importSchema.safeParse(input);
if (!parsed.success) { if (!parsed.success) {
return { success: false, error: parsed.error.issues[0]?.message ?? "Dados inválidos." }; return {
success: false,
error: parsed.error.issues[0]?.message ?? "Dados inválidos.",
};
} }
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } = parsed.data; const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } =
parsed.data;
// Valida ownership // Valida ownership
const [payerOk, accountOk, cardOk] = await Promise.all([ const [payerOk, accountOk, cardOk] = await Promise.all([
@@ -94,14 +99,19 @@ export async function importTransactionsAction(
const records = rows.map((row) => { const records = rows.map((row) => {
const purchaseDate = parseLocalDateString(row.date); const purchaseDate = parseLocalDateString(row.date);
const period = invoicePeriod ?? `${purchaseDate.getFullYear()}-${String(purchaseDate.getMonth() + 1).padStart(2, "0")}`; const period =
invoicePeriod ??
`${purchaseDate.getFullYear()}-${String(purchaseDate.getMonth() + 1).padStart(2, "0")}`;
return { return {
name: row.description, name: row.description,
transactionType: row.transactionType === "income" ? "Receita" : "Despesa", transactionType: row.transactionType === "income" ? "Receita" : "Despesa",
condition: "À vista" as const, condition: "À vista" as const,
paymentMethod, paymentMethod,
amount: (row.transactionType === "expense" ? -row.amount : row.amount).toFixed(2), amount: (row.transactionType === "expense"
? -row.amount
: row.amount
).toFixed(2),
purchaseDate, purchaseDate,
period, period,
isSettled, isSettled,
@@ -143,10 +153,7 @@ export async function deleteTransactionByFitId(
await db await db
.delete(transactions) .delete(transactions)
.where( .where(
and( and(eq(transactions.userId, userId), eq(transactions.ofxFitId, fitId)),
eq(transactions.userId, userId),
eq(transactions.ofxFitId, fitId),
),
); );
await revalidateForEntity("transactions", userId); await revalidateForEntity("transactions", userId);

View File

@@ -33,7 +33,8 @@ export function decodeAccountCard(value: string): {
id: string; id: string;
} | null { } | null {
if (value.startsWith("card:")) return { type: "card", id: value.slice(5) }; if (value.startsWith("card:")) return { type: "card", id: value.slice(5) };
if (value.startsWith("account:")) return { type: "account", id: value.slice(8) }; if (value.startsWith("account:"))
return { type: "account", id: value.slice(8) };
return null; return null;
} }
@@ -65,7 +66,9 @@ export function GlobalFields({
onBulkCategoryChange, onBulkCategoryChange,
}: GlobalFieldsProps) { }: GlobalFieldsProps) {
const isCard = accountCardValue?.startsWith("card:") ?? false; const isCard = accountCardValue?.startsWith("card:") ?? false;
const expenseCategories = categoryOptions.filter((o) => o.group === "despesa"); const expenseCategories = categoryOptions.filter(
(o) => o.group === "despesa",
);
const incomeCategories = categoryOptions.filter((o) => o.group === "receita"); const incomeCategories = categoryOptions.filter((o) => o.group === "receita");
return ( return (
@@ -131,7 +134,10 @@ export function GlobalFields({
<SelectContent> <SelectContent>
{payerOptions.map((opt) => ( {payerOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
<PayerSelectContent label={opt.label} avatarUrl={opt.avatarUrl} /> <PayerSelectContent
label={opt.label}
avatarUrl={opt.avatarUrl}
/>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -150,7 +156,10 @@ export function GlobalFields({
<SelectLabel>Despesa</SelectLabel> <SelectLabel>Despesa</SelectLabel>
{expenseCategories.map((opt) => ( {expenseCategories.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
<CategorySelectContent label={opt.label} icon={opt.icon} /> <CategorySelectContent
label={opt.label}
icon={opt.icon}
/>
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
@@ -163,7 +172,10 @@ export function GlobalFields({
<SelectLabel>Receita</SelectLabel> <SelectLabel>Receita</SelectLabel>
{incomeCategories.map((opt) => ( {incomeCategories.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
<CategorySelectContent label={opt.label} icon={opt.icon} /> <CategorySelectContent
label={opt.label}
icon={opt.icon}
/>
</SelectItem> </SelectItem>
))} ))}
</SelectGroup> </SelectGroup>
@@ -172,17 +184,17 @@ export function GlobalFields({
</Select> </Select>
</div> </div>
{isCard && ( {isCard && (
<div className="flex min-w-44 flex-col gap-1.5"> <div className="flex min-w-44 flex-col gap-1.5">
<Label>Fatura</Label> <Label>Fatura</Label>
<PeriodPicker <PeriodPicker
value={invoicePeriod ?? ""} value={invoicePeriod ?? ""}
onChange={(v) => onInvoicePeriodChange(v || null)} onChange={(v) => onInvoicePeriodChange(v || null)}
placeholder="Selecionar fatura…" placeholder="Selecionar fatura…"
/> />
</div> </div>
)} )}
</div>
</div> </div>
</div>
); );
} }

View File

@@ -1,13 +1,18 @@
"use client"; "use client";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useEffect, useMemo, useState, useTransition } from "react"; import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
fetchCategoryMappings, fetchCategoryMappings,
saveCategoryMappings, saveCategoryMappings,
} from "@/features/transactions/actions/category-memory-action"; } from "@/features/transactions/actions/category-memory-action";
import { normalizeDescriptionKey } from "@/features/transactions/lib/import-utils";
import { import {
checkDuplicateFitIds, checkDuplicateFitIds,
deleteTransactionByFitId, deleteTransactionByFitId,
@@ -27,6 +32,7 @@ import {
} from "@/features/transactions/components/import/review-table"; } from "@/features/transactions/components/import/review-table";
import { UploadZone } from "@/features/transactions/components/import/upload-zone"; import { UploadZone } from "@/features/transactions/components/import/upload-zone";
import type { SelectOption } from "@/features/transactions/components/types"; import type { SelectOption } from "@/features/transactions/components/types";
import { normalizeDescriptionKey } from "@/features/transactions/lib/import-utils";
import { Button } from "@/shared/components/ui/button"; import { Button } from "@/shared/components/ui/button";
import { import {
Card, Card,
@@ -82,7 +88,8 @@ export function ImportPage({
...t, ...t,
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false, isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
selected: t.externalId ? !duplicates.has(t.externalId) : true, selected: t.externalId ? !duplicates.has(t.externalId) : true,
categoryId: categoryMappings[normalizeDescriptionKey(t.description)] ?? null, categoryId:
categoryMappings[normalizeDescriptionKey(t.description)] ?? null,
})), })),
); );
} finally { } finally {
@@ -167,7 +174,9 @@ export function ImportPage({
const handleImport = () => { const handleImport = () => {
if (!statement || !canImport) return; if (!statement || !canImport) return;
const decoded = decodeAccountCard(accountCardValue!); const decoded = accountCardValue
? decodeAccountCard(accountCardValue)
: null;
const cardId = decoded?.type === "card" ? decoded.id : null; const cardId = decoded?.type === "card" ? decoded.id : null;
const accountId = decoded?.type === "account" ? decoded.id : null; const accountId = decoded?.type === "account" ? decoded.id : null;
const paymentMethod = const paymentMethod =
@@ -197,7 +206,10 @@ export function ImportPage({
// Salva mapeamentos description → category (fire-and-forget) // Salva mapeamentos description → category (fire-and-forget)
saveCategoryMappings( saveCategoryMappings(
selectedRows.map((r) => ({ description: r.description, categoryId: r.categoryId })), selectedRows.map((r) => ({
description: r.description,
categoryId: r.categoryId,
})),
); );
const { importBatchId } = result; const { importBatchId } = result;
@@ -236,7 +248,8 @@ export function ImportPage({
<div> <div>
<CardTitle>Importar extrato</CardTitle> <CardTitle>Importar extrato</CardTitle>
<CardDescription> <CardDescription>
Importe transações a partir de um arquivo .ofx ou planilha .xlsx exportado pelo seu banco. Importe transações a partir de um arquivo .ofx ou planilha .xlsx
exportado pelo seu banco.
</CardDescription> </CardDescription>
</div> </div>
<ImportSteps current={currentStep} /> <ImportSteps current={currentStep} />

View File

@@ -34,7 +34,9 @@ export function ImportSteps({ current }: ImportStepsProps) {
isCompleted && isCompleted &&
"border-primary bg-primary text-primary-foreground", "border-primary bg-primary text-primary-foreground",
isActive && "border-primary text-primary", isActive && "border-primary text-primary",
!isCompleted && !isActive && "border-muted-foreground/30 text-muted-foreground", !isCompleted &&
!isActive &&
"border-muted-foreground/30 text-muted-foreground",
)} )}
> >
{isCompleted ? ( {isCompleted ? (

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import { useRef } from "react";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";
import { CategorySelectContent } from "@/features/transactions/components/select-items"; import { CategorySelectContent } from "@/features/transactions/components/select-items";
import type { SelectOption } from "@/features/transactions/components/types"; import type { SelectOption } from "@/features/transactions/components/types";
import MoneyValues from "@/shared/components/money-values"; import MoneyValues from "@/shared/components/money-values";
@@ -91,9 +91,7 @@ export function ReviewTable({
onCheckedChange={(v) => onToggleAll(!!v)} onCheckedChange={(v) => onToggleAll(!!v)}
aria-label="Selecionar todas" aria-label="Selecionar todas"
data-state={ data-state={
!allSelected && someSelected !allSelected && someSelected ? "indeterminate" : undefined
? "indeterminate"
: undefined
} }
/> />
</TableHead> </TableHead>
@@ -114,7 +112,10 @@ export function ReviewTable({
</TableRow> </TableRow>
)} )}
{virtualRows.map((virtualRow) => { {virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index]!; const row = rows[virtualRow.index];
if (!row) {
return null;
}
const index = virtualRow.index; const index = virtualRow.index;
return ( return (
<TableRow <TableRow
@@ -199,9 +200,7 @@ export function ReviewTable({
<TableCell> <TableCell>
<TransactionTypeBadge <TransactionTypeBadge
kind={ kind={
row.transactionType === "income" row.transactionType === "income" ? "Receita" : "Despesa"
? "Receita"
: "Despesa"
} }
/> />
</TableCell> </TableCell>

View File

@@ -37,7 +37,9 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
} }
onParsed(statement); onParsed(statement);
} catch { } catch {
setError("Não foi possível ler o arquivo. Verifique se é um OFX válido."); setError(
"Não foi possível ler o arquivo. Verifique se é um OFX válido.",
);
} }
}; };
reader.readAsText(file, "windows-1252"); reader.readAsText(file, "windows-1252");
@@ -119,11 +121,7 @@ export function UploadZone({ onParsed }: UploadZoneProps) {
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{error ? ( {error ? <p className="text-destructive text-sm">{error}</p> : <span />}
<p className="text-destructive text-sm">{error}</p>
) : (
<span />
)}
<button <button
type="button" type="button"
onClick={handleDownloadTemplate} onClick={handleDownloadTemplate}

View File

@@ -13,9 +13,9 @@ import {
RiCheckLine, RiCheckLine,
RiDeleteBin5Line, RiDeleteBin5Line,
RiFileCopyLine, RiFileCopyLine,
RiFileExcel2Line,
RiFileList2Line, RiFileList2Line,
RiFlashlightFill, RiFlashlightFill,
RiFileExcel2Line,
RiGroupLine, RiGroupLine,
RiHistoryLine, RiHistoryLine,
RiMoreFill, RiMoreFill,

View File

@@ -1,4 +1,4 @@
import type { ImportStatement, ImportedTransaction } from "./types"; import type { ImportedTransaction, ImportStatement } from "./types";
// Extrai o valor de uma tag leaf do OFX SGML: <TAG>valor // Extrai o valor de uma tag leaf do OFX SGML: <TAG>valor
function getField(block: string, tag: string): string | null { function getField(block: string, tag: string): string | null {

View File

@@ -1,5 +1,8 @@
import * as XLSX from "xlsx"; import * as XLSX from "xlsx";
import type { ImportStatement, ImportedTransaction } from "@/shared/lib/import/types"; import type {
ImportedTransaction,
ImportStatement,
} from "@/shared/lib/import/types";
function parseDateValue(value: unknown): string | null { function parseDateValue(value: unknown): string | null {
if (value == null || value === "") return null; if (value == null || value === "") return null;