a818c110cf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
714 lines
26 KiB
TypeScript
714 lines
26 KiB
TypeScript
"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). */
|
||
export 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>
|
||
);
|
||
}
|