feat(lancamentos): amplia divisao e resumo do modal

This commit is contained in:
Felipe Coutinho
2026-05-28 10:59:13 -03:00
parent ef2c8c50e8
commit 311369f81b
10 changed files with 981 additions and 290 deletions

View File

@@ -39,8 +39,8 @@ export function SplitPairDialog({
<DialogHeader>
<DialogTitle>Editar lançamento dividido</DialogTitle>
<DialogDescription>
Este lançamento está dividido com outra pessoa. Escolha o que deseja
editar:
Este lançamento está dividido com outras pessoas. Escolha o que
deseja editar:
</DialogDescription>
</DialogHeader>
@@ -63,7 +63,7 @@ export function SplitPairDialog({
Apenas este lançamento
</Label>
<p className="text-xs text-muted-foreground">
Aplica a alteração somente neste lado da divisão
Aplica a alteração somente nesta parte da divisão
</p>
</div>
</div>
@@ -75,11 +75,11 @@ export function SplitPairDialog({
htmlFor="split-both"
className="text-sm cursor-pointer font-medium"
>
Atualizar os dois lançamentos
Atualizar toda a divisão
</Label>
<p className="text-xs text-muted-foreground">
Aplica nome, data, categoria e outros campos compartilhados
nos dois lados da divisão
Aplica nome, data, categoria e outros campos compartilhados em
todo o grupo da divisão
</p>
</div>
</div>

View File

@@ -3,8 +3,12 @@
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { RiSliceFill } from "@remixicon/react";
import { useState } from "react";
import { CurrencyInput } from "@/shared/components/ui/currency-input";
import { Input } from "@/shared/components/ui/input";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/shared/components/ui/avatar";
import { Button } from "@/shared/components/ui/button";
import { Label } from "@/shared/components/ui/label";
import {
Select,
@@ -13,120 +17,48 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/components/ui/select";
import {
ToggleGroup,
ToggleGroupItem,
} from "@/shared/components/ui/toggle-group";
import {
formatCurrency,
formatDecimalForDbRequired,
normalizeDecimalInput,
} from "@/shared/utils/currency";
import { safeToNumber } from "@/shared/utils/number";
import { getAvatarSrc } from "@/shared/lib/payers/utils";
import { cn } from "@/shared/utils/ui";
import { PayerSelectContent } from "../../select-items";
import { getSplitSummaryData, SplitConfigDialog } from "./split-config-dialog";
import type { PayerSectionProps } from "./transaction-dialog-types";
type SplitInputMode = "currency" | "percentage";
type SplitSummary = ReturnType<typeof getSplitSummaryData>;
const SPLIT_MODE_OPTIONS = [
{ value: "currency", label: "R$" },
{ value: "percentage", label: "%" },
] as const satisfies ReadonlyArray<{ value: SplitInputMode; label: string }>;
const amountToPercent = (amount: string, total: number): string => {
if (total <= 0) return "";
const numeric = safeToNumber(normalizeDecimalInput(amount), Number.NaN);
if (!Number.isFinite(numeric)) return "";
const pct = (numeric / total) * 100;
return (Math.round(pct * 10) / 10).toString();
};
const percentToAmount = (percent: string, total: number): string => {
const pct = safeToNumber(normalizeDecimalInput(percent), Number.NaN);
if (!Number.isFinite(pct) || total <= 0) return "0.00";
const clamped = Math.min(100, Math.max(0, pct));
return formatDecimalForDbRequired((total * clamped) / 100);
};
function SplitModeToggle({
mode,
onModeChange,
}: {
mode: SplitInputMode;
onModeChange: (mode: SplitInputMode) => void;
}) {
return (
<ToggleGroup
type="single"
size="sm"
variant="outline"
value={mode}
onValueChange={(value) => {
if (value) onModeChange(value as SplitInputMode);
}}
aria-label="Modo de entrada do split"
className="h-7 text-xs"
>
{SPLIT_MODE_OPTIONS.map((option) => (
<ToggleGroupItem
key={option.value}
value={option.value}
className="px-2 py-0 h-7 text-xs"
>
{option.label}
</ToggleGroupItem>
))}
</ToggleGroup>
);
}
function SplitAmountField({
mode,
value,
totalAmount,
onAmountChange,
ariaLabel,
}: {
mode: SplitInputMode;
value: string;
totalAmount: number;
onAmountChange: (amount: string) => void;
ariaLabel: string;
}) {
if (mode === "currency") {
return (
<CurrencyInput
value={value}
onValueChange={onAmountChange}
placeholder="R$ 0,00"
className="h-9 w-[45%] text-sm"
/>
);
function SplitSummaryContent({ summary }: { summary: SplitSummary }) {
if (summary.type === "text") {
return <p className="text-xs text-muted-foreground">{summary.label}</p>;
}
return (
<div className="w-[45%] space-y-1">
<div className="relative">
<Input
type="text"
inputMode="decimal"
value={amountToPercent(value, totalAmount)}
onChange={(event) => {
const sanitized = event.target.value.replace(/[^\d.,]/g, "");
onAmountChange(percentToAmount(sanitized, totalAmount));
}}
placeholder="0"
aria-label={ariaLabel}
className="h-9 w-full pr-7 text-sm"
/>
<span className="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-xs text-muted-foreground">
%
</span>
</div>
<p className="ml-1 text-xs text-muted-foreground">
{formatCurrency(safeToNumber(value))}
</p>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>{summary.count} pessoas:</span>
{summary.participants.map((participant, index) => {
const initial = participant.label.charAt(0).toUpperCase() || "?";
return (
<span
key={`${participant.label}-${index}`}
className="inline-flex min-w-0 items-center gap-0.5"
>
<Avatar className="size-4 border border-border/60 bg-background">
<AvatarImage
src={getAvatarSrc(participant.avatarUrl)}
alt={`Avatar de ${participant.label}`}
/>
<AvatarFallback className="text-[0.55rem] font-medium uppercase">
{initial}
</AvatarFallback>
</Avatar>
<span>{participant.firstName}</span>
</span>
);
})}
{summary.remainingCount > 0 ? (
<span>+{summary.remainingCount}</span>
) : null}
<span aria-hidden>·</span>
<span>{summary.totalLabel}</span>
</div>
);
}
@@ -135,53 +67,93 @@ export function PayerSection({
formState,
onFieldChange,
payerOptions,
secondaryPayerOptions,
splitPayerOptions,
totalAmount,
}: PayerSectionProps) {
const [splitMode, setSplitMode] = useState<SplitInputMode>("currency");
const [splitConfigOpen, setSplitConfigOpen] = useState(false);
const splitSummary = getSplitSummaryData(
formState,
payerOptions,
totalAmount,
);
const handlePrimaryAmountChange = (value: string) => {
onFieldChange("primarySplitAmount", value);
const remaining = Math.max(0, totalAmount - safeToNumber(value));
onFieldChange("secondarySplitAmount", remaining.toFixed(2));
const handleSplitToggle = (checked: boolean) => {
onFieldChange("isSplit", checked);
if (checked) {
setSplitConfigOpen(true);
}
};
const handleSecondaryAmountChange = (value: string) => {
onFieldChange("secondarySplitAmount", value);
const remaining = Math.max(0, totalAmount - safeToNumber(value));
onFieldChange("primarySplitAmount", remaining.toFixed(2));
const handleSplitCardClick = () => {
if (formState.isSplit) {
setSplitConfigOpen(true);
return;
}
handleSplitToggle(true);
};
return (
<div className="space-y-3">
<div className="space-y-1">
<Label htmlFor="payer">Pessoa</Label>
<Select
value={formState.payerId ?? ""}
onValueChange={(value) => onFieldChange("payerId", value)}
>
<SelectTrigger id="payer" className="w-full">
<SelectValue placeholder="Selecione">
{formState.payerId &&
(() => {
const selectedOption = payerOptions.find(
(opt) => opt.value === formState.payerId,
);
return selectedOption ? (
<PayerSelectContent
label={selectedOption.label}
avatarUrl={selectedOption.avatarUrl}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{payerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PayerSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div
className={cn(
"flex items-center justify-between rounded-lg border px-3 py-2.5 transition-colors",
"rounded-lg border px-3 py-2.5 transition-colors",
formState.isSplit
? "border-primary/20 bg-primary/5"
: "border-border bg-transparent",
)}
>
<div className="flex items-center gap-2">
<div>
<div className="flex items-start justify-between gap-3">
<button
type="button"
className="min-w-0 flex-1 space-y-0.5 text-left"
onClick={handleSplitCardClick}
>
<p className="text-sm text-foreground">Dividir lançamento</p>
<p className="text-xs text-muted-foreground">
Atribuir parte do valor a outra pessoa.
</p>
</div>
</div>
<div className="flex items-center gap-2">
{formState.isSplit ? (
<SplitModeToggle mode={splitMode} onModeChange={setSplitMode} />
) : null}
<SplitSummaryContent summary={splitSummary} />
</button>
<CheckboxPrimitive.Root
checked={formState.isSplit}
onCheckedChange={(checked) =>
onFieldChange("isSplit", Boolean(checked))
}
onCheckedChange={(checked) => handleSplitToggle(Boolean(checked))}
aria-label="Dividir lançamento"
className={cn(
"peer size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"peer mt-0.5 size-4 shrink-0 rounded-lg border shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
formState.isSplit
? "border-primary bg-primary text-primary-foreground"
: "border-input dark:bg-input/30",
@@ -192,110 +164,29 @@ export function PayerSection({
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
</div>
</div>
<div className="flex w-full flex-col gap-2 md:flex-row">
<div className="w-full space-y-1">
<Label htmlFor="payer">Pessoa</Label>
<div className="flex gap-2">
<Select
value={formState.payerId ?? ""}
onValueChange={(value) => onFieldChange("payerId", value)}
>
<SelectTrigger
id="payer"
className={formState.isSplit ? "min-w-0 flex-1" : "w-full"}
>
<SelectValue placeholder="Selecione">
{formState.payerId &&
(() => {
const selectedOption = payerOptions.find(
(opt) => opt.value === formState.payerId,
);
return selectedOption ? (
<PayerSelectContent
label={selectedOption.label}
avatarUrl={selectedOption.avatarUrl}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{payerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PayerSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
{formState.isSplit ? (
<SplitAmountField
mode={splitMode}
value={formState.primarySplitAmount}
totalAmount={totalAmount}
onAmountChange={handlePrimaryAmountChange}
ariaLabel="Porcentagem da pessoa"
/>
) : null}
</div>
</div>
{formState.isSplit ? (
<div className="w-full space-y-1 mb-1">
<Label htmlFor="secondaryPayer">Dividir com</Label>
<div className="flex gap-2">
<Select
value={formState.secondaryPayerId ?? ""}
onValueChange={(value) =>
onFieldChange("secondaryPayerId", value)
}
>
<SelectTrigger
id="secondaryPayer"
disabled={secondaryPayerOptions.length === 0}
className="w-[55%]"
>
<SelectValue placeholder="Selecione">
{formState.secondaryPayerId &&
(() => {
const selectedOption = secondaryPayerOptions.find(
(opt) => opt.value === formState.secondaryPayerId,
);
return selectedOption ? (
<PayerSelectContent
label={selectedOption.label}
avatarUrl={selectedOption.avatarUrl}
/>
) : null;
})()}
</SelectValue>
</SelectTrigger>
<SelectContent>
{secondaryPayerOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
<PayerSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</SelectItem>
))}
</SelectContent>
</Select>
<SplitAmountField
mode={splitMode}
value={formState.secondarySplitAmount}
totalAmount={totalAmount}
onAmountChange={handleSecondaryAmountChange}
ariaLabel="Porcentagem do segundo pagador"
/>
</div>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="mt-3 w-full"
onClick={() => setSplitConfigOpen(true)}
>
Editar divisão
</Button>
) : null}
</div>
<SplitConfigDialog
open={splitConfigOpen}
onOpenChange={setSplitConfigOpen}
formState={formState}
onFieldChange={onFieldChange}
payerOptions={payerOptions}
splitPayerOptions={splitPayerOptions}
totalAmount={totalAmount}
/>
</div>
);
}

View File

@@ -0,0 +1,412 @@
"use client";
import { Button } from "@/shared/components/ui/button";
import { Checkbox } from "@/shared/components/ui/checkbox";
import { CurrencyInput } from "@/shared/components/ui/currency-input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/shared/components/ui/dialog";
import { Input } from "@/shared/components/ui/input";
import { formatCurrency } from "@/shared/utils/currency";
import { safeToNumber } from "@/shared/utils/number";
import { cn } from "@/shared/utils/ui";
import { PayerSelectContent } from "../../select-items";
import type { FormState } from "./transaction-dialog-types";
const splitRowClassName =
"grid min-h-[2rem] items-center gap-2 rounded-lg border p-1.5 transition-colors sm:grid-cols-[minmax(0,1fr)_7rem_5.5rem]";
const splitDisabledFieldClassName =
"hidden h-9 rounded-md border border-transparent sm:block";
type SplitConfigDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
formState: FormState;
onFieldChange: <Key extends keyof FormState>(
key: Key,
value: FormState[Key],
) => void;
payerOptions: Array<{
value: string;
label: string;
role?: string | null;
avatarUrl?: string | null;
}>;
splitPayerOptions: Array<{
value: string;
label: string;
avatarUrl?: string | null;
}>;
totalAmount: number;
};
const getPercentValue = (amount: string, totalAmount: number) => {
if (totalAmount <= 0) return "0%";
const percentage = (safeToNumber(amount) / totalAmount) * 100;
return percentage.toLocaleString("pt-BR", {
maximumFractionDigits: 1,
});
};
const percentToAmount = (percent: string, totalAmount: number) => {
const normalized = percent.replace(/[^\d.,]/g, "").replace(",", ".");
const percentage = Number(normalized);
if (!Number.isFinite(percentage) || totalAmount <= 0) return "0.00";
const clamped = Math.min(100, Math.max(0, percentage));
return ((totalAmount * clamped) / 100).toFixed(2);
};
const getEqualAmounts = (count: number, totalAmount: number) => {
if (count <= 0 || totalAmount <= 0) return [];
const centsTotal = Math.round(totalAmount * 100);
const baseCents = Math.floor(centsTotal / count);
let remainder = centsTotal - baseCents * count;
return Array.from({ length: count }, () => {
const cents = baseCents + (remainder > 0 ? 1 : 0);
remainder -= 1;
return (cents / 100).toFixed(2);
});
};
type SplitSummaryPayerOption = {
value: string;
label: string;
avatarUrl?: string | null;
};
export function getSplitSummaryData(
formState: FormState,
payerOptions: SplitSummaryPayerOption[],
totalAmount: number,
) {
if (!formState.isSplit) {
return {
type: "text" as const,
label: "Atribuir partes do valor a outras pessoas.",
};
}
const participants = [
formState.payerId,
...formState.splitShares.map((share) => share.payerId),
].filter(Boolean);
if (participants.length <= 1) {
return {
type: "text" as const,
label: "Configure as pessoas e os valores da divisão.",
};
}
const total =
safeToNumber(formState.primarySplitAmount) +
formState.splitShares.reduce(
(sum, share) => sum + safeToNumber(share.amount),
0,
);
const displayedParticipants = participants
.slice(0, 3)
.map((payerId) => payerOptions.find((option) => option.value === payerId))
.filter(Boolean)
.map((option) => ({
label: option?.label ?? "",
firstName: option?.label.split(/\s+/)[0] ?? "",
avatarUrl: option?.avatarUrl ?? null,
}));
const remainingCount = Math.max(0, participants.length - 3);
const totalLabel =
Math.abs(total - totalAmount) <= 0.01
? formatCurrency(totalAmount)
: `${formatCurrency(total)} de ${formatCurrency(totalAmount)}`;
return {
type: "split" as const,
count: participants.length,
participants: displayedParticipants,
remainingCount,
totalLabel,
};
}
export function getSplitSummaryLabel(
formState: FormState,
payerOptions: SplitSummaryPayerOption[],
totalAmount: number,
) {
const summary = getSplitSummaryData(formState, payerOptions, totalAmount);
if (summary.type === "text") return summary.label;
const namesLabel = summary.participants
.map((participant) => participant.firstName)
.join(" ");
const remainingLabel =
summary.remainingCount > 0 ? ` +${summary.remainingCount}` : "";
return `${summary.count} pessoas: ${namesLabel}${remainingLabel} · ${summary.totalLabel}`;
}
export function SplitConfigDialog({
open,
onOpenChange,
formState,
onFieldChange,
payerOptions,
splitPayerOptions,
totalAmount,
}: SplitConfigDialogProps) {
const selectedSplitIds = new Set(
formState.splitShares.map((share) => share.payerId),
);
const availableSplitOptions = splitPayerOptions.filter(
(option) => option.value !== formState.payerId,
);
const primaryPayerOption =
payerOptions.find((option) => option.value === formState.payerId) ??
payerOptions.find((option) => option.role === "admin") ??
null;
const splitTotal =
safeToNumber(formState.primarySplitAmount) +
formState.splitShares.reduce(
(total, share) => total + safeToNumber(share.amount),
0,
);
const splitDifference = totalAmount - splitTotal;
const hasSplitDifference = Math.abs(splitDifference) > 0.01;
const splitDifferenceLabel =
splitDifference > 0
? `Faltam ${formatCurrency(splitDifference)}`
: `Sobram ${formatCurrency(Math.abs(splitDifference))}`;
const applyEqualSplit = (shares = formState.splitShares) => {
const participantCount = (formState.payerId ? 1 : 0) + shares.length;
const amounts = getEqualAmounts(participantCount, totalAmount);
if (amounts.length === 0) return;
onFieldChange("primarySplitAmount", amounts[0] ?? "0.00");
onFieldChange(
"splitShares",
shares.map((share, index) => ({
...share,
amount: amounts[index + 1] ?? "0.00",
})),
);
};
const toggleSplitPayer = (payerId: string, checked: boolean) => {
const nextShares = checked
? [...formState.splitShares, { payerId, amount: "0.00" }]
: formState.splitShares.filter((share) => share.payerId !== payerId);
applyEqualSplit(nextShares);
};
const handleSecondaryAmountChange = (payerId: string, value: string) => {
const nextShares = formState.splitShares.map((share) =>
share.payerId === payerId ? { ...share, amount: value } : share,
);
const othersTotal = nextShares.reduce(
(total, share) => total + safeToNumber(share.amount),
0,
);
onFieldChange("splitShares", nextShares);
onFieldChange(
"primarySplitAmount",
Math.max(0, totalAmount - othersTotal).toFixed(2),
);
};
const handleSecondaryPercentChange = (payerId: string, percent: string) => {
handleSecondaryAmountChange(payerId, percentToAmount(percent, totalAmount));
};
const handleDisableSplit = () => {
onFieldChange("isSplit", false);
onOpenChange(false);
};
const renderPercentInput = (
amount: string,
onPercentChange: (percent: string) => void,
ariaLabel: string,
) => (
<div className="relative">
<Input
type="text"
inputMode="decimal"
value={getPercentValue(amount, totalAmount)}
onChange={(event) => onPercentChange(event.target.value)}
placeholder="0"
aria-label={ariaLabel}
className="h-9 pr-7 text-sm"
/>
<span className="pointer-events-none absolute top-1/2 right-2 -translate-y-1/2 text-xs text-muted-foreground">
%
</span>
</div>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[90vh] min-w-0 flex-col overflow-hidden sm:max-w-lg">
<DialogHeader>
<DialogTitle>Dividir lançamento</DialogTitle>
<DialogDescription>
Marque as pessoas e ajuste os valores se precisar.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 space-y-2 overflow-y-auto pr-1">
<div
className={cn(
"flex flex-wrap items-center justify-between gap-2 rounded-lg border px-3 py-2.5",
hasSplitDifference
? "border-destructive/30 bg-destructive/5"
: "border-primary/20 bg-primary/5",
)}
>
<div>
<p className="text-sm font-medium">
{formatCurrency(splitTotal)} de {formatCurrency(totalAmount)}
</p>
<p
className={cn(
"text-xs",
hasSplitDifference
? "text-destructive"
: "text-muted-foreground",
)}
>
{hasSplitDifference ? splitDifferenceLabel : "Tudo certo"}
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => applyEqualSplit()}
disabled={
totalAmount <= 0 ||
!formState.payerId ||
formState.splitShares.length === 0
}
className="border-primary/30 bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary"
>
Dividir igualmente
</Button>
</div>
<div className="space-y-2">
{primaryPayerOption ? (
<div className={cn(splitRowClassName, "bg-background")}>
<div className="flex min-w-0 items-center gap-2 text-sm">
<Checkbox checked disabled aria-hidden />
<PayerSelectContent
label={primaryPayerOption.label}
avatarUrl={primaryPayerOption.avatarUrl}
/>
</div>
<CurrencyInput
value={formState.primarySplitAmount}
onValueChange={(value) =>
onFieldChange("primarySplitAmount", value)
}
placeholder="R$ 0,00"
aria-label={`Valor de ${primaryPayerOption.label}`}
className="h-9 text-sm"
/>
{renderPercentInput(
formState.primarySplitAmount,
(percent) =>
onFieldChange(
"primarySplitAmount",
percentToAmount(percent, totalAmount),
),
`Percentual de ${primaryPayerOption.label}`,
)}
</div>
) : null}
{availableSplitOptions.map((option) => {
const isSelected = selectedSplitIds.has(option.value);
const share = formState.splitShares.find(
(item) => item.payerId === option.value,
);
return (
<div
key={option.value}
className={cn(
splitRowClassName,
isSelected
? "bg-background"
: "border-border/60 bg-muted/20 opacity-60",
)}
>
<label className="flex min-w-0 cursor-pointer items-center gap-2 text-sm">
<Checkbox
checked={isSelected}
onCheckedChange={(checked) =>
toggleSplitPayer(option.value, Boolean(checked))
}
/>
<span className="min-w-0 flex-1">
<PayerSelectContent
label={option.label}
avatarUrl={option.avatarUrl}
/>
</span>
</label>
{isSelected && share ? (
<>
<CurrencyInput
value={share.amount}
onValueChange={(value) =>
handleSecondaryAmountChange(option.value, value)
}
placeholder="R$ 0,00"
aria-label={`Valor de ${option.label}`}
className="h-9 text-sm"
/>
{renderPercentInput(
share.amount,
(percent) =>
handleSecondaryPercentChange(option.value, percent),
`Percentual de ${option.label}`,
)}
</>
) : (
<>
<div className={splitDisabledFieldClassName} />
<div className={splitDisabledFieldClassName} />
</>
)}
</div>
);
})}
</div>
</div>
<DialogFooter className="shrink-0">
<Button type="button" variant="outline" onClick={handleDisableSplit}>
Cancelar divisão
</Button>
<Button type="button" onClick={() => onOpenChange(false)}>
Concluir
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -96,7 +96,7 @@ export interface CategorySectionProps extends BaseFieldSectionProps {
export interface PayerSectionProps extends BaseFieldSectionProps {
payerOptions: SelectOption[];
secondaryPayerOptions: SelectOption[];
splitPayerOptions: SelectOption[];
totalAmount: number;
}

View File

@@ -11,10 +11,7 @@ import {
detachTransactionAttachmentAction,
getPresignedUploadUrlAction,
} from "@/features/transactions/actions/attachments";
import {
filterSecondaryPayerOptions,
groupAndSortCategories,
} from "@/features/transactions/lib/category-helpers";
import { groupAndSortCategories } from "@/features/transactions/lib/category-helpers";
import {
applyFieldDependencies,
buildTransactionInitialState,
@@ -50,6 +47,7 @@ import type {
FormState,
TransactionDialogProps,
} from "./transaction-dialog-types";
import { TransactionSummaryCard } from "./transaction-summary-card";
export function TransactionDialog({
mode,
@@ -166,13 +164,6 @@ export function TransactionDialog({
mode,
]);
const primaryPayerId = formState.payerId;
const secondaryPayerOptions = useMemo(
() => filterSecondaryPayerOptions(splitPayerOptions, primaryPayerId),
[splitPayerOptions, primaryPayerId],
);
const categoryGroups = useMemo(() => {
const filtered = categoryOptions.filter(
(option) =>
@@ -259,14 +250,6 @@ export function TransactionDialog({
return;
}
if (formState.isSplit && !formState.secondaryPayerId) {
const message =
"Selecione a pessoa secundário para dividir o lançamento.";
setErrorMessage(message);
toast.error(message);
return;
}
const amountValue = Number(formState.amount);
if (Number.isNaN(amountValue)) {
const message = "Informe um valor válido.";
@@ -276,6 +259,44 @@ export function TransactionDialog({
}
const sanitizedAmount = Math.abs(amountValue);
const normalizedSplitShares = formState.isSplit
? [
{
payerId: formState.payerId ?? "",
amount: Number.parseFloat(formState.primarySplitAmount) || 0,
},
...formState.splitShares.map((share) => ({
payerId: share.payerId,
amount: Number.parseFloat(share.amount) || 0,
})),
]
: undefined;
if (formState.isSplit) {
if (formState.splitShares.length === 0) {
const message = "Selecione pelo menos uma pessoa para dividir.";
setErrorMessage(message);
toast.error(message);
return;
}
if (normalizedSplitShares?.some((share) => share.amount <= 0)) {
const message = "Informe um valor maior que zero para cada pessoa.";
setErrorMessage(message);
toast.error(message);
return;
}
const splitTotal =
normalizedSplitShares?.reduce((sum, share) => sum + share.amount, 0) ??
0;
if (Math.abs(splitTotal - sanitizedAmount) > 0.01) {
const message = "A soma das divisões deve ser igual ao valor total.";
setErrorMessage(message);
toast.error(message);
return;
}
}
if (!formState.categoryId) {
const message = "Selecione uma categoria.";
@@ -309,9 +330,7 @@ export function TransactionDialog({
paymentMethod:
formState.paymentMethod as CreateTransactionInput["paymentMethod"],
payerId: formState.payerId ?? null,
secondaryPayerId: formState.isSplit
? formState.secondaryPayerId
: undefined,
splitShares: normalizedSplitShares,
isSplit: formState.isSplit,
primarySplitAmount: formState.isSplit
? Number.parseFloat(formState.primarySplitAmount) || undefined
@@ -598,7 +617,7 @@ export function TransactionDialog({
formState={formState}
onFieldChange={handleFieldChange}
payerOptions={payerOptions}
secondaryPayerOptions={secondaryPayerOptions}
splitPayerOptions={splitPayerOptions}
totalAmount={totalAmount}
/>
@@ -671,7 +690,10 @@ export function TransactionDialog({
className="min-w-0"
>
<CollapsibleTrigger className="flex w-full items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer [&[data-state=open]>svg]:rotate-180 mt-4">
<RiArrowDropDownLine className="text-primary size-4 transition-transform duration-200" />
<RiArrowDropDownLine
className="text-primary size-4 transition-transform duration-200"
aria-hidden
/>
Condições, anotações e anexos
</CollapsibleTrigger>
<CollapsibleContent className="min-w-0 overflow-hidden space-y-3 pt-3">
@@ -707,6 +729,16 @@ export function TransactionDialog({
</CollapsibleContent>
</Collapsible>
)}
<div className="mt-3">
<TransactionSummaryCard
formState={formState}
payerOptions={payerOptions}
accountOptions={accountOptions}
cardOptions={cardOptions}
categoryOptions={categoryOptions}
/>
</div>
</div>
{errorMessage ? (

View File

@@ -0,0 +1,261 @@
"use client";
import {
type RemixiconComponentType,
RiBankCard2Line,
RiBankLine,
RiCalendarScheduleLine,
RiPriceTag3Line,
} from "@remixicon/react";
import type { ReactNode } from "react";
import { formatCurrency } from "@/shared/utils/currency";
import { safeToNumber } from "@/shared/utils/number";
import { MONTH_NAMES, parsePeriod } from "@/shared/utils/period";
import { cn } from "@/shared/utils/ui";
import type { SelectOption } from "../../types";
import type { FormState } from "./transaction-dialog-types";
type TransactionSummaryCardProps = {
formState: FormState;
payerOptions: SelectOption[];
accountOptions: SelectOption[];
cardOptions: SelectOption[];
categoryOptions: SelectOption[];
};
type ShareSummary = {
payerId: string | undefined;
label: string;
amountCents: number;
};
type SummaryChipProps = {
icon: RemixiconComponentType;
children: ReactNode;
};
const splitCents = (totalCents: number, parts: number) => {
if (parts <= 0) return [];
const base = Math.trunc(totalCents / parts);
const remainder = totalCents % parts;
return Array.from(
{ length: parts },
(_, index) => base + (index < remainder ? 1 : 0),
);
};
const toCents = (value: string | number) =>
Math.round(safeToNumber(value) * 100);
const firstName = (label: string) => label.trim().split(/\s+/)[0] || label;
function getOptionLabel(options: SelectOption[], value?: string) {
if (!value) return null;
return options.find((option) => option.value === value)?.label ?? null;
}
function SummaryChip({ icon: Icon, children }: SummaryChipProps) {
return (
<span className="inline-flex items-center gap-1 rounded-md bg-background/70 px-1.5 py-0.5 text-[0.7rem] leading-5 text-foreground/80 ring-1 ring-primary/10">
<Icon className="size-3 shrink-0 text-primary/65" aria-hidden />
<span className="min-w-0 truncate">{children}</span>
</span>
);
}
function getShareSummaries(
formState: FormState,
payerOptions: SelectOption[],
totalCents: number,
): ShareSummary[] {
if (!formState.isSplit) {
const label = getOptionLabel(payerOptions, formState.payerId) ?? "Pessoa";
return [{ payerId: formState.payerId, label, amountCents: totalCents }];
}
const shares = [
{
payerId: formState.payerId,
amountCents: toCents(formState.primarySplitAmount),
},
...formState.splitShares.map((share) => ({
payerId: share.payerId,
amountCents: toCents(share.amount),
})),
].filter((share) => share.payerId || share.amountCents > 0);
return shares.map((share, index) => ({
payerId: share.payerId,
label:
getOptionLabel(payerOptions, share.payerId) ??
(index === 0 ? "Pessoa principal" : "Pessoa"),
amountCents: share.amountCents,
}));
}
function formatInstallmentPart(totalCents: number, installmentCount: number) {
const parts = splitCents(totalCents, installmentCount);
const uniqueValues = Array.from(new Set(parts));
if (parts.length === 0) return null;
if (uniqueValues.length === 1) {
return `${installmentCount}x de ${formatCurrency(parts[0] / 100)}`;
}
return `${installmentCount}x de ~${formatCurrency(Math.max(...parts) / 100)}`;
}
function formatInvoicePeriod(period: string) {
try {
const { year, month } = parsePeriod(period);
return `${MONTH_NAMES[month - 1]} de ${year}`;
} catch {
return period;
}
}
export function TransactionSummaryCard({
formState,
payerOptions,
accountOptions,
cardOptions,
categoryOptions,
}: TransactionSummaryCardProps) {
const totalCents = Math.abs(toCents(formState.amount));
const totalAmount = totalCents / 100;
const installmentCount = Math.max(
0,
Math.trunc(safeToNumber(formState.installmentCount)),
);
const startInstallment = Math.max(
1,
Math.trunc(safeToNumber(formState.startInstallment, 1)),
);
const isInstallment =
formState.condition === "Parcelado" && installmentCount > 1;
const remainingInstallments = isInstallment
? Math.max(0, installmentCount - startInstallment + 1)
: 1;
const shares = getShareSummaries(formState, payerOptions, totalCents);
const targetLabel =
formState.paymentMethod === "Cartão de crédito"
? getOptionLabel(cardOptions, formState.cardId)
: getOptionLabel(accountOptions, formState.accountId);
const categoryLabel = getOptionLabel(categoryOptions, formState.categoryId);
const shareTotalCents = shares.reduce(
(sum, share) => sum + share.amountCents,
0,
);
const hasSplitDifference =
formState.isSplit && Math.abs(shareTotalCents - totalCents) > 1;
const displayedShares = shares.slice(0, 3);
const remainingShares = Math.max(0, shares.length - displayedShares.length);
const operationCount =
Math.max(1, remainingInstallments) * Math.max(1, shares.length);
const statusLabel =
formState.paymentMethod === "Cartão de crédito"
? `na fatura de ${formatInvoicePeriod(formState.period)}`
: formState.isSettled
? "como pago"
: "em aberto";
return (
<section className="rounded-lg border border-primary/20 bg-primary/5 px-3 py-2.5 text-xs shadow-xs shadow-primary/5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-semibold text-foreground">Resumo da operação</p>
<p className="mt-0.5 text-muted-foreground">
{formState.transactionType || "Lançamento"} de{" "}
<span className="font-medium text-foreground">
{formatCurrency(totalAmount)}
</span>{" "}
{statusLabel}
</p>
</div>
<span
className={cn(
"shrink-0 rounded-full px-2 py-0.5 font-medium",
formState.transactionType === "Receita"
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-300"
: "bg-orange-500/10 text-orange-700 dark:text-orange-300",
)}
>
{operationCount} lançamento{operationCount > 1 ? "s" : ""}
</span>
</div>
<div className="mt-2 flex flex-wrap gap-1 text-muted-foreground">
<SummaryChip
icon={
formState.paymentMethod === "Cartão de crédito"
? RiBankCard2Line
: RiBankLine
}
>
{formState.paymentMethod || "Forma não informada"}
</SummaryChip>
{targetLabel ? (
<SummaryChip
icon={
formState.paymentMethod === "Cartão de crédito"
? RiBankCard2Line
: RiBankLine
}
>
{targetLabel}
</SummaryChip>
) : null}
{categoryLabel ? (
<SummaryChip icon={RiPriceTag3Line}>{categoryLabel}</SummaryChip>
) : null}
{isInstallment ? (
<SummaryChip icon={RiCalendarScheduleLine}>
{startInstallment > 1
? `${remainingInstallments} parcelas restantes de ${installmentCount}`
: `${installmentCount} parcelas`}
</SummaryChip>
) : null}
</div>
<div className="mt-2 space-y-1">
{displayedShares.map((share) => {
const installmentLabel = isInstallment
? formatInstallmentPart(share.amountCents, installmentCount)
: null;
return (
<div
key={`${share.payerId ?? share.label}-${share.amountCents}`}
className="flex items-center justify-between gap-3 text-muted-foreground"
>
<span className="min-w-0 truncate">{firstName(share.label)}</span>
<span className="shrink-0 text-right text-foreground">
{formatCurrency(share.amountCents / 100)}
{installmentLabel ? (
<span className="text-muted-foreground">
{" "}
· {installmentLabel}
</span>
) : null}
</span>
</div>
);
})}
{remainingShares > 0 ? (
<p className="text-muted-foreground">
+{remainingShares} pessoas na divisão
</p>
) : null}
</div>
{hasSplitDifference ? (
<p className="mt-2 text-[0.7rem] text-destructive">
A divisão soma {formatCurrency(shareTotalCents / 100)} de{" "}
{formatCurrency(totalAmount)}.
</p>
) : null}
</section>
);
}

View File

@@ -29,7 +29,7 @@ export function PayerSelectContent({
return (
<span className="flex items-center gap-2">
<Avatar className="size-5 border border-border/60 bg-background">
<Avatar className="size-6 border border-border/60 bg-background">
<AvatarImage src={avatarSrc} alt={`Avatar de ${label}`} />
<AvatarFallback className="text-xs font-medium uppercase">
{initial}