mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 19:01:47 +00:00
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:
188
src/features/transactions/components/import/global-fields.tsx
Normal file
188
src/features/transactions/components/import/global-fields.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AccountCardSelectContent,
|
||||
CategorySelectContent,
|
||||
PayerSelectContent,
|
||||
} from "@/features/transactions/components/select-items";
|
||||
import type { SelectOption } from "@/features/transactions/components/types";
|
||||
import { PeriodPicker } from "@/shared/components/period-picker";
|
||||
import { Label } from "@/shared/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/components/ui/select";
|
||||
|
||||
export type AccountCardValue = `card:${string}` | `account:${string}`;
|
||||
|
||||
export function encodeAccountCard(
|
||||
type: "card" | "account",
|
||||
id: string,
|
||||
): AccountCardValue {
|
||||
return `${type}:${id}` as AccountCardValue;
|
||||
}
|
||||
|
||||
export function decodeAccountCard(value: string): {
|
||||
type: "card" | "account";
|
||||
id: string;
|
||||
} | null {
|
||||
if (value.startsWith("card:")) return { type: "card", id: value.slice(5) };
|
||||
if (value.startsWith("account:")) return { type: "account", id: value.slice(8) };
|
||||
return null;
|
||||
}
|
||||
|
||||
interface GlobalFieldsProps {
|
||||
accountOptions: SelectOption[];
|
||||
cardOptions: SelectOption[];
|
||||
payerOptions: SelectOption[];
|
||||
categoryOptions: SelectOption[];
|
||||
accountCardValue: string | null;
|
||||
payerId: string | null;
|
||||
invoicePeriod: string | null;
|
||||
onAccountCardChange: (value: string | null) => void;
|
||||
onPayerChange: (value: string | null) => void;
|
||||
onInvoicePeriodChange: (value: string | null) => void;
|
||||
onBulkCategoryChange: (categoryId: string) => void;
|
||||
}
|
||||
|
||||
export function GlobalFields({
|
||||
accountOptions,
|
||||
cardOptions,
|
||||
payerOptions,
|
||||
categoryOptions,
|
||||
accountCardValue,
|
||||
payerId,
|
||||
invoicePeriod,
|
||||
onAccountCardChange,
|
||||
onPayerChange,
|
||||
onInvoicePeriodChange,
|
||||
onBulkCategoryChange,
|
||||
}: GlobalFieldsProps) {
|
||||
const isCard = accountCardValue?.startsWith("card:") ?? false;
|
||||
const expenseCategories = categoryOptions.filter((o) => o.group === "despesa");
|
||||
const incomeCategories = categoryOptions.filter((o) => o.group === "receita");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Aplicado a todos os lançamentos importados.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div className="flex min-w-44 flex-col gap-1.5">
|
||||
<Label>Conta / Cartão</Label>
|
||||
<Select
|
||||
value={accountCardValue ?? ""}
|
||||
onValueChange={(v) => onAccountCardChange(v || null)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecionar conta ou cartão…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{cardOptions.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Cartões</SelectLabel>
|
||||
{cardOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={`card:${opt.value}`}>
|
||||
<AccountCardSelectContent
|
||||
label={opt.label}
|
||||
logo={opt.logo}
|
||||
isCartao
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{cardOptions.length > 0 && accountOptions.length > 0 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
{accountOptions.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Contas</SelectLabel>
|
||||
{accountOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={`account:${opt.value}`}>
|
||||
<AccountCardSelectContent
|
||||
label={opt.label}
|
||||
logo={opt.logo}
|
||||
isCartao={false}
|
||||
/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-44 flex-col gap-1.5">
|
||||
<Label>Pagador</Label>
|
||||
<Select
|
||||
value={payerId ?? ""}
|
||||
onValueChange={(v) => onPayerChange(v || null)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecionar pagador…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{payerOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<PayerSelectContent label={opt.label} avatarUrl={opt.avatarUrl} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-44 flex-col gap-1.5">
|
||||
<Label>Categoria</Label>
|
||||
<Select onValueChange={onBulkCategoryChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Aplicar a todas selecionadas…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{expenseCategories.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Despesa</SelectLabel>
|
||||
{expenseCategories.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<CategorySelectContent label={opt.label} icon={opt.icon} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{expenseCategories.length > 0 && incomeCategories.length > 0 && (
|
||||
<SelectSeparator />
|
||||
)}
|
||||
{incomeCategories.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Receita</SelectLabel>
|
||||
{incomeCategories.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
<CategorySelectContent label={opt.label} icon={opt.icon} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isCard && (
|
||||
<div className="flex min-w-44 flex-col gap-1.5">
|
||||
<Label>Fatura</Label>
|
||||
<PeriodPicker
|
||||
value={invoicePeriod ?? ""}
|
||||
onChange={(v) => onInvoicePeriodChange(v || null)}
|
||||
placeholder="Selecionar fatura…"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user