mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-14 00:56:00 +00:00
feat(lancamentos): amplia divisao e resumo do modal
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user