mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-09 23:06:01 +00:00
feat(lancamentos): amplia divisao e resumo do modal
This commit is contained in:
@@ -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.");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -96,7 +96,7 @@ export interface CategorySectionProps extends BaseFieldSectionProps {
|
||||
|
||||
export interface PayerSectionProps extends BaseFieldSectionProps {
|
||||
payerOptions: SelectOption[];
|
||||
secondaryPayerOptions: SelectOption[];
|
||||
splitPayerOptions: SelectOption[];
|
||||
totalAmount: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user