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 { 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;
|
||||||
|
|
||||||
|
const payerIdsByRow = rows.map((row) => row.payerId ?? payerId ?? null);
|
||||||
|
|
||||||
|
if (payerIdsByRow.some((id) => !id)) {
|
||||||
|
return { success: false, error: "Pessoa obrigatória." };
|
||||||
|
}
|
||||||
|
|
||||||
// Valida ownership
|
// Valida ownership
|
||||||
const [payerOk, accountOk, cardOk] = await Promise.all([
|
const [ownedPayerIds, ownedCategoryIds, accountOk, cardOk] =
|
||||||
validatePayerOwnership(userId, payerId),
|
await Promise.all([
|
||||||
|
fetchOwnedPayerIds(userId, payerIdsByRow),
|
||||||
|
fetchOwnedCategoryIds(
|
||||||
|
userId,
|
||||||
|
rows.map((row) => row.categoryId),
|
||||||
|
),
|
||||||
validateContaOwnership(userId, accountId),
|
validateContaOwnership(userId, accountId),
|
||||||
validateCartaoOwnership(userId, cardId),
|
validateCartaoOwnership(userId, cardId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!payerOk) return { success: false, error: "Pessoa não encontrada." };
|
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 (!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,
|
||||||
|
|||||||
@@ -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 ?? ""}
|
||||||
|
|||||||
@@ -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,7 +74,25 @@ 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(
|
||||||
|
() =>
|
||||||
|
new Map(categoryOptions.map((option) => [option.value, option.group])),
|
||||||
|
[categoryOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const isCategoryCompatible = useCallback(
|
||||||
|
(
|
||||||
|
categoryId: string | null,
|
||||||
|
transactionType: ReviewRow["transactionType"],
|
||||||
|
) =>
|
||||||
|
!categoryId ||
|
||||||
|
categoryGroupById.get(categoryId) ===
|
||||||
|
categoryGroupByTransactionType[transactionType],
|
||||||
|
[categoryGroupById],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleParsed = useCallback(
|
||||||
|
async (stmt: ImportStatement) => {
|
||||||
setStatement(stmt);
|
setStatement(stmt);
|
||||||
setIsChecking(true);
|
setIsChecking(true);
|
||||||
|
|
||||||
@@ -84,18 +107,30 @@ export function ImportPage({
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
setRows(
|
setRows(
|
||||||
stmt.transactions.map((t) => ({
|
stmt.transactions.map((t) => {
|
||||||
|
const mappedCategoryId =
|
||||||
|
categoryMappings[normalizeDescriptionKey(t.description)] ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
...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:
|
payerId,
|
||||||
categoryMappings[normalizeDescriptionKey(t.description)] ?? null,
|
categoryId: isCategoryCompatible(
|
||||||
})),
|
mappedCategoryId,
|
||||||
|
t.transactionType,
|
||||||
|
)
|
||||||
|
? mappedCategoryId
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsChecking(false);
|
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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user