feat(ui): birikim sayfası, varlık detayı ve hareket diyalogları

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 23:17:44 +03:00
parent 890ba9f521
commit a818c110cf
8 changed files with 877 additions and 4 deletions
@@ -0,0 +1,315 @@
"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>
);
}