mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-09 23:06:01 +00:00
feat(importacao): melhora revisao de extratos
This commit is contained in:
@@ -4,9 +4,10 @@ import { and, eq, inArray } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { transactions } from "@/db/schema";
|
||||
import {
|
||||
fetchOwnedCategoryIds,
|
||||
fetchOwnedPayerIds,
|
||||
validateCartaoOwnership,
|
||||
validateContaOwnership,
|
||||
validatePayerOwnership,
|
||||
} from "@/features/transactions/actions/core";
|
||||
import { revalidateForEntity } from "@/shared/lib/actions/helpers";
|
||||
import { getUserId } from "@/shared/lib/auth/server";
|
||||
@@ -21,6 +22,7 @@ const importRowSchema = z.object({
|
||||
description: z.string().min(1, "Descrição obrigatória."),
|
||||
transactionType: z.enum(["income", "expense"]),
|
||||
categoryId: uuidSchema("Category").nullable().optional(),
|
||||
payerId: uuidSchema("Payer").nullable().optional(),
|
||||
});
|
||||
|
||||
const importSchema = z.object({
|
||||
@@ -76,14 +78,34 @@ export async function importTransactionsAction(
|
||||
const { rows, payerId, accountId, cardId, paymentMethod, invoicePeriod } =
|
||||
parsed.data;
|
||||
|
||||
// Valida ownership
|
||||
const [payerOk, accountOk, cardOk] = await Promise.all([
|
||||
validatePayerOwnership(userId, payerId),
|
||||
validateContaOwnership(userId, accountId),
|
||||
validateCartaoOwnership(userId, cardId),
|
||||
]);
|
||||
const payerIdsByRow = rows.map((row) => row.payerId ?? payerId ?? null);
|
||||
|
||||
if (payerIdsByRow.some((id) => !id)) {
|
||||
return { success: false, error: "Pessoa obrigatória." };
|
||||
}
|
||||
|
||||
// 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 (!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
|
||||
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 period =
|
||||
invoicePeriod ??
|
||||
@@ -115,7 +137,7 @@ export async function importTransactionsAction(
|
||||
period,
|
||||
isSettled,
|
||||
userId,
|
||||
payerId: payerId ?? null,
|
||||
payerId: payerIdsByRow[index],
|
||||
accountId: accountId ?? null,
|
||||
cardId: cardId ?? null,
|
||||
categoryId: row.categoryId ?? null,
|
||||
|
||||
@@ -74,16 +74,16 @@ export function GlobalFields({
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Aplicado a todos os lançamentos importados.
|
||||
Aplicado aos lançamentos selecionados.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex min-w-44 flex-col gap-1.5">
|
||||
<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-0 flex-col gap-1.5">
|
||||
<Label>Conta / Cartão</Label>
|
||||
<Select
|
||||
value={accountCardValue ?? ""}
|
||||
onValueChange={(v) => onAccountCardChange(v || null)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Selecionar conta ou cartão…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -122,14 +122,14 @@ export function GlobalFields({
|
||||
</Select>
|
||||
</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>
|
||||
<Select
|
||||
value={payerId ?? ""}
|
||||
onValueChange={(v) => onPayerChange(v || null)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecionar pessoa…" />
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Aplicar pessoa…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{payerOptions.map((opt) => (
|
||||
@@ -144,10 +144,10 @@ export function GlobalFields({
|
||||
</Select>
|
||||
</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>
|
||||
<Select onValueChange={onBulkCategoryChange}>
|
||||
<SelectTrigger>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Aplicar a todas selecionadas…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -185,7 +185,7 @@ export function GlobalFields({
|
||||
</div>
|
||||
|
||||
{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>
|
||||
<PeriodPicker
|
||||
value={invoicePeriod ?? ""}
|
||||
|
||||
@@ -44,6 +44,11 @@ import {
|
||||
import { Skeleton } from "@/shared/components/ui/skeleton";
|
||||
import type { ImportStatement } from "@/shared/lib/import/types";
|
||||
|
||||
const categoryGroupByTransactionType = {
|
||||
expense: "despesa",
|
||||
income: "receita",
|
||||
} as const;
|
||||
|
||||
interface ImportPageProps {
|
||||
payerOptions: SelectOption[];
|
||||
accountOptions: SelectOption[];
|
||||
@@ -69,33 +74,63 @@ export function ImportPage({
|
||||
const [accountCardValue, setAccountCardValue] = useState<string | null>(null);
|
||||
const [invoicePeriod, setInvoicePeriod] = useState<string | null>(null);
|
||||
|
||||
const handleParsed = useCallback(async (stmt: ImportStatement) => {
|
||||
setStatement(stmt);
|
||||
setIsChecking(true);
|
||||
const categoryGroupById = useMemo(
|
||||
() =>
|
||||
new Map(categoryOptions.map((option) => [option.value, option.group])),
|
||||
[categoryOptions],
|
||||
);
|
||||
|
||||
try {
|
||||
const fitIds = stmt.transactions
|
||||
.map((t) => t.externalId)
|
||||
.filter((id): id is string => id !== null);
|
||||
const isCategoryCompatible = useCallback(
|
||||
(
|
||||
categoryId: string | null,
|
||||
transactionType: ReviewRow["transactionType"],
|
||||
) =>
|
||||
!categoryId ||
|
||||
categoryGroupById.get(categoryId) ===
|
||||
categoryGroupByTransactionType[transactionType],
|
||||
[categoryGroupById],
|
||||
);
|
||||
|
||||
const [duplicates, categoryMappings] = await Promise.all([
|
||||
checkDuplicateFitIds(fitIds).then((ids) => new Set(ids)),
|
||||
fetchCategoryMappings(stmt.transactions.map((t) => t.description)),
|
||||
]);
|
||||
const handleParsed = useCallback(
|
||||
async (stmt: ImportStatement) => {
|
||||
setStatement(stmt);
|
||||
setIsChecking(true);
|
||||
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
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) => {
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -121,7 +156,17 @@ export function ImportPage({
|
||||
|
||||
const handleCategoryChange = (index: number, categoryId: string | null) => {
|
||||
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) => {
|
||||
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 { selectedRows, duplicateCount, uncategorizedCount } = useMemo(() => {
|
||||
const {
|
||||
selectedRows,
|
||||
duplicateCount,
|
||||
uncategorizedCount,
|
||||
withoutPayerCount,
|
||||
} = 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,
|
||||
withoutPayerCount: selected.filter((r) => !r.payerId).length,
|
||||
};
|
||||
}, [rows]);
|
||||
|
||||
@@ -168,6 +232,7 @@ export function ImportPage({
|
||||
selectedRows.length > 0 &&
|
||||
!!accountCardValue &&
|
||||
uncategorizedCount === 0 &&
|
||||
withoutPayerCount === 0 &&
|
||||
(!isCard || !!invoicePeriod) &&
|
||||
!isPending;
|
||||
|
||||
@@ -191,6 +256,7 @@ export function ImportPage({
|
||||
description: r.description,
|
||||
transactionType: r.transactionType,
|
||||
categoryId: r.categoryId,
|
||||
payerId: r.payerId,
|
||||
})),
|
||||
payerId,
|
||||
accountId,
|
||||
@@ -280,6 +346,7 @@ export function ImportPage({
|
||||
selected={selectedRows.length}
|
||||
duplicates={duplicateCount}
|
||||
uncategorized={uncategorizedCount}
|
||||
withoutPayer={withoutPayerCount}
|
||||
/>
|
||||
|
||||
<GlobalFields
|
||||
@@ -291,23 +358,25 @@ export function ImportPage({
|
||||
payerId={payerId}
|
||||
invoicePeriod={invoicePeriod}
|
||||
onAccountCardChange={setAccountCardValue}
|
||||
onPayerChange={setPayerId}
|
||||
onPayerChange={handleBulkPayerChange}
|
||||
onInvoicePeriodChange={setInvoicePeriod}
|
||||
onBulkCategoryChange={handleBulkCategoryChange}
|
||||
/>
|
||||
|
||||
<ReviewTable
|
||||
rows={rows}
|
||||
payerOptions={payerOptions}
|
||||
categoryOptions={categoryOptions}
|
||||
onToggle={toggleRow}
|
||||
onToggleAll={toggleAll}
|
||||
onPayerChange={handlePayerChange}
|
||||
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="sticky bottom-0 -mx-6 px-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -10,6 +10,7 @@ interface ImportSummaryProps {
|
||||
selected: number;
|
||||
duplicates: number;
|
||||
uncategorized: number;
|
||||
withoutPayer: number;
|
||||
}
|
||||
|
||||
export function ImportSummary({
|
||||
@@ -18,9 +19,10 @@ export function ImportSummary({
|
||||
selected,
|
||||
duplicates,
|
||||
uncategorized,
|
||||
withoutPayer,
|
||||
}: ImportSummaryProps) {
|
||||
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 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{statement.source}</span>
|
||||
@@ -40,8 +42,7 @@ export function ImportSummary({
|
||||
)}
|
||||
|
||||
<span>
|
||||
<span className="font-medium text-foreground">{selected}</span>/
|
||||
{total} selecionadas
|
||||
{selected}/{total} selecionadas
|
||||
</span>
|
||||
|
||||
{duplicates > 0 && (
|
||||
@@ -59,6 +60,16 @@ export function ImportSummary({
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
|
||||
{withoutPayer > 0 ? (
|
||||
<span>{withoutPayer} sem pessoa</span>
|
||||
) : (
|
||||
selected > 0 && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400">
|
||||
todas com pessoa ✓
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
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 MoneyValues from "@/shared/components/money-values";
|
||||
import { TransactionTypeBadge } from "@/shared/components/transaction-type-badge";
|
||||
@@ -31,17 +34,28 @@ import {
|
||||
import type { ImportedTransaction } from "@/shared/lib/import/types";
|
||||
import { formatDate } from "@/shared/utils/date";
|
||||
|
||||
const categoryGroupByTransactionType: Record<
|
||||
ImportedTransaction["transactionType"],
|
||||
string
|
||||
> = {
|
||||
expense: "despesa",
|
||||
income: "receita",
|
||||
};
|
||||
|
||||
export type ReviewRow = ImportedTransaction & {
|
||||
selected: boolean;
|
||||
isDuplicate: boolean;
|
||||
categoryId: string | null;
|
||||
payerId: string | null;
|
||||
};
|
||||
|
||||
interface ReviewTableProps {
|
||||
rows: ReviewRow[];
|
||||
payerOptions: SelectOption[];
|
||||
categoryOptions: SelectOption[];
|
||||
onToggle: (index: number) => void;
|
||||
onToggleAll: (selected: boolean) => void;
|
||||
onPayerChange: (index: number, payerId: string | null) => void;
|
||||
onCategoryChange: (index: number, categoryId: string | null) => void;
|
||||
onDescriptionChange: (index: number, description: string) => void;
|
||||
onUndoDuplicate: (index: number) => void;
|
||||
@@ -49,9 +63,11 @@ interface ReviewTableProps {
|
||||
|
||||
export function ReviewTable({
|
||||
rows,
|
||||
payerOptions,
|
||||
categoryOptions,
|
||||
onToggle,
|
||||
onToggleAll,
|
||||
onPayerChange,
|
||||
onCategoryChange,
|
||||
onDescriptionChange,
|
||||
onUndoDuplicate,
|
||||
@@ -97,6 +113,7 @@ export function ReviewTable({
|
||||
</TableHead>
|
||||
<TableHead className="w-24">Data</TableHead>
|
||||
<TableHead>Descrição</TableHead>
|
||||
<TableHead className="w-44">Pessoa</TableHead>
|
||||
<TableHead className="w-44">Categoria</TableHead>
|
||||
<TableHead className="w-20">Tipo</TableHead>
|
||||
<TableHead className="w-28 text-right">Valor</TableHead>
|
||||
@@ -106,7 +123,7 @@ export function ReviewTable({
|
||||
{paddingTop > 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
colSpan={7}
|
||||
style={{ height: paddingTop, padding: 0 }}
|
||||
/>
|
||||
</TableRow>
|
||||
@@ -117,6 +134,11 @@ export function ReviewTable({
|
||||
return null;
|
||||
}
|
||||
const index = virtualRow.index;
|
||||
const categoryOptionsForRow = categoryOptions.filter(
|
||||
(option) =>
|
||||
option.group ===
|
||||
categoryGroupByTransactionType[row.transactionType],
|
||||
);
|
||||
return (
|
||||
<TableRow
|
||||
key={row.externalId ?? `${row.date}-${index}`}
|
||||
@@ -177,6 +199,26 @@ export function ReviewTable({
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
<Select
|
||||
value={row.categoryId ?? ""}
|
||||
@@ -186,7 +228,7 @@ export function ReviewTable({
|
||||
<SelectValue placeholder="Categoria…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryOptions.map((opt) => (
|
||||
{categoryOptionsForRow.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<CategorySelectContent
|
||||
label={opt.label}
|
||||
@@ -225,7 +267,7 @@ export function ReviewTable({
|
||||
{paddingBottom > 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
colSpan={7}
|
||||
style={{ height: paddingBottom, padding: 0 }}
|
||||
/>
|
||||
</TableRow>
|
||||
|
||||
@@ -14,12 +14,12 @@ function excelSerialToDate(
|
||||
if (serial < 1) return null;
|
||||
let adjusted = serial;
|
||||
if (serial > 60) adjusted--;
|
||||
const baseDate = new Date(1899, 11, 31);
|
||||
const date = new Date(baseDate.getTime() + adjusted * 86400000);
|
||||
const baseDate = Date.UTC(1899, 11, 31);
|
||||
const date = new Date(baseDate + adjusted * 86400000);
|
||||
return {
|
||||
y: date.getFullYear(),
|
||||
m: date.getMonth() + 1,
|
||||
d: date.getDate(),
|
||||
y: date.getUTCFullYear(),
|
||||
m: date.getUTCMonth() + 1,
|
||||
d: date.getUTCDate(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,9 +38,9 @@ function parseDateValue(value: unknown): string | null {
|
||||
|
||||
// ExcelJS pode retornar Date objects
|
||||
if (value instanceof Date) {
|
||||
const y = value.getFullYear();
|
||||
const m = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const d = String(value.getDate()).padStart(2, "0");
|
||||
const y = value.getUTCFullYear();
|
||||
const m = String(value.getUTCMonth() + 1).padStart(2, "0");
|
||||
const d = String(value.getUTCDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user