Files
butcem/src/features/contacts/components/contact-dialogs.tsx
T

435 lines
15 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 { 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>
);
}