feat(accounts): hesaplar (KMH, kredi kartı, yemek kartı, yalnızca ben)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 02:57:58 +03:00
parent 380ddaf303
commit 8dd5fd42e3
9 changed files with 1805 additions and 0 deletions
@@ -0,0 +1,678 @@
"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;
};
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: "",
};
}
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 ?? "",
};
}
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>
)}
/>
</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>
);
}