feat(ui): cari hesaplar sayfaları, hareket formu ve vade uyarısı

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 11:58:37 +03:00
parent 5d370a062e
commit b6c6be5c97
11 changed files with 975 additions and 12 deletions
@@ -0,0 +1,434 @@
"use client";
import { Loader2, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
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 { Field, FieldDescription, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
getTransactionFormOptionsAction,
setTransactionDeletedAction,
} from "@/features/transactions/actions";
import { AccountSelect } from "@/features/transactions/components/transaction-form";
import type { TransactionFormOptions, TransactionListItem } from "@/features/transactions/service";
import { tr } from "@/i18n/tr";
import {
CONTACT_ENTRY_KINDS,
CONTACT_RELATIONS,
isOutgoing,
type ContactEntryKind,
type ContactRelation,
} from "@/shared/contacts";
import { todayIstanbul } from "@/shared/dates";
import { formatMoneyInput } from "@/shared/money";
import { saveContactAction, saveContactEntryAction } from "../actions";
import type { ContactView } from "../service";
const t = tr.contacts;
type Errors = Record<string, string | undefined>;
/** Cari oluştur / düzenle. */
export function ContactFormDialog({
open,
contact,
onOpenChange,
}: {
open: boolean;
contact: ContactView | null;
onOpenChange: (open: boolean) => void;
}) {
return open ? (
<ContactForm key={contact?.id ?? "new"} contact={contact} onClose={() => onOpenChange(false)} />
) : null;
}
function ContactForm({ contact, onClose }: { contact: ContactView | null; onClose: () => void }) {
const [v, setV] = useState({
name: contact?.name ?? "",
relation: (contact?.relation ?? "friend") as ContactRelation,
phone: contact?.phone ?? "",
note: contact?.note ?? "",
openingBalance: contact?.openingBalanceMinor
? formatMoneyInput(contact.openingBalanceMinor)
: "",
openingDate: contact?.openingDate ?? todayIstanbul(),
dueDate: contact?.dueDate ?? "",
reminderDays: String(contact?.reminderDays ?? 3),
isPrivate: contact?.isPrivate ?? false,
});
const [errors, setErrors] = useState<Errors>({});
const [busy, setBusy] = useState(false);
const set = <K extends keyof typeof v>(k: K, value: (typeof v)[K]) =>
setV((x) => ({ ...x, [k]: value }));
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const r = await saveContactAction(contact?.id ?? null, v);
setBusy(false);
if (r.ok) {
toast.success(contact ? t.toast.updated : t.toast.created);
onClose();
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
}
const f = t.fields;
return (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={contact ? t.editTitle : t.createTitle}
footer={
<>
<Button variant="outline" type="button" onClick={onClose}>
{tr.common.cancel}
</Button>
<Button type="submit" form="contact-form" disabled={busy}>
{busy ? <Loader2 className="animate-spin" /> : null}
{tr.common.save}
</Button>
</>
}
>
<form id="contact-form" onSubmit={submit} noValidate>
<FieldGroup>
<Field data-invalid={Boolean(errors.name)}>
<FieldLabel htmlFor="ct-name">{f.name}</FieldLabel>
<Input
id="ct-name"
value={v.name}
onChange={(e) => set("name", e.target.value)}
aria-invalid={Boolean(errors.name)}
autoFocus
/>
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field>
<FieldLabel htmlFor="ct-relation">{f.relation}</FieldLabel>
<Select
value={v.relation}
onValueChange={(x) => set("relation", x as ContactRelation)}
>
<SelectTrigger id="ct-relation" className="w-full">
<SelectValue>{t.relations[v.relation]}</SelectValue>
</SelectTrigger>
<SelectContent>
{CONTACT_RELATIONS.map((r) => (
<SelectItem key={r} value={r}>
{t.relations[r]}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field data-invalid={Boolean(errors.phone)}>
<FieldLabel htmlFor="ct-phone">{f.phone}</FieldLabel>
<Input
id="ct-phone"
type="tel"
inputMode="tel"
value={v.phone}
onChange={(e) => set("phone", e.target.value)}
aria-invalid={Boolean(errors.phone)}
/>
{errors.phone ? <FieldError>{errors.phone}</FieldError> : null}
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.openingBalance)}>
<FieldLabel htmlFor="ct-opening">{f.openingBalance}</FieldLabel>
<MoneyInput
id="ct-opening"
value={v.openingBalance}
onChange={(x) => set("openingBalance", x)}
allowNegative
/>
{errors.openingBalance ? <FieldError>{errors.openingBalance}</FieldError> : null}
</Field>
<Field>
<FieldLabel htmlFor="ct-opening-date">{tr.transactions.fields.date}</FieldLabel>
<Input
id="ct-opening-date"
type="date"
value={v.openingDate}
onChange={(e) => set("openingDate", e.target.value)}
/>
</Field>
</div>
<FieldDescription className="-mt-2">{f.openingHint}</FieldDescription>
<DueFields
dueDate={v.dueDate}
reminderDays={v.reminderDays}
onChange={(d, r) => setV((x) => ({ ...x, dueDate: d, reminderDays: r }))}
errors={errors}
/>
<Field>
<FieldLabel htmlFor="ct-note">{f.note}</FieldLabel>
<Textarea
id="ct-note"
rows={2}
value={v.note}
onChange={(e) => set("note", e.target.value)}
/>
</Field>
<label className="flex items-start gap-2 text-sm">
<Checkbox
checked={v.isPrivate}
onCheckedChange={(x) => set("isPrivate", x === true)}
className="mt-0.5"
/>
{f.isPrivate}
</label>
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
function DueFields({
dueDate,
reminderDays,
onChange,
errors,
}: {
dueDate: string;
reminderDays: string;
onChange: (dueDate: string, reminderDays: string) => void;
errors: Errors;
}) {
const f = t.fields;
return (
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.dueDate)}>
<FieldLabel htmlFor="ct-due">{f.dueDate}</FieldLabel>
<Input
id="ct-due"
type="date"
value={dueDate}
onChange={(e) => onChange(e.target.value, reminderDays)}
aria-describedby="ct-due-hint"
/>
<FieldDescription id="ct-due-hint">{f.dueHint}</FieldDescription>
{errors.dueDate ? <FieldError>{errors.dueDate}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.reminderDays)}>
<FieldLabel htmlFor="ct-remind">{f.reminderDays}</FieldLabel>
<Input
id="ct-remind"
type="number"
inputMode="numeric"
min={0}
max={60}
value={reminderDays}
onChange={(e) => onChange(dueDate, e.target.value)}
disabled={!dueDate}
/>
</Field>
</div>
);
}
export type EntryMode = {
contact: ContactView;
entry?: TransactionListItem;
kind?: ContactEntryKind;
};
/** Cari hareketi ekle / düzenle (düzenlemede sil). */
export function ContactEntryDialog({
mode,
onClose,
}: {
mode: EntryMode | null;
onClose: () => void;
}) {
return mode ? (
<EntryForm key={mode.entry?.id ?? `new-${mode.kind ?? ""}`} mode={mode} onClose={onClose} />
) : null;
}
function EntryForm({ mode, onClose }: { mode: EntryMode; onClose: () => void }) {
const { contact, entry } = mode;
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
const [kind, setKind] = useState<ContactEntryKind>(
entry?.contact?.kind ?? mode.kind ?? (contact.balanceMinor < 0 ? "repay" : "lend"),
);
const [v, setV] = useState({
accountId: entry?.account?.id ?? entry?.toAccount?.id ?? "",
amount: entry ? formatMoneyInput(entry.amountMinor) : "",
date: entry?.date ?? todayIstanbul(),
description: entry?.description ?? "",
dueDate: contact.dueDate ?? "",
reminderDays: String(contact.reminderDays),
});
const [errors, setErrors] = useState<Errors>({});
const [busy, setBusy] = useState(false);
useEffect(() => {
let cancelled = false;
getTransactionFormOptionsAction().then((r) => {
if (cancelled || !r.ok) return;
setOptions(r.data);
setV((x) => (x.accountId ? x : { ...x, accountId: r.data.accounts[0]?.id ?? "" }));
});
return () => {
cancelled = true;
};
}, []);
const accounts = (() => {
const list = [...(options?.accounts ?? [])];
const current = entry?.account ?? entry?.toAccount;
if (current && !list.some((a) => a.id === current.id))
list.push({ ...current, type: "bank", icon: "wallet", color: "slate" });
return list;
})();
const createsDebt = kind === "lend" || kind === "borrow";
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const payload = {
kind,
accountId: v.accountId,
amount: v.amount,
date: v.date,
description: v.description,
// Vade yalnızca borç veren/alan harekette güncellenir.
...(createsDebt ? { dueDate: v.dueDate, reminderDays: v.reminderDays } : {}),
};
const r = await saveContactEntryAction(contact.id, entry?.id ?? null, payload);
setBusy(false);
if (r.ok) {
toast.success(t.toast.entrySaved);
onClose();
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
}
async function remove() {
if (!entry) return;
const r = await setTransactionDeletedAction(entry.id, true);
if (!r.ok) return void toast.error(r.error);
onClose();
toast.success(tr.transactions.toast.deleted, {
duration: 10_000,
action: {
label: tr.transactions.actions.restore,
onClick: async () => {
const x = await setTransactionDeletedAction(entry.id, false);
if (x.ok) toast.success(tr.transactions.toast.restored);
else toast.error(x.error);
},
},
});
}
const f = t.fields;
return (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={`${entry ? t.editEntryTitle : t.entryTitle} · ${contact.name}`}
footer={
<>
{entry ? (
<Button variant="destructive" type="button" onClick={remove} className="sm:mr-auto">
<Trash2 />
{tr.transactions.actions.delete}
</Button>
) : null}
<Button variant="outline" type="button" onClick={onClose}>
{tr.common.cancel}
</Button>
<Button type="submit" form="contact-entry-form" disabled={busy || !options}>
{busy ? <Loader2 className="animate-spin" /> : null}
{tr.common.save}
</Button>
</>
}
>
<form id="contact-entry-form" onSubmit={submit} noValidate>
<FieldGroup>
<Tabs value={kind} onValueChange={(x) => setKind(x as ContactEntryKind)}>
<TabsList className="grid h-auto w-full grid-cols-2 sm:grid-cols-4">
{CONTACT_ENTRY_KINDS.map((k) => (
<TabsTrigger key={k} value={k}>
{t.kinds[k]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<p className="-mt-2 text-xs text-muted-foreground">{t.kindHints[kind]}</p>
<div className="grid grid-cols-[1fr_auto] gap-3">
<Field data-invalid={Boolean(errors.amount)}>
<FieldLabel htmlFor="ce-amount">{tr.transactions.fields.amount}</FieldLabel>
<MoneyInput
id="ce-amount"
value={v.amount}
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
aria-invalid={Boolean(errors.amount)}
autoFocus
/>
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.date)}>
<FieldLabel htmlFor="ce-date">{tr.transactions.fields.date}</FieldLabel>
<Input
id="ce-date"
type="date"
value={v.date}
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
/>
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
</Field>
</div>
<Field data-invalid={Boolean(errors.accountId)}>
<FieldLabel htmlFor="ce-account">
{isOutgoing(kind) ? f.fromAccount : f.toAccount}
</FieldLabel>
<AccountSelect
id="ce-account"
value={v.accountId}
onChange={(x) => setV((s) => ({ ...s, accountId: x }))}
accounts={accounts}
invalid={Boolean(errors.accountId)}
/>
{errors.accountId ? <FieldError>{errors.accountId}</FieldError> : null}
</Field>
<Field>
<FieldLabel htmlFor="ce-description">{tr.transactions.fields.description}</FieldLabel>
<Input
id="ce-description"
value={v.description}
onChange={(e) => setV((s) => ({ ...s, description: e.target.value }))}
/>
</Field>
{createsDebt ? (
<DueFields
dueDate={v.dueDate}
reminderDays={v.reminderDays}
onChange={(d, r) => setV((s) => ({ ...s, dueDate: d, reminderDays: r }))}
errors={errors}
/>
) : null}
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}