b6c6be5c97
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
435 lines
15 KiB
TypeScript
435 lines
15 KiB
TypeScript
"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>
|
||
);
|
||
}
|