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

@@ -155,14 +155,20 @@ export async function validateAllOwnership(
fields: {
payerId?: string | null;
secondaryPayerId?: string | null;
splitPayerIds?: Array<string | null | undefined>;
categoryId?: string | null;
accountId?: string | null;
cardId?: string | null;
},
): Promise<string | null> {
const payerIds = [
fields.payerId,
fields.secondaryPayerId,
...(fields.splitPayerIds ?? []),
];
const [ownedPayerIds, ownedCategoryIds, ownedAccountIds, ownedCardIds] =
await Promise.all([
fetchOwnedPayerIds(userId, [fields.payerId, fields.secondaryPayerId]),
fetchOwnedPayerIds(userId, payerIds),
fetchOwnedCategoryIds(userId, [fields.categoryId]),
fetchOwnedAccountIds(userId, [fields.accountId]),
fetchOwnedCardIds(userId, [fields.cardId]),
@@ -171,6 +177,7 @@ export async function validateAllOwnership(
const checks = [
!fields.payerId || ownedPayerIds.has(fields.payerId),
!fields.secondaryPayerId || ownedPayerIds.has(fields.secondaryPayerId),
(fields.splitPayerIds ?? []).every((id) => !id || ownedPayerIds.has(id)),
!fields.categoryId || ownedCategoryIds.has(fields.categoryId),
!fields.accountId || ownedAccountIds.has(fields.accountId),
!fields.cardId || ownedCardIds.has(fields.cardId),
@@ -178,7 +185,8 @@ export async function validateAllOwnership(
const errors = [
"Pessoa não encontrada ou sem permissão.",
"Pessoa secundário não encontrado ou sem permissão.",
"Pessoa secundária não encontrada ou sem permissão.",
"Uma das pessoas selecionadas não foi encontrada ou está sem permissão.",
"Categoria não encontrada.",
"Conta não encontrada.",
"Cartão não encontrado.",
@@ -322,6 +330,14 @@ const baseFields = z.object({
}),
payerId: uuidSchema("Payer").nullable().optional(),
secondaryPayerId: uuidSchema("Payer secundário").optional(),
splitShares: z
.array(
z.object({
payerId: uuidSchema("Pessoa"),
amount: z.coerce.number().min(0.01, "Informe um valor maior que zero."),
}),
)
.optional(),
isSplit: z.boolean().optional().default(false),
primarySplitAmount: z.coerce.number().min(0).optional(),
secondarySplitAmount: z.coerce.number().min(0).optional(),
@@ -434,6 +450,8 @@ const refineLancamento = (
}
if (data.isSplit) {
const shares = resolveSplitShares(data);
if (!data.payerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
@@ -442,30 +460,38 @@ const refineLancamento = (
});
}
if (!data.secondaryPayerId) {
if (shares.length < 2) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["secondaryPayerId"],
message: "Selecione a pessoa secundário para dividir o lançamento.",
});
} else if (data.payerId && data.secondaryPayerId === data.payerId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["secondaryPayerId"],
message: "Escolha uma pessoa diferente para dividir o lançamento.",
path: ["splitShares"],
message: "Selecione pelo menos uma pessoa para dividir o lançamento.",
});
}
if (
data.primarySplitAmount !== undefined &&
data.secondarySplitAmount !== undefined
) {
const sum = data.primarySplitAmount + data.secondarySplitAmount;
const uniquePayerIds = new Set(shares.map((share) => share.payerId));
if (uniquePayerIds.size !== shares.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["splitShares"],
message: "Escolha pessoas diferentes para dividir o lançamento.",
});
}
if (shares.some((share) => share.amount <= 0)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["splitShares"],
message: "Informe um valor maior que zero para cada pessoa.",
});
}
if (shares.length > 0) {
const sum = shares.reduce((total, share) => total + share.amount, 0);
const total = Math.abs(data.amount);
if (Math.abs(sum - total) > 0.01) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["primarySplitAmount"],
path: ["splitShares"],
message: "A soma das divisões deve ser igual ao valor total.",
});
}
@@ -561,11 +587,41 @@ type Share = {
amountCents: number;
};
type SplitShareInput = {
payerId: string;
amount: number;
};
export const resolveSplitShares = (data: {
payerId?: string | null;
secondaryPayerId?: string | null;
splitShares?: SplitShareInput[];
primarySplitAmount?: number;
secondarySplitAmount?: number;
}): SplitShareInput[] => {
if (data.splitShares && data.splitShares.length > 0) {
return data.splitShares;
}
if (!data.payerId || !data.secondaryPayerId) {
return [];
}
return [
{ payerId: data.payerId, amount: data.primarySplitAmount ?? 0 },
{
payerId: data.secondaryPayerId,
amount: data.secondarySplitAmount ?? 0,
},
];
};
export const buildShares = ({
totalCents,
payerId,
isSplit,
secondaryPayerId,
splitShares,
primarySplitAmountCents,
secondarySplitAmountCents,
}: {
@@ -573,10 +629,18 @@ export const buildShares = ({
payerId: string | null;
isSplit: boolean;
secondaryPayerId?: string;
splitShares?: SplitShareInput[];
primarySplitAmountCents?: number;
secondarySplitAmountCents?: number;
}): Share[] => {
if (isSplit) {
if (splitShares && splitShares.length > 0) {
return splitShares.map((share) => ({
payerId: share.payerId,
amountCents: Math.round(share.amount * 100),
}));
}
if (!payerId || !secondaryPayerId) {
throw new Error("Configuração de divisão inválida para o lançamento.");
}

View File

@@ -56,6 +56,7 @@ export async function createTransactionAction(
const ownershipError = await validateAllOwnership(user.id, {
payerId: data.payerId,
secondaryPayerId: data.secondaryPayerId,
splitPayerIds: data.splitShares?.map((share) => share.payerId),
categoryId: data.categoryId,
accountId: data.accountId,
cardId: data.cardId,
@@ -84,6 +85,7 @@ export async function createTransactionAction(
payerId: data.payerId ?? null,
isSplit: data.isSplit ?? false,
secondaryPayerId: data.secondaryPayerId,
splitShares: data.splitShares,
primarySplitAmountCents: data.primarySplitAmount
? Math.round(data.primarySplitAmount * 100)
: undefined,
@@ -207,6 +209,7 @@ export async function updateTransactionAction(
const ownershipError = await validateAllOwnership(user.id, {
payerId: data.payerId,
secondaryPayerId: data.secondaryPayerId,
splitPayerIds: data.splitShares?.map((share) => share.payerId),
categoryId: data.categoryId,
accountId: data.accountId,
cardId: data.cardId,
@@ -477,6 +480,7 @@ export async function updateTransactionSplitPairAction(
const ownershipError = await validateAllOwnership(user.id, {
payerId: data.payerId,
splitPayerIds: data.splitShares?.map((share) => share.payerId),
categoryId: data.categoryId,
accountId: data.accountId,
cardId: data.cardId,

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}

View File

@@ -73,6 +73,7 @@ export type TransactionFormState = {
paymentMethod: string;
payerId: string | undefined;
secondaryPayerId: string | undefined;
splitShares: Array<{ payerId: string; amount: string }>;
isSplit: boolean;
primarySplitAmount: string;
secondarySplitAmount: string;
@@ -171,6 +172,7 @@ export function buildTransactionInitialState(
paymentMethod,
payerId: fallbackPayerId ?? undefined,
secondaryPayerId: undefined,
splitShares: [],
isSplit: false,
primarySplitAmount: "",
@@ -332,6 +334,7 @@ export function applyFieldDependencies(
// When split is disabled, clear secondary pagador and split fields
if (key === "isSplit" && value === false) {
updates.secondaryPayerId = undefined;
updates.splitShares = [];
updates.primarySplitAmount = "";
updates.secondarySplitAmount = "";
}
@@ -340,9 +343,8 @@ export function applyFieldDependencies(
if (key === "isSplit" && value === true) {
const totalAmount = Number.parseFloat(currentState.amount) || 0;
if (totalAmount > 0) {
const half = (totalAmount / 2).toFixed(2);
updates.primarySplitAmount = half;
updates.secondarySplitAmount = half;
updates.primarySplitAmount = totalAmount.toFixed(2);
updates.secondarySplitAmount = "";
}
}
@@ -350,12 +352,20 @@ export function applyFieldDependencies(
if (key === "amount" && typeof value === "string" && currentState.isSplit) {
const totalAmount = Number.parseFloat(value) || 0;
if (totalAmount > 0) {
const half = (totalAmount / 2).toFixed(2);
updates.primarySplitAmount = half;
updates.secondarySplitAmount = half;
const otherTotal = currentState.splitShares.reduce(
(total, share) => total + (Number.parseFloat(share.amount) || 0),
0,
);
updates.primarySplitAmount = Math.max(
0,
totalAmount - otherTotal,
).toFixed(2);
} else {
updates.primarySplitAmount = "";
updates.secondarySplitAmount = "";
updates.splitShares = currentState.splitShares.map((share) => ({
...share,
amount: "",
}));
}
}
@@ -365,6 +375,23 @@ export function applyFieldDependencies(
if (secondaryValue && secondaryValue === value) {
updates.secondaryPayerId = undefined;
}
if (currentState.splitShares.some((share) => share.payerId === value)) {
const nextShares = currentState.splitShares.filter(
(share) => share.payerId !== value,
);
updates.splitShares = nextShares;
if (currentState.isSplit) {
const totalAmount = Number.parseFloat(currentState.amount) || 0;
const otherTotal = nextShares.reduce(
(total, share) => total + (Number.parseFloat(share.amount) || 0),
0,
);
updates.primarySplitAmount = Math.max(
0,
totalAmount - otherTotal,
).toFixed(2);
}
}
}
// When isSettled changes and payment method is Boleto