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:
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, Plus } from "lucide-react";
|
||||
import { Fragment, 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 { 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 { tr } from "@/i18n/tr";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ASSET_KEYS, ASSETS, type AssetKey } from "@/shared/assets";
|
||||
import { todayIstanbul } from "@/shared/dates";
|
||||
import { formatDate, formatMoney } from "@/shared/format";
|
||||
import { formatMoneyInput } from "@/shared/money";
|
||||
import { savePriceAction } from "../actions";
|
||||
import type { PriceRow } from "../service";
|
||||
|
||||
const t = tr.prices;
|
||||
const d = (ymd: string) => formatDate(`${ymd}T12:00:00+03:00`);
|
||||
|
||||
export function PricesView({ rows, canWrite }: { rows: PriceRow[]; canWrite: boolean }) {
|
||||
const [editing, setEditing] = useState<AssetKey | null>(null);
|
||||
const [openRow, setOpenRow] = useState<AssetKey | null>(null);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{canWrite ? (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={() => setEditing("gram_gold")}>
|
||||
<Plus />
|
||||
{t.add}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-xs text-muted-foreground">
|
||||
<tr className="border-b">
|
||||
<th className="p-3 text-left font-normal">{t.asset}</th>
|
||||
<th className="p-3 text-right font-normal">{t.buy}</th>
|
||||
<th className="p-3 text-right font-normal">{t.sell}</th>
|
||||
<th className="p-3 text-right font-normal">{t.date}</th>
|
||||
<th className="w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y tabular-nums">
|
||||
{rows.map((r) => (
|
||||
<Fragment key={r.assetKey}>
|
||||
<tr>
|
||||
<td className="p-3">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-left font-medium disabled:cursor-default"
|
||||
onClick={() => setOpenRow(openRow === r.assetKey ? null : r.assetKey)}
|
||||
disabled={r.history.length < 2}
|
||||
aria-expanded={openRow === r.assetKey}
|
||||
>
|
||||
{r.history.length > 1 ? (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
openRow === r.assetKey && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
{ASSETS[r.assetKey].label}
|
||||
<span className="text-xs font-normal text-muted-foreground">
|
||||
{t.perUnit(ASSETS[r.assetKey].unit)}
|
||||
</span>
|
||||
</button>
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{r.latest ? formatMoney(r.latest.buyMinor) : "—"}
|
||||
</td>
|
||||
<td className="p-3 text-right">
|
||||
{r.latest ? formatMoney(r.latest.sellMinor) : "—"}
|
||||
</td>
|
||||
<td className="p-3 text-right text-muted-foreground">
|
||||
{r.latest ? d(r.latest.date) : t.noPrice}
|
||||
</td>
|
||||
<td className="p-1 text-right">
|
||||
{canWrite ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditing(r.assetKey)}
|
||||
aria-label={`${t.add}: ${ASSETS[r.assetKey].label}`}
|
||||
>
|
||||
<Plus />
|
||||
</Button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
{openRow === r.assetKey
|
||||
? r.history.slice(1).map((h) => (
|
||||
<tr key={h.date} className="bg-muted/30 text-muted-foreground">
|
||||
<td className="p-2 pl-9 text-xs">{t.history}</td>
|
||||
<td className="p-2 text-right">{formatMoney(h.buyMinor)}</td>
|
||||
<td className="p-2 text-right">{formatMoney(h.sellMinor)}</td>
|
||||
<td className="p-2 text-right">{d(h.date)}</td>
|
||||
<td />
|
||||
</tr>
|
||||
))
|
||||
: null}
|
||||
</Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{editing ? (
|
||||
<PriceForm
|
||||
key={editing}
|
||||
assetKey={editing}
|
||||
latest={rows.find((r) => r.assetKey === editing)?.latest ?? null}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriceForm({
|
||||
assetKey: initial,
|
||||
latest,
|
||||
onClose,
|
||||
}: {
|
||||
assetKey: AssetKey;
|
||||
latest: PriceRow["latest"];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [v, setV] = useState({
|
||||
assetKey: initial,
|
||||
date: todayIstanbul(),
|
||||
buy: latest ? formatMoneyInput(latest.buyMinor) : "",
|
||||
sell: latest ? formatMoneyInput(latest.sellMinor) : "",
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
const r = await savePriceAction(v);
|
||||
setBusy(false);
|
||||
if (r.ok) {
|
||||
toast.success(t.saved);
|
||||
onClose();
|
||||
} else setErrors({ ...r.fieldErrors, root: r.fieldErrors ? undefined : r.error });
|
||||
}
|
||||
|
||||
return (
|
||||
<ResponsiveDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={t.add}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="outline" type="button" onClick={onClose}>
|
||||
{tr.common.cancel}
|
||||
</Button>
|
||||
<Button type="submit" form="price-form" disabled={busy}>
|
||||
{tr.common.save}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="price-form" onSubmit={submit} noValidate>
|
||||
<FieldGroup>
|
||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pr-asset">{t.asset}</FieldLabel>
|
||||
<Select
|
||||
value={v.assetKey}
|
||||
onValueChange={(x) => setV((s) => ({ ...s, assetKey: x as AssetKey }))}
|
||||
>
|
||||
<SelectTrigger id="pr-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.date)}>
|
||||
<FieldLabel htmlFor="pr-date">{t.date}</FieldLabel>
|
||||
<Input
|
||||
id="pr-date"
|
||||
type="date"
|
||||
value={v.date}
|
||||
max={todayIstanbul()}
|
||||
onChange={(e) => setV((s) => ({ ...s, date: e.target.value }))}
|
||||
/>
|
||||
{errors.date ? <FieldError>{errors.date}</FieldError> : null}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field data-invalid={Boolean(errors.buy)}>
|
||||
<FieldLabel htmlFor="pr-buy">{t.buy}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pr-buy"
|
||||
value={v.buy}
|
||||
onChange={(x) => setV((s) => ({ ...s, buy: x }))}
|
||||
autoFocus
|
||||
/>
|
||||
{errors.buy ? <FieldError>{errors.buy}</FieldError> : null}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="pr-sell">{t.sell}</FieldLabel>
|
||||
<MoneyInput
|
||||
id="pr-sell"
|
||||
value={v.sell}
|
||||
onChange={(x) => setV((s) => ({ ...s, sell: x }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<FieldDescription className="-mt-2">{t.sellHint}</FieldDescription>
|
||||
{errors.root ? <FieldError>{errors.root}</FieldError> : null}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</ResponsiveDialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user