3cd8d98324
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
236 lines
8.5 KiB
TypeScript
236 lines
8.5 KiB
TypeScript
"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>
|
|
);
|
|
}
|