mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-06-13 16:46:01 +00:00
395 lines
11 KiB
TypeScript
395 lines
11 KiB
TypeScript
"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 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>
|
|
);
|
|
}
|