feat(ui): aynî kayıt kartları, ödeme ve birikim diyalogları, fiyat tablosu sayfası

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 12:37:48 +03:00
parent 9781cd9ed2
commit 3cd8d98324
9 changed files with 1119 additions and 7 deletions
@@ -0,0 +1,713 @@
"use client";
import { Coins, EllipsisVertical, HandCoins, Pencil, PiggyBank, Plus, Trash2 } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { MoneyInput } from "@/components/money-input";
import { ResponsiveDialog } from "@/components/responsive-dialog";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
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 { getTransactionFormOptionsAction } from "@/features/transactions/actions";
import { AccountSelect } from "@/features/transactions/components/transaction-form";
import type { TransactionFormOptions } from "@/features/transactions/service";
import { tr } from "@/i18n/tr";
import { cn } from "@/lib/utils";
import { ASSET_KEYS, ASSETS, formatAssetQuantity, type AssetKey } from "@/shared/assets";
import { todayIstanbul } from "@/shared/dates";
import { formatDate, formatMoney } from "@/shared/format";
import { formatMoneyInput } from "@/shared/money";
import { QUANTITY_SCALE } from "@/shared/quantity";
import {
addContributionAction,
deleteInKindAction,
recordPaymentAction,
saveInKindAction,
} from "../actions";
import type { InKindView } from "../service";
const t = tr.inKind;
type Errors = Record<string, string | undefined>;
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
const monthFmt = new Intl.DateTimeFormat("tr-TR", {
month: "long",
year: "numeric",
timeZone: "Europe/Istanbul",
});
const qtyInput = (q: number) =>
(q / QUANTITY_SCALE).toLocaleString("tr-TR", { maximumFractionDigits: 3, useGrouping: false });
/** Hesap seçenekleri (diyaloglar açıldığında). */
function useAccounts() {
const [options, setOptions] = useState<TransactionFormOptions | null>(null);
useEffect(() => {
let cancelled = false;
getTransactionFormOptionsAction().then((r) => {
if (!cancelled && r.ok) setOptions(r.data);
});
return () => {
cancelled = true;
};
}, []);
return options?.accounts ?? null;
}
function Stat({ label, value, tone }: { label: string; value: string; tone?: string }) {
return (
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className={cn("font-semibold tabular-nums", tone)}>{value}</p>
</div>
);
}
type Pending =
| { kind: "form"; item: InKindView | null }
| { kind: "contribute"; item: InKindView }
| { kind: "pay"; item: InKindView }
| { kind: "delete"; item: InKindView }
| null;
/** Cari sayfasında aynî borç/alacak bölümü. */
export function InKindSection({
contactId,
items,
canWrite,
}: {
contactId: string;
items: InKindView[];
canWrite: boolean;
}) {
const [pending, setPending] = useState<Pending>(null);
const close = () => setPending(null);
return (
<section className="flex flex-col gap-3" aria-labelledby="in-kind-title">
<div className="flex flex-wrap items-end justify-between gap-2">
<div>
<h2 id="in-kind-title" className="text-base font-medium">
{t.title}
</h2>
<p className="text-xs text-muted-foreground">{t.description}</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" asChild>
<Link href="/fiyatlar">
<Coins />
{t.prices}
</Link>
</Button>
{canWrite ? (
<Button
size="sm"
variant="outline"
onClick={() => setPending({ kind: "form", item: null })}
>
<Plus />
{t.new}
</Button>
) : null}
</div>
</div>
{items.map((item) => (
<InKindCard key={item.id} item={item} canWrite={canWrite} onAction={setPending} />
))}
{pending?.kind === "form" ? (
<InKindForm contactId={contactId} item={pending.item} onClose={close} />
) : null}
{pending?.kind === "contribute" ? (
<ContributionForm item={pending.item} onClose={close} />
) : null}
{pending?.kind === "pay" ? <PaymentForm item={pending.item} onClose={close} /> : null}
<AlertDialog open={pending?.kind === "delete"} onOpenChange={(o) => !o && close()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t.deleteTitle}</AlertDialogTitle>
<AlertDialogDescription>{t.deleteDescription}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tr.common.cancel}</AlertDialogCancel>
<Button
variant="destructive"
onClick={async () => {
if (pending?.kind !== "delete") return;
const r = await deleteInKindAction(pending.item.id);
close();
if (r.ok) toast.success(t.toast.deleted);
else toast.error(r.error);
}}
>
{t.delete}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
function InKindCard({
item,
canWrite,
onAction,
}: {
item: InKindView;
canWrite: boolean;
onAction: (p: Pending) => void;
}) {
const p = item.progress;
const debt = item.direction === "debt";
const open = !item.closedAt;
return (
<Card size="sm" className={cn(!open && "opacity-70")}>
<CardHeader>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<CardTitle className="flex flex-wrap items-center gap-2 text-base">
{item.label}
<Badge variant={debt ? "destructive" : "secondary"}>
{t.directions[item.direction]}
</Badge>
{item.closedAt ? (
<Badge variant="outline">{t.stats.closed(d(item.closedAt))}</Badge>
) : null}
</CardTitle>
<CardDescription>
{t.stats.dateValue(d(item.date))}:{" "}
{item.valueMinor !== null ? formatMoney(item.valueMinor) : "—"}
{item.unitPriceMinor !== null
? ` · ${t.stats.priceAt(formatMoney(item.unitPriceMinor), d(item.date))}`
: ""}
</CardDescription>
</div>
{canWrite ? (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button size="icon-sm" variant="ghost" aria-label={tr.recurring.actions.more}>
<EllipsisVertical />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => onAction({ kind: "form", item })}>
<Pencil />
{tr.common.edit}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => onAction({ kind: "delete", item })}
>
<Trash2 />
{t.delete}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Stat
label={t.stats.remaining}
value={formatAssetQuantity(item.assetKey, p.remainingQuantity)}
/>
<Stat
label={t.stats.currentValue}
value={
p.currentValueMinor !== null ? formatMoney(p.currentValueMinor) : t.stats.noPrice
}
tone={debt ? "text-expense" : "text-income"}
/>
{item.savingsAccount ? (
<>
<Stat
label={`${t.stats.accumulated} · ${item.savingsAccount.name}`}
value={formatMoney(p.accumulatedMinor)}
tone="text-income"
/>
<Stat
label={t.stats.remainingNeed}
value={p.remainingNeedMinor !== null ? formatMoney(p.remainingNeedMinor) : "—"}
/>
</>
) : null}
</div>
{p.estimatedCloseMonth && open ? (
<p className="text-sm">
{t.stats.estimatedClose}:{" "}
<span className="font-medium capitalize">
{monthFmt.format(new Date(`${p.estimatedCloseMonth}-15T12:00:00+03:00`))}
</span>
{item.monthlyPlanMinor ? (
<span className="text-muted-foreground">
{" "}
· {formatMoney(item.monthlyPlanMinor)}/ay
</span>
) : null}
</p>
) : null}
{item.currentPrice ? (
<p className="text-xs text-muted-foreground">
{t.stats.currentPrice(
debt,
formatMoney(debt ? item.currentPrice.sellMinor : item.currentPrice.buyMinor),
d(item.currentPrice.date),
)}
</p>
) : null}
{item.note ? <p className="text-sm text-muted-foreground">{item.note}</p> : null}
{item.payments.length ? (
<div>
<p className="mb-1 text-xs font-medium text-muted-foreground">{t.payments}</p>
<ul className="divide-y rounded-lg border text-sm">
{item.payments.map((x) => (
<li key={x.id} className="flex items-center justify-between gap-2 p-2">
<span className="min-w-0 truncate">
{d(x.date)} ·{" "}
{t.paymentLine(
t.methods[x.method],
formatAssetQuantity(item.assetKey, x.quantity),
x.amountMinor ? formatMoney(x.amountMinor) : null,
)}
</span>
{x.account ? (
<span className="shrink-0 text-xs text-muted-foreground">{x.account}</span>
) : null}
</li>
))}
</ul>
</div>
) : null}
{canWrite && open ? (
<div className="flex flex-wrap justify-end gap-2">
{debt && item.savingsAccount ? (
<Button
size="sm"
variant="outline"
onClick={() => onAction({ kind: "contribute", item })}
>
<PiggyBank />
{t.contribute}
</Button>
) : null}
<Button size="sm" onClick={() => onAction({ kind: "pay", item })}>
<HandCoins />
{t.pay}
</Button>
</div>
) : null}
</CardContent>
</Card>
);
}
function Footer({ busy, formId, onClose }: { busy: boolean; formId: string; onClose: () => void }) {
return (
<>
<Button variant="outline" type="button" onClick={onClose}>
{tr.common.cancel}
</Button>
<Button type="submit" form={formId} disabled={busy}>
{tr.common.save}
</Button>
</>
);
}
function InKindForm({
contactId,
item,
onClose,
}: {
contactId: string;
item: InKindView | null;
onClose: () => void;
}) {
const accounts = useAccounts();
const [v, setV] = useState({
direction: item?.direction ?? "debt",
assetKey: (item?.assetKey ?? "gram_gold") as AssetKey,
quantity: item ? qtyInput(item.quantity) : "",
date: item?.date ?? todayIstanbul(),
unitPrice: item?.unitPriceMinor ? formatMoneyInput(item.unitPriceMinor) : "",
note: item?.note ?? "",
savingsAccountId: item?.savingsAccount?.id ?? "",
monthlyPlan: item?.monthlyPlanMinor ? formatMoneyInput(item.monthlyPlanMinor) : "",
});
const [errors, setErrors] = useState<Errors>({});
const [busy, setBusy] = useState(false);
const set = <K extends keyof typeof v>(k: K, x: (typeof v)[K]) => setV((s) => ({ ...s, [k]: x }));
const NONE = "__none__";
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const r = await saveInKindAction(contactId, item?.id ?? null, {
...v,
savingsAccountId: v.direction === "debt" ? v.savingsAccountId : "",
monthlyPlan: v.direction === "debt" && v.savingsAccountId ? v.monthlyPlan : "",
});
setBusy(false);
if (r.ok) {
toast.success(t.toast.saved);
onClose();
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
}
const f = t.fields;
return (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={item ? t.editTitle : t.createTitle}
footer={<Footer busy={busy} formId="in-kind-form" onClose={onClose} />}
>
<form id="in-kind-form" onSubmit={submit} noValidate>
<FieldGroup>
<Tabs
value={v.direction}
onValueChange={(x) => set("direction", x as "debt" | "receivable")}
>
<TabsList className="w-full">
{(["debt", "receivable"] as const).map((x) => (
<TabsTrigger
key={x}
value={x}
className="flex-1"
disabled={Boolean(item?.payments.length)}
>
{t.directions[x]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="grid grid-cols-[1fr_8rem] gap-3">
<Field>
<FieldLabel htmlFor="ik-asset">{f.asset}</FieldLabel>
<Select
value={v.assetKey}
onValueChange={(x) => set("assetKey", x as AssetKey)}
disabled={Boolean(item?.payments.length)}
>
<SelectTrigger id="ik-asset" className="w-full">
<SelectValue>{ASSETS[v.assetKey].label}</SelectValue>
</SelectTrigger>
<SelectContent>
{ASSET_KEYS.map((k) => (
<SelectItem key={k} value={k}>
{ASSETS[k].label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Field data-invalid={Boolean(errors.quantity)}>
<FieldLabel htmlFor="ik-qty">
{f.quantity} ({ASSETS[v.assetKey].unit})
</FieldLabel>
<Input
id="ik-qty"
inputMode="decimal"
value={v.quantity}
onChange={(e) => set("quantity", e.target.value)}
className="text-right tabular-nums"
aria-invalid={Boolean(errors.quantity)}
/>
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.date)}>
<FieldLabel htmlFor="ik-date">{f.date}</FieldLabel>
<Input
id="ik-date"
type="date"
value={v.date}
onChange={(e) => set("date", e.target.value)}
/>
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
</Field>
<Field data-invalid={Boolean(errors.unitPrice)}>
<FieldLabel htmlFor="ik-price">{f.unitPrice}</FieldLabel>
<MoneyInput id="ik-price" value={v.unitPrice} onChange={(x) => set("unitPrice", x)} />
{errors.unitPrice ? <FieldError>{errors.unitPrice}</FieldError> : null}
</Field>
</div>
<FieldDescription className="-mt-2">{f.unitPriceHint}</FieldDescription>
{v.direction === "debt" ? (
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.savingsAccountId)}>
<FieldLabel htmlFor="ik-savings">{f.savingsAccount}</FieldLabel>
<Select
value={v.savingsAccountId || NONE}
onValueChange={(x) => set("savingsAccountId", x === NONE ? "" : x)}
>
<SelectTrigger id="ik-savings" className="w-full">
<SelectValue>
{accounts?.find((a) => a.id === v.savingsAccountId)?.name ??
(v.savingsAccountId ? item?.savingsAccount?.name : f.savingsNone)}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>{f.savingsNone}</SelectItem>
{(accounts ?? [])
.filter((a) => a.type !== "credit_card")
.map((a) => (
<SelectItem key={a.id} value={a.id}>
{a.name}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.savingsAccountId ? (
<FieldError>{errors.savingsAccountId}</FieldError>
) : null}
</Field>
<Field>
<FieldLabel htmlFor="ik-plan">{f.monthlyPlan}</FieldLabel>
<MoneyInput
id="ik-plan"
value={v.monthlyPlan}
onChange={(x) => set("monthlyPlan", x)}
disabled={!v.savingsAccountId}
/>
</Field>
</div>
) : null}
{v.direction === "debt" ? (
<FieldDescription className="-mt-2">{f.planHint}</FieldDescription>
) : null}
<Field>
<FieldLabel htmlFor="ik-note">{f.note}</FieldLabel>
<Input id="ik-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
</Field>
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
function ContributionForm({ item, onClose }: { item: InKindView; onClose: () => void }) {
const accounts = useAccounts();
const [v, setV] = useState({
fromAccountId: "",
amount: item.monthlyPlanMinor ? formatMoneyInput(item.monthlyPlanMinor) : "",
date: todayIstanbul(),
});
const [errors, setErrors] = useState<Errors>({});
const [busy, setBusy] = useState(false);
const sources = (accounts ?? []).filter((a) => a.id !== item.savingsAccount?.id);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const r = await addContributionAction(item.id, {
...v,
fromAccountId: v.fromAccountId || sources[0]?.id || "",
});
setBusy(false);
if (r.ok) {
toast.success(t.toast.contributed);
onClose();
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
}
return (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={`${t.contributionTitle} · ${item.label}`}
description={`→ ${item.savingsAccount?.name ?? ""}`}
footer={<Footer busy={busy} formId="contribution-form" onClose={onClose} />}
>
<form id="contribution-form" onSubmit={submit} noValidate>
<FieldGroup>
<Field data-invalid={Boolean(errors.fromAccountId)}>
<FieldLabel htmlFor="ct-from">{t.fromAccount}</FieldLabel>
<AccountSelect
id="ct-from"
value={v.fromAccountId || sources[0]?.id || ""}
onChange={(x) => setV((s) => ({ ...s, fromAccountId: x }))}
accounts={sources}
/>
{errors.fromAccountId ? <FieldError>{errors.fromAccountId}</FieldError> : null}
</Field>
<div className="grid grid-cols-2 gap-3">
<Field data-invalid={Boolean(errors.amount)}>
<FieldLabel htmlFor="ct-amount">{tr.transactions.fields.amount}</FieldLabel>
<MoneyInput
id="ct-amount"
value={v.amount}
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
/>
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
</Field>
<Field>
<FieldLabel htmlFor="ct-date">{tr.transactions.fields.date}</FieldLabel>
<Input
id="ct-date"
type="date"
value={v.date}
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
/>
</Field>
</div>
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}
function PaymentForm({ item, onClose }: { item: InKindView; onClose: () => void }) {
const accounts = useAccounts();
const debt = item.direction === "debt";
const [method, setMethod] = useState<"asset" | "cash">("cash");
const [v, setV] = useState({
date: todayIstanbul(),
quantity: qtyInput(item.progress.remainingQuantity),
amount: "",
accountId: item.savingsAccount?.id ?? "",
note: "",
});
const [errors, setErrors] = useState<Errors>({});
const [busy, setBusy] = useState(false);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
const r = await recordPaymentAction(item.id, {
method,
date: v.date,
quantity: method === "cash" ? "" : v.quantity,
amount: v.amount,
accountId: v.amount ? v.accountId || accounts?.[0]?.id || "" : "",
note: v.note,
});
setBusy(false);
if (r.ok) {
toast.success((r.data as { closed: boolean }).closed ? t.toast.closed : t.toast.paid);
onClose();
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
}
const f = t.payFields;
const hints = debt ? t.methodHints : t.receiveHints;
return (
<ResponsiveDialog
open
onOpenChange={(o) => !o && onClose()}
title={`${t.payTitle} · ${item.label}`}
description={`${t.stats.remaining}: ${formatAssetQuantity(item.assetKey, item.progress.remainingQuantity)}`}
footer={<Footer busy={busy} formId="payment-form" onClose={onClose} />}
>
<form id="payment-form" onSubmit={submit} noValidate>
<FieldGroup>
<Tabs value={method} onValueChange={(x) => setMethod(x as "asset" | "cash")}>
<TabsList className="w-full">
{(["cash", "asset"] as const).map((m) => (
<TabsTrigger key={m} value={m} className="flex-1">
{t.methods[m]}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<p className="-mt-2 text-xs text-muted-foreground">{hints[method]}</p>
<div className="grid grid-cols-2 gap-3">
{method === "asset" ? (
<Field data-invalid={Boolean(errors.quantity)}>
<FieldLabel htmlFor="pay-qty">
{f.quantity} ({ASSETS[item.assetKey].unit})
</FieldLabel>
<Input
id="pay-qty"
inputMode="decimal"
value={v.quantity}
onChange={(e) => setV((s) => ({ ...s, quantity: e.target.value }))}
className="text-right tabular-nums"
/>
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
</Field>
) : null}
<Field data-invalid={Boolean(errors.amount)}>
<FieldLabel htmlFor="pay-amount">
{method === "cash" ? f.amount : f.assetAmount}
</FieldLabel>
<MoneyInput
id="pay-amount"
value={v.amount}
onChange={(x) => setV((s) => ({ ...s, amount: x }))}
/>
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
</Field>
<Field>
<FieldLabel htmlFor="pay-date">{tr.transactions.fields.date}</FieldLabel>
<Input
id="pay-date"
type="date"
value={v.date}
max={todayIstanbul()}
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
/>
</Field>
</div>
{method === "cash" ? (
<FieldDescription className="-mt-2">{f.quantityHint}</FieldDescription>
) : null}
{method === "cash" || v.amount ? (
<Field data-invalid={Boolean(errors.accountId)}>
<FieldLabel htmlFor="pay-account">{f.account}</FieldLabel>
<AccountSelect
id="pay-account"
value={v.accountId || accounts?.[0]?.id || ""}
onChange={(x) => setV((s) => ({ ...s, accountId: x }))}
accounts={accounts ?? []}
invalid={Boolean(errors.accountId)}
/>
{errors.accountId ? <FieldError>{errors.accountId}</FieldError> : null}
</Field>
) : null}
<Field>
<FieldLabel htmlFor="pay-note">{t.fields.note}</FieldLabel>
<Input
id="pay-note"
value={v.note}
onChange={(e) => setV((s) => ({ ...s, note: e.target.value }))}
/>
</Field>
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
</FieldGroup>
</form>
</ResponsiveDialog>
);
}