a818c110cf
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
316 lines
11 KiB
TypeScript
316 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { 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 { useAccounts } from "@/features/in-kind/components/in-kind-section";
|
||
import { tr } from "@/i18n/tr";
|
||
import { ASSET_KEYS, ASSETS, formatAssetQuantity, type AssetKey } from "@/shared/assets";
|
||
import { todayIstanbul } from "@/shared/dates";
|
||
import { MOVEMENT_KINDS, type MovementKind } from "@/shared/portfolio";
|
||
import { addMovementAction, saveAssetAction } from "../actions";
|
||
import type { AssetView } from "../service";
|
||
|
||
const t = tr.assets;
|
||
type Errors = Record<string, string | undefined>;
|
||
const NONE = "__none__";
|
||
|
||
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>
|
||
</>
|
||
);
|
||
}
|
||
|
||
/** Yeni varlık / varlığı düzenle. */
|
||
export function AssetFormDialog({
|
||
item,
|
||
onClose,
|
||
onCreated,
|
||
}: {
|
||
item: AssetView | null;
|
||
onClose: () => void;
|
||
onCreated?: (id: string) => void;
|
||
}) {
|
||
const [v, setV] = useState({
|
||
assetKey: (item?.assetKey ?? "gram_gold") as AssetKey,
|
||
name: item?.name ?? "",
|
||
note: item?.note ?? "",
|
||
isPrivate: item?.isPrivate ?? false,
|
||
});
|
||
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 locked = Boolean(item?.movementCount);
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
const r = await saveAssetAction(item?.id ?? null, {
|
||
...v,
|
||
name: v.name.trim() || ASSETS[v.assetKey].label,
|
||
});
|
||
setBusy(false);
|
||
if (r.ok) {
|
||
toast.success(t.toast.saved);
|
||
onClose();
|
||
if (!item) onCreated?.((r.data as { id: string }).id);
|
||
} 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="asset-form" onClose={onClose} />}
|
||
>
|
||
<form id="asset-form" onSubmit={submit} noValidate>
|
||
<FieldGroup>
|
||
<Field data-invalid={Boolean(errors.assetKey)}>
|
||
<FieldLabel htmlFor="as-key">{f.asset}</FieldLabel>
|
||
<Select
|
||
value={v.assetKey}
|
||
onValueChange={(x) => set("assetKey", x as AssetKey)}
|
||
disabled={locked}
|
||
>
|
||
<SelectTrigger id="as-key" 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>
|
||
{errors.assetKey ? <FieldError>{errors.assetKey}</FieldError> : null}
|
||
</Field>
|
||
<Field data-invalid={Boolean(errors.name)}>
|
||
<FieldLabel htmlFor="as-name">{f.name}</FieldLabel>
|
||
<Input
|
||
id="as-name"
|
||
value={v.name}
|
||
placeholder={ASSETS[v.assetKey].label}
|
||
onChange={(e) => set("name", e.target.value)}
|
||
aria-invalid={Boolean(errors.name)}
|
||
/>
|
||
{errors.name ? <FieldError>{errors.name}</FieldError> : null}
|
||
</Field>
|
||
<Field>
|
||
<FieldLabel htmlFor="as-note">{f.note}</FieldLabel>
|
||
<Input id="as-note" 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"
|
||
/>
|
||
<span>
|
||
{f.isPrivate}
|
||
<span className="block text-xs text-muted-foreground">{f.isPrivateHint}</span>
|
||
</span>
|
||
</label>
|
||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||
</FieldGroup>
|
||
</form>
|
||
</ResponsiveDialog>
|
||
);
|
||
}
|
||
|
||
/** Alım, gelen hediye, satış veya çıkış hareketi. */
|
||
export function MovementDialog({
|
||
item,
|
||
initialKind,
|
||
onClose,
|
||
}: {
|
||
item: AssetView;
|
||
initialKind: MovementKind;
|
||
onClose: () => void;
|
||
}) {
|
||
const accounts = useAccounts();
|
||
const [kind, setKind] = useState<MovementKind>(initialKind);
|
||
const [v, setV] = useState({
|
||
date: todayIstanbul(),
|
||
quantity: "",
|
||
amount: "",
|
||
unitPrice: "",
|
||
accountId: "",
|
||
storage: item.storage,
|
||
note: "",
|
||
});
|
||
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 withAccount = kind === "buy" || kind === "sell";
|
||
const priced = kind !== "gift_out";
|
||
const inflow = kind === "buy" || kind === "gift_in";
|
||
|
||
async function submit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setBusy(true);
|
||
const r = await addMovementAction(item.id, {
|
||
kind,
|
||
date: v.date,
|
||
quantity: v.quantity,
|
||
amount: priced ? v.amount : "",
|
||
unitPrice: priced ? v.unitPrice : "",
|
||
accountId: withAccount ? v.accountId : "",
|
||
storage: inflow ? v.storage : "",
|
||
note: v.note,
|
||
});
|
||
setBusy(false);
|
||
if (r.ok) {
|
||
toast.success(t.toast.movement);
|
||
onClose();
|
||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||
}
|
||
|
||
const m = t.movement;
|
||
const unit = ASSETS[item.assetKey].unit;
|
||
return (
|
||
<ResponsiveDialog
|
||
open
|
||
onOpenChange={(o) => !o && onClose()}
|
||
title={m.title(t.kinds[kind], item.name)}
|
||
description={m.held(formatAssetQuantity(item.assetKey, item.summary.quantity))}
|
||
footer={<Footer busy={busy} formId="movement-form" onClose={onClose} />}
|
||
>
|
||
<form id="movement-form" onSubmit={submit} noValidate>
|
||
<FieldGroup>
|
||
<Tabs
|
||
value={kind}
|
||
onValueChange={(x) => {
|
||
setKind(x as MovementKind);
|
||
setErrors({});
|
||
}}
|
||
>
|
||
<TabsList className="grid h-auto w-full grid-cols-2 sm:grid-cols-4">
|
||
{MOVEMENT_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-2 gap-3">
|
||
<Field data-invalid={Boolean(errors.quantity)}>
|
||
<FieldLabel htmlFor="mv-qty">
|
||
{m.quantity} ({unit})
|
||
</FieldLabel>
|
||
<Input
|
||
id="mv-qty"
|
||
inputMode="decimal"
|
||
value={v.quantity}
|
||
onChange={(e) => set("quantity", e.target.value)}
|
||
className="text-right tabular-nums"
|
||
aria-invalid={Boolean(errors.quantity)}
|
||
autoFocus
|
||
/>
|
||
{errors.quantity ? <FieldError>{errors.quantity}</FieldError> : null}
|
||
</Field>
|
||
<Field data-invalid={Boolean(errors.date)}>
|
||
<FieldLabel htmlFor="mv-date">{m.date}</FieldLabel>
|
||
<Input
|
||
id="mv-date"
|
||
type="date"
|
||
value={v.date}
|
||
max={todayIstanbul()}
|
||
onChange={(e) => set("date", e.target.value)}
|
||
/>
|
||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||
</Field>
|
||
</div>
|
||
{priced ? (
|
||
<>
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<Field data-invalid={Boolean(errors.amount)}>
|
||
<FieldLabel htmlFor="mv-amount">{m.amount}</FieldLabel>
|
||
<MoneyInput id="mv-amount" value={v.amount} onChange={(x) => set("amount", x)} />
|
||
{errors.amount ? <FieldError>{errors.amount}</FieldError> : null}
|
||
</Field>
|
||
<Field data-invalid={Boolean(errors.unitPrice)}>
|
||
<FieldLabel htmlFor="mv-unit">{m.unitPrice}</FieldLabel>
|
||
<MoneyInput
|
||
id="mv-unit"
|
||
value={v.unitPrice}
|
||
onChange={(x) => set("unitPrice", x)}
|
||
/>
|
||
{errors.unitPrice ? <FieldError>{errors.unitPrice}</FieldError> : null}
|
||
</Field>
|
||
</div>
|
||
<FieldDescription className="-mt-2">
|
||
{kind === "gift_in" ? m.giftValueHint : m.priceHint}
|
||
</FieldDescription>
|
||
</>
|
||
) : null}
|
||
{withAccount ? (
|
||
<Field data-invalid={Boolean(errors.accountId)}>
|
||
<FieldLabel htmlFor="mv-account">{m.account}</FieldLabel>
|
||
<Select
|
||
value={v.accountId || NONE}
|
||
onValueChange={(x) => set("accountId", x === NONE ? "" : x)}
|
||
>
|
||
<SelectTrigger id="mv-account" className="w-full">
|
||
<SelectValue>
|
||
{accounts?.find((a) => a.id === v.accountId)?.name ?? m.accountNone}
|
||
</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent className="max-h-72">
|
||
<SelectItem value={NONE}>{m.accountNone}</SelectItem>
|
||
{(accounts ?? []).map((a) => (
|
||
<SelectItem key={a.id} value={a.id}>
|
||
{a.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<FieldDescription>{m.accountHint}</FieldDescription>
|
||
{errors.accountId ? <FieldError>{errors.accountId}</FieldError> : null}
|
||
</Field>
|
||
) : null}
|
||
{inflow ? (
|
||
<Field>
|
||
<FieldLabel htmlFor="mv-storage">{m.storage}</FieldLabel>
|
||
<Input
|
||
id="mv-storage"
|
||
value={v.storage}
|
||
placeholder={m.storagePlaceholder}
|
||
onChange={(e) => set("storage", e.target.value)}
|
||
/>
|
||
</Field>
|
||
) : null}
|
||
<Field>
|
||
<FieldLabel htmlFor="mv-note">{m.note}</FieldLabel>
|
||
<Input id="mv-note" value={v.note} onChange={(e) => set("note", e.target.value)} />
|
||
</Field>
|
||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||
</FieldGroup>
|
||
</form>
|
||
</ResponsiveDialog>
|
||
);
|
||
}
|