Files
butcem/src/features/accounts/components/account-form-dialog.tsx
T

707 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { ChevronDown, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { Controller, useForm, useWatch, type Control, type Resolver } from "react-hook-form";
import { toast } from "sonner";
import { IconPicker, SwatchPicker } from "@/components/appearance/pickers";
import { MoneyInput } from "@/components/money-input";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { tr } from "@/i18n/tr";
import {
ACCOUNT_TYPES,
DUE_RULE_KINDS,
MEAL_CARD_PROVIDERS,
type AccountType,
} from "@/shared/accounts";
import type { IconKey } from "@/shared/appearance";
import { todayIstanbul } from "@/shared/dates";
import { formatMoneyInput } from "@/shared/money";
import {
accountFormSchema,
type AccountForm,
type AccountFormInput,
} from "@/shared/schemas/account";
import { createAccountAction, updateAccountAction } from "../actions";
import type { AccountView } from "../service";
const t = tr.accounts;
const f = t.fields;
const NONE = "__none__";
const DAYS = Array.from({ length: 31 }, (_, i) => i + 1);
const DEFAULT_ICON: Record<AccountType, IconKey> = {
cash: "wallet",
bank: "landmark",
savings: "piggy-bank",
credit_card: "credit-card",
meal_card: "utensils",
};
type FormValues = {
name: string;
type: AccountType;
institution: string;
mealCardProvider: AccountFormInput["mealCardProvider"];
openingBalance: string;
openingDate: string;
color: AccountFormInput["color"];
icon: AccountFormInput["icon"];
ownerUserId: string;
isPrivate: boolean;
overdraftEnabled: boolean;
overdraftLimit: string;
overdraftMonthlyRate: string;
overdraftStatementDay: string;
cardLimit: string;
cardStatementDay: string;
cardDueKind: AccountFormInput["cardDueKind"];
cardDueValue: string;
cardPaymentAccountId: string;
cardMinimumRate: string;
};
const money = (minor: number | null | undefined) => (minor ? formatMoneyInput(minor) : "");
function toValues(a?: AccountView): FormValues {
if (!a) {
return {
name: "",
type: "bank",
institution: "",
mealCardProvider: null,
openingBalance: "",
openingDate: todayIstanbul(),
color: "blue",
icon: "landmark",
ownerUserId: "",
isPrivate: false,
overdraftEnabled: false,
overdraftLimit: "",
overdraftMonthlyRate: "",
overdraftStatementDay: "",
cardLimit: "",
cardStatementDay: "1",
cardDueKind: "daysAfter",
cardDueValue: "10",
cardPaymentAccountId: "",
cardMinimumRate: "",
};
}
const isCard = a.type === "credit_card";
return {
name: a.name,
type: a.type,
institution: a.institution,
mealCardProvider: a.mealCardProvider,
openingBalance:
a.openingBalanceMinor === null
? ""
: money(isCard ? Math.abs(a.openingBalanceMinor) : a.openingBalanceMinor),
openingDate: a.openingDate,
color: a.color,
icon: a.icon,
ownerUserId: a.ownerUserId ?? "",
isPrivate: a.isPrivate,
overdraftEnabled: Boolean(a.overdraft),
overdraftLimit: money(a.overdraft?.limitMinor),
overdraftMonthlyRate: a.overdraft?.monthlyRateBp
? formatMoneyInput(a.overdraft.monthlyRateBp)
: "",
overdraftStatementDay: a.overdraft?.statementDay ? String(a.overdraft.statementDay) : "",
cardLimit: money(a.creditCard?.limitMinor),
cardStatementDay: String(a.creditCard?.statementDay ?? 1),
cardDueKind: a.creditCard?.dueRule.kind ?? "daysAfter",
cardDueValue: String(a.creditCard?.dueRule.value ?? 10),
cardPaymentAccountId: a.creditCard?.paymentAccountId ?? "",
cardMinimumRate:
a.creditCard?.minimumPaymentRateBp != null
? formatMoneyInput(a.creditCard.minimumPaymentRateBp)
: "",
};
}
function DaySelect({
id,
value,
onChange,
max = 31,
allowEmpty,
}: {
id: string;
value: string;
onChange: (v: string) => void;
max?: number;
allowEmpty?: boolean;
}) {
return (
<Select value={value || NONE} onValueChange={(v) => onChange(v === NONE ? "" : v)}>
<SelectTrigger id={id} className="w-28">
<SelectValue>{value || "—"}</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-64">
{allowEmpty ? <SelectItem value={NONE}>—</SelectItem> : null}
{DAYS.slice(0, max).map((d) => (
<SelectItem key={d} value={String(d)}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
function MoneyField({
control,
name,
label,
hint,
allowNegative,
disabled,
}: {
control: Control<FormValues, unknown, AccountForm>;
name: "openingBalance" | "overdraftLimit" | "cardLimit";
label: string;
hint?: string;
allowNegative?: boolean;
disabled?: boolean;
}) {
return (
<Controller
name={name}
control={control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor={`acc-${name}`}>{label}</FieldLabel>
<MoneyInput
id={`acc-${name}`}
name={field.name}
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
allowNegative={allowNegative}
disabled={disabled}
placeholder={disabled ? t.hidden : "0,00"}
aria-invalid={fieldState.invalid}
aria-describedby={hint ? `acc-${name}-hint` : undefined}
/>
{hint ? <FieldDescription id={`acc-${name}-hint`}>{hint}</FieldDescription> : null}
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
);
}
export function AccountFormDialog({
open,
account,
members,
paymentAccounts,
onOpenChange,
}: {
open: boolean;
account?: AccountView;
members: { id: string; name: string }[];
paymentAccounts: { id: string; name: string }[];
onOpenChange: (open: boolean) => void;
}) {
const isEdit = Boolean(account);
const balanceHidden = isEdit && account!.openingBalanceMinor === null;
const form = useForm<FormValues, unknown, AccountForm>({
// FormValues, şemanın giriş şekliyle birebir (sayısal alanlar string olarak girilir, zod çevirir).
resolver: zodResolver(accountFormSchema) as unknown as Resolver<
FormValues,
unknown,
AccountForm
>,
defaultValues: toValues(),
});
const [advancedOpen, setAdvancedOpen] = useState(false);
useEffect(() => {
if (open) form.reset(toValues(account));
}, [open, account, form]);
function handleOpenChange(next: boolean) {
if (!next) setAdvancedOpen(false);
onOpenChange(next);
}
const type = useWatch({ control: form.control, name: "type" });
const color = useWatch({ control: form.control, name: "color" });
const overdraftEnabled = useWatch({ control: form.control, name: "overdraftEnabled" });
const dueKind = useWatch({ control: form.control, name: "cardDueKind" });
const isCard = type === "credit_card";
/** Ham form değerleri gönderilir; sunucu aynı şemayla ayrıştırır. */
async function onSubmit() {
const values = form.getValues();
// Gizli bakiyede sunucu mevcut değeri korur; doğrulamadan geçmesi için 0 gönderilir.
const payload = balanceHidden ? { ...values, openingBalance: "0" } : values;
const result = account
? await updateAccountAction(account.id, payload)
: await createAccountAction(payload);
if (result.ok) {
toast.success(account ? t.updated(values.name) : t.created(values.name));
handleOpenChange(false);
return;
}
let advancedError = false;
for (const [name, message] of Object.entries(result.fieldErrors ?? {})) {
if (!message) continue;
form.setError(name as keyof FormValues, { message });
if (/^(overdraft|ownerUserId|isPrivate)/.test(name)) advancedError = true;
}
if (advancedError) setAdvancedOpen(true);
if (!result.fieldErrors) form.setError("root", { message: result.error });
}
return (
<ResponsiveDialog
open={open}
onOpenChange={handleOpenChange}
title={isEdit ? t.editTitle : t.createTitle}
description={t.formDescription}
footer={
<>
<Button variant="outline" type="button" onClick={() => handleOpenChange(false)}>
{tr.common.cancel}
</Button>
<Button type="submit" form="account-form" disabled={form.formState.isSubmitting}>
{form.formState.isSubmitting ? <Loader2 className="animate-spin" /> : null}
{isEdit ? tr.common.save : tr.common.create}
</Button>
</>
}
>
<form id="account-form" onSubmit={form.handleSubmit(onSubmit)} noValidate>
<FieldGroup>
<Controller
name="type"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="acc-type">{f.type}</FieldLabel>
<Select
value={field.value}
disabled={isEdit}
onValueChange={(v) => {
field.onChange(v);
if (!isEdit) form.setValue("icon", DEFAULT_ICON[v as AccountType]);
}}
>
<SelectTrigger id="acc-type" className="w-full">
<SelectValue>{t.types[field.value]}</SelectValue>
</SelectTrigger>
<SelectContent>
{ACCOUNT_TYPES.map((x) => (
<SelectItem key={x} value={x}>
{t.types[x]}
</SelectItem>
))}
</SelectContent>
</Select>
{isEdit ? <FieldDescription>{f.typeLockedHint}</FieldDescription> : null}
</Field>
)}
/>
<Controller
name="name"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-name">{f.name}</FieldLabel>
<Input {...field} id="acc-name" aria-invalid={fieldState.invalid} />
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
{type === "meal_card" ? (
<Controller
name="mealCardProvider"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-provider">{f.provider}</FieldLabel>
<Select value={field.value ?? undefined} onValueChange={field.onChange}>
<SelectTrigger
id="acc-provider"
className="w-full"
aria-invalid={fieldState.invalid}
>
<SelectValue placeholder={f.provider}>
{field.value ? t.providers[field.value] : null}
</SelectValue>
</SelectTrigger>
<SelectContent>
{MEAL_CARD_PROVIDERS.map((p) => (
<SelectItem key={p} value={p}>
{t.providers[p]}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
) : type !== "cash" ? (
<Controller
name="institution"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="acc-institution">{f.institution}</FieldLabel>
<Input {...field} id="acc-institution" placeholder={f.institutionPlaceholder} />
</Field>
)}
/>
) : null}
<div className="grid gap-4 sm:grid-cols-2">
<MoneyField
control={form.control}
name="openingBalance"
label={isCard ? f.openingDebt : f.openingBalance}
hint={
balanceHidden
? t.hiddenHint
: isCard
? f.openingDebtHint
: type === "bank"
? f.openingBalanceHint
: undefined
}
allowNegative={type === "bank"}
disabled={balanceHidden}
/>
<Controller
name="openingDate"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-date">{f.openingDate}</FieldLabel>
<Input {...field} id="acc-date" type="date" aria-invalid={fieldState.invalid} />
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
</div>
{isCard ? (
<FieldSet>
<FieldLegend>{f.card}</FieldLegend>
<FieldGroup>
<MoneyField control={form.control} name="cardLimit" label={f.cardLimit} />
<Controller
name="cardStatementDay"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="acc-card-statement">{f.cardStatementDay}</FieldLabel>
<DaySelect
id="acc-card-statement"
value={field.value}
onChange={field.onChange}
/>
<FieldDescription>{f.cardStatementDayHint}</FieldDescription>
</Field>
)}
/>
<div className="grid gap-4 sm:grid-cols-2">
<Controller
name="cardDueKind"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="acc-card-due-kind">{f.cardDueKind}</FieldLabel>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger id="acc-card-due-kind" className="w-full">
<SelectValue>{f.dueKinds[field.value]}</SelectValue>
</SelectTrigger>
<SelectContent>
{DUE_RULE_KINDS.map((k) => (
<SelectItem key={k} value={k}>
{f.dueKinds[k]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
)}
/>
<Controller
name="cardDueValue"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-card-due-value">
{dueKind === "daysAfter" ? f.cardDueValueDays : f.cardDueValueDay}
</FieldLabel>
<DaySelect
id="acc-card-due-value"
value={field.value}
onChange={field.onChange}
max={dueKind === "daysAfter" ? 30 : 31}
/>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
</div>
<Controller
name="cardPaymentAccountId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-card-payer">{f.cardPaymentAccount}</FieldLabel>
<Select
value={field.value || NONE}
onValueChange={(v) => field.onChange(v === NONE ? "" : v)}
>
<SelectTrigger
id="acc-card-payer"
className="w-full"
aria-invalid={fieldState.invalid}
>
<SelectValue>
{paymentAccounts.find((p) => p.id === field.value)?.name ??
f.cardPaymentAccountNone}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>{f.cardPaymentAccountNone}</SelectItem>
{paymentAccounts
.filter((p) => p.id !== account?.id)
.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="cardMinimumRate"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-card-min">{f.cardMinimumRate}</FieldLabel>
<Input
{...field}
id="acc-card-min"
inputMode="decimal"
placeholder="20"
className="w-28 tabular-nums"
aria-describedby="acc-card-min-hint"
aria-invalid={fieldState.invalid}
/>
<FieldDescription id="acc-card-min-hint">
{f.cardMinimumRateHint}
</FieldDescription>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
</FieldGroup>
</FieldSet>
) : null}
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<Button type="button" variant="ghost" className="-ml-2 w-fit">
<ChevronDown
className={
advancedOpen ? "rotate-180 transition-transform" : "transition-transform"
}
/>
{f.advanced}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-3">
<FieldGroup>
{type === "bank" ? (
<FieldSet>
<FieldLegend>{f.overdraft}</FieldLegend>
<FieldGroup>
<Controller
name="overdraftEnabled"
control={form.control}
render={({ field }) => (
<Field orientation="horizontal">
<Checkbox
id="acc-od"
checked={field.value}
onCheckedChange={(v) => field.onChange(v === true)}
/>
<FieldLabel htmlFor="acc-od" className="font-normal">
{f.overdraftEnabled}
</FieldLabel>
</Field>
)}
/>
{overdraftEnabled ? (
<>
<MoneyField
control={form.control}
name="overdraftLimit"
label={f.overdraftLimit}
/>
<div className="grid gap-4 sm:grid-cols-2">
<Controller
name="overdraftMonthlyRate"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-od-rate">{f.overdraftRate}</FieldLabel>
<Input
{...field}
id="acc-od-rate"
inputMode="decimal"
placeholder="4,25"
className="w-28 tabular-nums"
aria-invalid={fieldState.invalid}
/>
{fieldState.invalid ? (
<FieldError errors={[fieldState.error]} />
) : null}
</Field>
)}
/>
<Controller
name="overdraftStatementDay"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel htmlFor="acc-od-day">
{f.overdraftStatementDay}
</FieldLabel>
<DaySelect
id="acc-od-day"
value={field.value}
onChange={field.onChange}
allowEmpty
/>
</Field>
)}
/>
</div>
</>
) : null}
</FieldGroup>
</FieldSet>
) : null}
<Controller
name="ownerUserId"
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor="acc-owner">{f.owner}</FieldLabel>
<Select
value={field.value || NONE}
onValueChange={(v) => field.onChange(v === NONE ? "" : v)}
>
<SelectTrigger
id="acc-owner"
className="w-full"
aria-invalid={fieldState.invalid}
>
<SelectValue>
{members.find((m) => m.id === field.value)?.name ?? f.ownerNone}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>{f.ownerNone}</SelectItem>
{members.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
{fieldState.invalid ? <FieldError errors={[fieldState.error]} /> : null}
</Field>
)}
/>
<Controller
name="isPrivate"
control={form.control}
render={({ field }) => (
<Field orientation="horizontal">
<Checkbox
id="acc-private"
checked={field.value}
onCheckedChange={(v) => field.onChange(v === true)}
/>
<FieldLabel htmlFor="acc-private" className="font-normal">
{f.isPrivate}
</FieldLabel>
</Field>
)}
/>
<Controller
name="color"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel id="acc-color-label">{tr.categories.color}</FieldLabel>
<SwatchPicker
value={field.value}
onChange={field.onChange}
labelledBy="acc-color-label"
/>
</Field>
)}
/>
<Controller
name="icon"
control={form.control}
render={({ field }) => (
<Field>
<FieldLabel id="acc-icon-label">{tr.categories.icon}</FieldLabel>
<IconPicker
value={field.value}
color={color}
onChange={field.onChange}
labelledBy="acc-icon-label"
/>
</Field>
)}
/>
</FieldGroup>
</CollapsibleContent>
</Collapsible>
{form.formState.errors.root ? (
<FieldError>{form.formState.errors.root.message}</FieldError>
) : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}