diff --git a/src/features/transactions/actions/core.ts b/src/features/transactions/actions/core.ts index 9f6feb9..244feb3 100644 --- a/src/features/transactions/actions/core.ts +++ b/src/features/transactions/actions/core.ts @@ -155,14 +155,20 @@ export async function validateAllOwnership( fields: { payerId?: string | null; secondaryPayerId?: string | null; + splitPayerIds?: Array; categoryId?: string | null; accountId?: string | null; cardId?: string | null; }, ): Promise { + 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."); } diff --git a/src/features/transactions/actions/single-actions.ts b/src/features/transactions/actions/single-actions.ts index 5c2bc7f..3b0e74c 100644 --- a/src/features/transactions/actions/single-actions.ts +++ b/src/features/transactions/actions/single-actions.ts @@ -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, diff --git a/src/features/transactions/components/dialogs/split-pair-dialog.tsx b/src/features/transactions/components/dialogs/split-pair-dialog.tsx index b73d0f1..851bcdb 100644 --- a/src/features/transactions/components/dialogs/split-pair-dialog.tsx +++ b/src/features/transactions/components/dialogs/split-pair-dialog.tsx @@ -39,8 +39,8 @@ export function SplitPairDialog({ Editar lançamento dividido - 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: @@ -63,7 +63,7 @@ export function SplitPairDialog({ Apenas este lançamento

- Aplica a alteração somente neste lado da divisão + Aplica a alteração somente nesta parte da divisão

@@ -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

- 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

diff --git a/src/features/transactions/components/dialogs/transaction-dialog/payer-section.tsx b/src/features/transactions/components/dialogs/transaction-dialog/payer-section.tsx index ab4928e..fc7605b 100644 --- a/src/features/transactions/components/dialogs/transaction-dialog/payer-section.tsx +++ b/src/features/transactions/components/dialogs/transaction-dialog/payer-section.tsx @@ -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; -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 ( - { - if (value) onModeChange(value as SplitInputMode); - }} - aria-label="Modo de entrada do split" - className="h-7 text-xs" - > - {SPLIT_MODE_OPTIONS.map((option) => ( - - {option.label} - - ))} - - ); -} - -function SplitAmountField({ - mode, - value, - totalAmount, - onAmountChange, - ariaLabel, -}: { - mode: SplitInputMode; - value: string; - totalAmount: number; - onAmountChange: (amount: string) => void; - ariaLabel: string; -}) { - if (mode === "currency") { - return ( - - ); +function SplitSummaryContent({ summary }: { summary: SplitSummary }) { + if (summary.type === "text") { + return

{summary.label}

; } return ( -
-
- { - 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" - /> - - % - -
-

- {formatCurrency(safeToNumber(value))} -

+
+ {summary.count} pessoas: + {summary.participants.map((participant, index) => { + const initial = participant.label.charAt(0).toUpperCase() || "?"; + + return ( + + + + + {initial} + + + {participant.firstName} + + ); + })} + {summary.remainingCount > 0 ? ( + +{summary.remainingCount} + ) : null} + · + {summary.totalLabel}
); } @@ -135,53 +67,93 @@ export function PayerSection({ formState, onFieldChange, payerOptions, - secondaryPayerOptions, + splitPayerOptions, totalAmount, }: PayerSectionProps) { - const [splitMode, setSplitMode] = useState("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 (
+
+ + +
+
-
-
+
+
-
-
- {formState.isSplit ? ( - - ) : null} + + - 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({
-
- -
-
- -
- - {formState.isSplit ? ( - - ) : null} -
-
{formState.isSplit ? ( -
- -
- - -
-
+ ) : null}
+ +
); } diff --git a/src/features/transactions/components/dialogs/transaction-dialog/split-config-dialog.tsx b/src/features/transactions/components/dialogs/transaction-dialog/split-config-dialog.tsx new file mode 100644 index 0000000..0b9fdbc --- /dev/null +++ b/src/features/transactions/components/dialogs/transaction-dialog/split-config-dialog.tsx @@ -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: 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, + ) => ( +
+ onPercentChange(event.target.value)} + placeholder="0" + aria-label={ariaLabel} + className="h-9 pr-7 text-sm" + /> + + % + +
+ ); + + return ( + + + + Dividir lançamento + + Marque as pessoas e ajuste os valores se precisar. + + + +
+
+
+

+ {formatCurrency(splitTotal)} de {formatCurrency(totalAmount)} +

+

+ {hasSplitDifference ? splitDifferenceLabel : "Tudo certo"} +

+
+ +
+ +
+ {primaryPayerOption ? ( +
+
+ + +
+ + 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}`, + )} +
+ ) : null} + + {availableSplitOptions.map((option) => { + const isSelected = selectedSplitIds.has(option.value); + const share = formState.splitShares.find( + (item) => item.payerId === option.value, + ); + + return ( +
+ + {isSelected && share ? ( + <> + + 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}`, + )} + + ) : ( + <> +
+
+ + )} +
+ ); + })} +
+
+ + + + + + +
+ ); +} diff --git a/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog-types.ts b/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog-types.ts index 194ece3..0ef1a33 100644 --- a/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog-types.ts +++ b/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog-types.ts @@ -96,7 +96,7 @@ export interface CategorySectionProps extends BaseFieldSectionProps { export interface PayerSectionProps extends BaseFieldSectionProps { payerOptions: SelectOption[]; - secondaryPayerOptions: SelectOption[]; + splitPayerOptions: SelectOption[]; totalAmount: number; } diff --git a/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx b/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx index 305d4e9..db8a17d 100644 --- a/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx +++ b/src/features/transactions/components/dialogs/transaction-dialog/transaction-dialog.tsx @@ -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" > - + Condições, anotações e anexos @@ -707,6 +729,16 @@ export function TransactionDialog({ )} + +
+ +
{errorMessage ? ( diff --git a/src/features/transactions/components/dialogs/transaction-dialog/transaction-summary-card.tsx b/src/features/transactions/components/dialogs/transaction-dialog/transaction-summary-card.tsx new file mode 100644 index 0000000..82a19ce --- /dev/null +++ b/src/features/transactions/components/dialogs/transaction-dialog/transaction-summary-card.tsx @@ -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 ( + + + {children} + + ); +} + +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 ( +
+
+
+

Resumo da operação

+

+ {formState.transactionType || "Lançamento"} de{" "} + + {formatCurrency(totalAmount)} + {" "} + {statusLabel} +

+
+ + {operationCount} lançamento{operationCount > 1 ? "s" : ""} + +
+ +
+ + {formState.paymentMethod || "Forma não informada"} + + {targetLabel ? ( + + {targetLabel} + + ) : null} + {categoryLabel ? ( + {categoryLabel} + ) : null} + {isInstallment ? ( + + {startInstallment > 1 + ? `${remainingInstallments} parcelas restantes de ${installmentCount}` + : `${installmentCount} parcelas`} + + ) : null} +
+ +
+ {displayedShares.map((share) => { + const installmentLabel = isInstallment + ? formatInstallmentPart(share.amountCents, installmentCount) + : null; + + return ( +
+ {firstName(share.label)} + + {formatCurrency(share.amountCents / 100)} + {installmentLabel ? ( + + {" "} + · {installmentLabel} + + ) : null} + +
+ ); + })} + {remainingShares > 0 ? ( +

+ +{remainingShares} pessoas na divisão +

+ ) : null} +
+ + {hasSplitDifference ? ( +

+ A divisão soma {formatCurrency(shareTotalCents / 100)} de{" "} + {formatCurrency(totalAmount)}. +

+ ) : null} +
+ ); +} diff --git a/src/features/transactions/components/select-items.tsx b/src/features/transactions/components/select-items.tsx index d371a46..65c1ae7 100644 --- a/src/features/transactions/components/select-items.tsx +++ b/src/features/transactions/components/select-items.tsx @@ -29,7 +29,7 @@ export function PayerSelectContent({ return ( - + {initial} diff --git a/src/features/transactions/lib/form-helpers.ts b/src/features/transactions/lib/form-helpers.ts index a161a97..b764f43 100644 --- a/src/features/transactions/lib/form-helpers.ts +++ b/src/features/transactions/lib/form-helpers.ts @@ -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