feat(importacao): melhora revisao de extratos

This commit is contained in:
Felipe Coutinho
2026-05-21 13:46:42 +00:00
parent 21d7396c80
commit b6659ef66e
6 changed files with 208 additions and 64 deletions

View File

@@ -4,9 +4,10 @@ import { and, eq, inArray } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { transactions } from "@/db/schema"; import { transactions } from "@/db/schema";
import { import {
fetchOwnedCategoryIds,
fetchOwnedPayerIds,
validateCartaoOwnership, validateCartaoOwnership,
validateContaOwnership, validateContaOwnership,
validatePayerOwnership,
} from "@/features/transactions/actions/core"; } from "@/features/transactions/actions/core";
import { revalidateForEntity } from "@/shared/lib/actions/helpers"; import { revalidateForEntity } from "@/shared/lib/actions/helpers";
import { getUserId } from "@/shared/lib/auth/server"; import { getUserId } from "@/shared/lib/auth/server";
@@ -21,6 +22,7 @@ const importRowSchema = z.object({
description: z.string().min(1, "Descrição obrigatória."), description: z.string().min(1, "Descrição obrigatória."),
transactionType: z.enum(["income", "expense"]), transactionType: z.enum(["income", "expense"]),
categoryId: uuidSchema("Category").nullable().optional(), categoryId: uuidSchema("Category").nullable().optional(),
payerId: uuidSchema("Payer").nullable().optional(),
}); });
const importSchema = z.object({ const importSchema = z.object({
@@ -76,14 +78,34 @@ export async function importTransactionsAction(
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } = const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } =
parsed.data; parsed.data;
// Valida ownership const payerIdsByRow = rows.map((row) => row.payerId ?? payerId ?? null);
const [payerOk, accountOk, cardOk] = await Promise.all([
validatePayerOwnership(userId, payerId), if (payerIdsByRow.some((id) => !id)) {
validateContaOwnership(userId, accountId), return { success: false, error: "Pessoa obrigatória." };
validateCartaoOwnership(userId, cardId), }
]);
// Valida ownership
const [ownedPayerIds, ownedCategoryIds, accountOk, cardOk] =
await Promise.all([
fetchOwnedPayerIds(userId, payerIdsByRow),
fetchOwnedCategoryIds(
userId,
rows.map((row) => row.categoryId),
),
validateContaOwnership(userId, accountId),
validateCartaoOwnership(userId, cardId),
]);
if (payerIdsByRow.some((id) => id && !ownedPayerIds.has(id))) {
return { success: false, error: "Pessoa não encontrada." };
}
if (
rows.some((row) => row.categoryId && !ownedCategoryIds.has(row.categoryId))
) {
return { success: false, error: "Categoria não encontrada." };
}
if (!payerOk) return { success: false, error: "Pessoa não encontrada." };
if (!accountOk) return { success: false, error: "Conta não encontrada." }; if (!accountOk) return { success: false, error: "Conta não encontrada." };
if (!cardOk) return { success: false, error: "Cartão não encontrado." }; if (!cardOk) return { success: false, error: "Cartão não encontrado." };
@@ -96,7 +118,7 @@ export async function importTransactionsAction(
// Cartão de crédito: fatura pode ainda não ter sido paga // Cartão de crédito: fatura pode ainda não ter sido paga
const isSettled = paymentMethod !== "Cartão de crédito"; const isSettled = paymentMethod !== "Cartão de crédito";
const records = rows.map((row) => { const records = rows.map((row, index) => {
const purchaseDate = parseLocalDateString(row.date); const purchaseDate = parseLocalDateString(row.date);
const period = const period =
invoicePeriod ?? invoicePeriod ??
@@ -115,7 +137,7 @@ export async function importTransactionsAction(
period, period,
isSettled, isSettled,
userId, userId,
payerId: payerId ?? null, payerId: payerIdsByRow[index],
accountId: accountId ?? null, accountId: accountId ?? null,
cardId: cardId ?? null, cardId: cardId ?? null,
categoryId: row.categoryId ?? null, categoryId: row.categoryId ?? null,

View File

@@ -74,16 +74,16 @@ export function GlobalFields({
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-sm">
Aplicado a todos os lançamentos importados. Aplicado aos lançamentos selecionados.
</p> </p>
<div className="flex flex-wrap gap-4"> <div className="grid w-full grid-cols-1 items-end justify-start gap-3 sm:grid-cols-[repeat(2,minmax(0,14rem))] lg:grid-cols-[16rem_14rem_18rem_14rem]">
<div className="flex min-w-44 flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label>Conta / Cartão</Label> <Label>Conta / Cartão</Label>
<Select <Select
value={accountCardValue ?? ""} value={accountCardValue ?? ""}
onValueChange={(v) => onAccountCardChange(v || null)} onValueChange={(v) => onAccountCardChange(v || null)}
> >
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue placeholder="Selecionar conta ou cartão…" /> <SelectValue placeholder="Selecionar conta ou cartão…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -122,14 +122,14 @@ export function GlobalFields({
</Select> </Select>
</div> </div>
<div className="flex min-w-44 flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label>Pessoa</Label> <Label>Pessoa</Label>
<Select <Select
value={payerId ?? ""} value={payerId ?? ""}
onValueChange={(v) => onPayerChange(v || null)} onValueChange={(v) => onPayerChange(v || null)}
> >
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue placeholder="Selecionar pessoa…" /> <SelectValue placeholder="Aplicar pessoa…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{payerOptions.map((opt) => ( {payerOptions.map((opt) => (
@@ -144,10 +144,10 @@ export function GlobalFields({
</Select> </Select>
</div> </div>
<div className="flex min-w-44 flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label>Categoria</Label> <Label>Categoria</Label>
<Select onValueChange={onBulkCategoryChange}> <Select onValueChange={onBulkCategoryChange}>
<SelectTrigger> <SelectTrigger className="w-full">
<SelectValue placeholder="Aplicar a todas selecionadas…" /> <SelectValue placeholder="Aplicar a todas selecionadas…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@@ -185,7 +185,7 @@ export function GlobalFields({
</div> </div>
{isCard && ( {isCard && (
<div className="flex min-w-44 flex-col gap-1.5"> <div className="flex min-w-0 flex-col gap-1.5">
<Label>Fatura</Label> <Label>Fatura</Label>
<PeriodPicker <PeriodPicker
value={invoicePeriod ?? ""} value={invoicePeriod ?? ""}

View File

@@ -44,6 +44,11 @@ import {
import { Skeleton } from "@/shared/components/ui/skeleton"; import { Skeleton } from "@/shared/components/ui/skeleton";
import type { ImportStatement } from "@/shared/lib/import/types"; import type { ImportStatement } from "@/shared/lib/import/types";
const categoryGroupByTransactionType = {
expense: "despesa",
income: "receita",
} as const;
interface ImportPageProps { interface ImportPageProps {
payerOptions: SelectOption[]; payerOptions: SelectOption[];
accountOptions: SelectOption[]; accountOptions: SelectOption[];
@@ -69,33 +74,63 @@ export function ImportPage({
const [accountCardValue, setAccountCardValue] = useState<string | null>(null); const [accountCardValue, setAccountCardValue] = useState<string | null>(null);
const [invoicePeriod, setInvoicePeriod] = useState<string | null>(null); const [invoicePeriod, setInvoicePeriod] = useState<string | null>(null);
const handleParsed = useCallback(async (stmt: ImportStatement) => { const categoryGroupById = useMemo(
setStatement(stmt); () =>
setIsChecking(true); new Map(categoryOptions.map((option) => [option.value, option.group])),
[categoryOptions],
);
try { const isCategoryCompatible = useCallback(
const fitIds = stmt.transactions (
.map((t) => t.externalId) categoryId: string | null,
.filter((id): id is string => id !== null); transactionType: ReviewRow["transactionType"],
) =>
!categoryId ||
categoryGroupById.get(categoryId) ===
categoryGroupByTransactionType[transactionType],
[categoryGroupById],
);
const [duplicates, categoryMappings] = await Promise.all([ const handleParsed = useCallback(
checkDuplicateFitIds(fitIds).then((ids) => new Set(ids)), async (stmt: ImportStatement) => {
fetchCategoryMappings(stmt.transactions.map((t) => t.description)), setStatement(stmt);
]); setIsChecking(true);
setRows( try {
stmt.transactions.map((t) => ({ const fitIds = stmt.transactions
...t, .map((t) => t.externalId)
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false, .filter((id): id is string => id !== null);
selected: t.externalId ? !duplicates.has(t.externalId) : true,
categoryId: const [duplicates, categoryMappings] = await Promise.all([
categoryMappings[normalizeDescriptionKey(t.description)] ?? null, checkDuplicateFitIds(fitIds).then((ids) => new Set(ids)),
})), fetchCategoryMappings(stmt.transactions.map((t) => t.description)),
); ]);
} finally {
setIsChecking(false); setRows(
} stmt.transactions.map((t) => {
}, []); const mappedCategoryId =
categoryMappings[normalizeDescriptionKey(t.description)] ?? null;
return {
...t,
isDuplicate: t.externalId ? duplicates.has(t.externalId) : false,
selected: t.externalId ? !duplicates.has(t.externalId) : true,
payerId,
categoryId: isCategoryCompatible(
mappedCategoryId,
t.transactionType,
)
? mappedCategoryId
: null,
};
}),
);
} finally {
setIsChecking(false);
}
},
[isCategoryCompatible, payerId],
);
// Pré-seleciona cartão ou conta com base no tipo detectado no OFX // Pré-seleciona cartão ou conta com base no tipo detectado no OFX
useEffect(() => { useEffect(() => {
@@ -121,7 +156,17 @@ export function ImportPage({
const handleCategoryChange = (index: number, categoryId: string | null) => { const handleCategoryChange = (index: number, categoryId: string | null) => {
setRows((prev) => setRows((prev) =>
prev.map((r, i) => (i === index ? { ...r, categoryId } : r)), prev.map((r, i) =>
i === index && isCategoryCompatible(categoryId, r.transactionType)
? { ...r, categoryId }
: r,
),
);
};
const handlePayerChange = (index: number, payerId: string | null) => {
setRows((prev) =>
prev.map((r, i) => (i === index ? { ...r, payerId } : r)),
); );
}; };
@@ -150,17 +195,36 @@ export function ImportPage({
}; };
const handleBulkCategoryChange = (categoryId: string) => { const handleBulkCategoryChange = (categoryId: string) => {
setRows((prev) => prev.map((r) => (r.selected ? { ...r, categoryId } : r))); setRows((prev) =>
prev.map((r) =>
r.selected && isCategoryCompatible(categoryId, r.transactionType)
? { ...r, categoryId }
: r,
),
);
};
const handleBulkPayerChange = (nextPayerId: string | null) => {
setPayerId(nextPayerId);
setRows((prev) =>
prev.map((r) => (r.selected ? { ...r, payerId: nextPayerId } : r)),
);
}; };
const isCard = accountCardValue?.startsWith("card:") ?? false; const isCard = accountCardValue?.startsWith("card:") ?? false;
const { selectedRows, duplicateCount, uncategorizedCount } = useMemo(() => { const {
selectedRows,
duplicateCount,
uncategorizedCount,
withoutPayerCount,
} = useMemo(() => {
const selected = rows.filter((r) => r.selected); const selected = rows.filter((r) => r.selected);
return { return {
selectedRows: selected, selectedRows: selected,
duplicateCount: rows.filter((r) => r.isDuplicate).length, duplicateCount: rows.filter((r) => r.isDuplicate).length,
uncategorizedCount: selected.filter((r) => !r.categoryId).length, uncategorizedCount: selected.filter((r) => !r.categoryId).length,
withoutPayerCount: selected.filter((r) => !r.payerId).length,
}; };
}, [rows]); }, [rows]);
@@ -168,6 +232,7 @@ export function ImportPage({
selectedRows.length > 0 && selectedRows.length > 0 &&
!!accountCardValue && !!accountCardValue &&
uncategorizedCount === 0 && uncategorizedCount === 0 &&
withoutPayerCount === 0 &&
(!isCard || !!invoicePeriod) && (!isCard || !!invoicePeriod) &&
!isPending; !isPending;
@@ -191,6 +256,7 @@ export function ImportPage({
description: r.description, description: r.description,
transactionType: r.transactionType, transactionType: r.transactionType,
categoryId: r.categoryId, categoryId: r.categoryId,
payerId: r.payerId,
})), })),
payerId, payerId,
accountId, accountId,
@@ -280,6 +346,7 @@ export function ImportPage({
selected={selectedRows.length} selected={selectedRows.length}
duplicates={duplicateCount} duplicates={duplicateCount}
uncategorized={uncategorizedCount} uncategorized={uncategorizedCount}
withoutPayer={withoutPayerCount}
/> />
<GlobalFields <GlobalFields
@@ -291,23 +358,25 @@ export function ImportPage({
payerId={payerId} payerId={payerId}
invoicePeriod={invoicePeriod} invoicePeriod={invoicePeriod}
onAccountCardChange={setAccountCardValue} onAccountCardChange={setAccountCardValue}
onPayerChange={setPayerId} onPayerChange={handleBulkPayerChange}
onInvoicePeriodChange={setInvoicePeriod} onInvoicePeriodChange={setInvoicePeriod}
onBulkCategoryChange={handleBulkCategoryChange} onBulkCategoryChange={handleBulkCategoryChange}
/> />
<ReviewTable <ReviewTable
rows={rows} rows={rows}
payerOptions={payerOptions}
categoryOptions={categoryOptions} categoryOptions={categoryOptions}
onToggle={toggleRow} onToggle={toggleRow}
onToggleAll={toggleAll} onToggleAll={toggleAll}
onPayerChange={handlePayerChange}
onCategoryChange={handleCategoryChange} onCategoryChange={handleCategoryChange}
onDescriptionChange={handleDescriptionChange} onDescriptionChange={handleDescriptionChange}
onUndoDuplicate={handleUndoDuplicate} onUndoDuplicate={handleUndoDuplicate}
/> />
{/* Sticky footer */} {/* Sticky footer */}
<div className="sticky bottom-0 -mx-6 border-t bg-background px-6 py-4"> <div className="sticky bottom-0 -mx-6 px-6">
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-4">
<Button <Button
variant="outline" variant="outline"

View File

@@ -10,6 +10,7 @@ interface ImportSummaryProps {
selected: number; selected: number;
duplicates: number; duplicates: number;
uncategorized: number; uncategorized: number;
withoutPayer: number;
} }
export function ImportSummary({ export function ImportSummary({
@@ -18,9 +19,10 @@ export function ImportSummary({
selected, selected,
duplicates, duplicates,
uncategorized, uncategorized,
withoutPayer,
}: ImportSummaryProps) { }: ImportSummaryProps) {
return ( return (
<Card className="flex flex-col gap-1 p-5 text-sm bg-linear-to-br from-primary/5 to-transparent"> <Card className="flex flex-col gap-1 p-5 text-sm bg-primary/10 shadow-none ">
{/* Linha 1: título */} {/* Linha 1: título */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-medium">{statement.source}</span> <span className="font-medium">{statement.source}</span>
@@ -40,8 +42,7 @@ export function ImportSummary({
)} )}
<span> <span>
<span className="font-medium text-foreground">{selected}</span>/ {selected}/{total} selecionadas
{total} selecionadas
</span> </span>
{duplicates > 0 && ( {duplicates > 0 && (
@@ -59,6 +60,16 @@ export function ImportSummary({
</span> </span>
) )
)} )}
{withoutPayer > 0 ? (
<span>{withoutPayer} sem pessoa</span>
) : (
selected > 0 && (
<span className="text-emerald-600 dark:text-emerald-400">
todas com pessoa
</span>
)
)}
</div> </div>
</Card> </Card>
); );

View File

@@ -2,7 +2,10 @@
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react"; import { useRef } from "react";
import { CategorySelectContent } from "@/features/transactions/components/select-items"; import {
CategorySelectContent,
PayerSelectContent,
} 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";
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge"; import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
@@ -31,17 +34,28 @@ import {
import type { ImportedTransaction } from "@/shared/lib/import/types"; import type { ImportedTransaction } from "@/shared/lib/import/types";
import { formatDate } from "@/shared/utils/date"; import { formatDate } from "@/shared/utils/date";
const categoryGroupByTransactionType: Record<
ImportedTransaction["transactionType"],
string
> = {
expense: "despesa",
income: "receita",
};
export type ReviewRow = ImportedTransaction & { export type ReviewRow = ImportedTransaction & {
selected: boolean; selected: boolean;
isDuplicate: boolean; isDuplicate: boolean;
categoryId: string | null; categoryId: string | null;
payerId: string | null;
}; };
interface ReviewTableProps { interface ReviewTableProps {
rows: ReviewRow[]; rows: ReviewRow[];
payerOptions: SelectOption[];
categoryOptions: SelectOption[]; categoryOptions: SelectOption[];
onToggle: (index: number) => void; onToggle: (index: number) => void;
onToggleAll: (selected: boolean) => void; onToggleAll: (selected: boolean) => void;
onPayerChange: (index: number, payerId: string | null) => void;
onCategoryChange: (index: number, categoryId: string | null) => void; onCategoryChange: (index: number, categoryId: string | null) => void;
onDescriptionChange: (index: number, description: string) => void; onDescriptionChange: (index: number, description: string) => void;
onUndoDuplicate: (index: number) => void; onUndoDuplicate: (index: number) => void;
@@ -49,9 +63,11 @@ interface ReviewTableProps {
export function ReviewTable({ export function ReviewTable({
rows, rows,
payerOptions,
categoryOptions, categoryOptions,
onToggle, onToggle,
onToggleAll, onToggleAll,
onPayerChange,
onCategoryChange, onCategoryChange,
onDescriptionChange, onDescriptionChange,
onUndoDuplicate, onUndoDuplicate,
@@ -97,6 +113,7 @@ export function ReviewTable({
</TableHead> </TableHead>
<TableHead className="w-24">Data</TableHead> <TableHead className="w-24">Data</TableHead>
<TableHead>Descrição</TableHead> <TableHead>Descrição</TableHead>
<TableHead className="w-44">Pessoa</TableHead>
<TableHead className="w-44">Categoria</TableHead> <TableHead className="w-44">Categoria</TableHead>
<TableHead className="w-20">Tipo</TableHead> <TableHead className="w-20">Tipo</TableHead>
<TableHead className="w-28 text-right">Valor</TableHead> <TableHead className="w-28 text-right">Valor</TableHead>
@@ -106,7 +123,7 @@ export function ReviewTable({
{paddingTop > 0 && ( {paddingTop > 0 && (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={7}
style={{ height: paddingTop, padding: 0 }} style={{ height: paddingTop, padding: 0 }}
/> />
</TableRow> </TableRow>
@@ -117,6 +134,11 @@ export function ReviewTable({
return null; return null;
} }
const index = virtualRow.index; const index = virtualRow.index;
const categoryOptionsForRow = categoryOptions.filter(
(option) =>
option.group ===
categoryGroupByTransactionType[row.transactionType],
);
return ( return (
<TableRow <TableRow
key={row.externalId ?? `${row.date}-${index}`} key={row.externalId ?? `${row.date}-${index}`}
@@ -177,6 +199,26 @@ export function ReviewTable({
</div> </div>
)} )}
</TableCell> </TableCell>
<TableCell>
<Select
value={row.payerId ?? ""}
onValueChange={(v) => onPayerChange(index, v || null)}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Pessoa…" />
</SelectTrigger>
<SelectContent>
{payerOptions.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<PayerSelectContent
label={opt.label}
avatarUrl={opt.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell> <TableCell>
<Select <Select
value={row.categoryId ?? ""} value={row.categoryId ?? ""}
@@ -186,7 +228,7 @@ export function ReviewTable({
<SelectValue placeholder="Categoria…" /> <SelectValue placeholder="Categoria…" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{categoryOptions.map((opt) => ( {categoryOptionsForRow.map((opt) => (
<SelectItem key={opt.value} value={opt.value}> <SelectItem key={opt.value} value={opt.value}>
<CategorySelectContent <CategorySelectContent
label={opt.label} label={opt.label}
@@ -225,7 +267,7 @@ export function ReviewTable({
{paddingBottom > 0 && ( {paddingBottom > 0 && (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={7}
style={{ height: paddingBottom, padding: 0 }} style={{ height: paddingBottom, padding: 0 }}
/> />
</TableRow> </TableRow>

View File

@@ -14,12 +14,12 @@ function excelSerialToDate(
if (serial < 1) return null; if (serial < 1) return null;
let adjusted = serial; let adjusted = serial;
if (serial > 60) adjusted--; if (serial > 60) adjusted--;
const baseDate = new Date(1899, 11, 31); const baseDate = Date.UTC(1899, 11, 31);
const date = new Date(baseDate.getTime() + adjusted * 86400000); const date = new Date(baseDate + adjusted * 86400000);
return { return {
y: date.getFullYear(), y: date.getUTCFullYear(),
m: date.getMonth() + 1, m: date.getUTCMonth() + 1,
d: date.getDate(), d: date.getUTCDate(),
}; };
} }
@@ -38,9 +38,9 @@ function parseDateValue(value: unknown): string | null {
// ExcelJS pode retornar Date objects // ExcelJS pode retornar Date objects
if (value instanceof Date) { if (value instanceof Date) {
const y = value.getFullYear(); const y = value.getUTCFullYear();
const m = String(value.getMonth() + 1).padStart(2, "0"); const m = String(value.getUTCMonth() + 1).padStart(2, "0");
const d = String(value.getDate()).padStart(2, "0"); const d = String(value.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${d}`; return `${y}-${m}-${d}`;
} }