268 lines
10 KiB
TypeScript
268 lines
10 KiB
TypeScript
"use client";
|
||
|
||
import * as React from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
import { z } from "zod";
|
||
import { Skeleton } from "@seed-design/react";
|
||
import { apiGet } from "@/api/client";
|
||
import { useManualAssets } from "@/api/hooks/reads";
|
||
import { useManualAssetMutations } from "@/api/hooks/mutations";
|
||
import { AssetPointSchema, AssetListingSchema, type ManualAsset, type RevalueResult } from "@/api/schemas";
|
||
import { Card, MercuryButton, IconChip, type IconName } from "@/ds";
|
||
import { tugrikRaw, tugrikShortRaw } from "@/ds/money";
|
||
import { assetsStrings as s } from "./strings";
|
||
import { ValueHistoryChart } from "./ValueHistoryChart";
|
||
import { ConfirmDialog } from "./ConfirmDialog";
|
||
import { DetailHeader } from "./DetailHeader";
|
||
|
||
function assetChip(category: string): { icon: IconName; tint: string; fg: string } {
|
||
switch (category) {
|
||
case "car":
|
||
return { icon: "car", tint: "#DDE6F6", fg: "#2A4B9A" };
|
||
case "electronics":
|
||
return { icon: "monitor", tint: "#E1E5EA", fg: "#42505F" };
|
||
default:
|
||
return { icon: "layers", tint: "#EDEBE7", fg: "#6B6257" };
|
||
}
|
||
}
|
||
|
||
// --- Local reads (no hook exists yet for asset history/listings; these mirror
|
||
// the shape of reads.ts's other hooks and hit the same endpoints iOS uses:
|
||
// GET /manual-assets/{name}/history and /listings — see MercuryAPI.swift). ---
|
||
|
||
const HistoryResponseSchema = z.object({ history: z.array(AssetPointSchema) });
|
||
const ListingsResponseSchema = z.object({ listings: z.array(AssetListingSchema) });
|
||
|
||
function useAssetHistory(name: string) {
|
||
return useQuery({
|
||
queryKey: ["assetHistory", name],
|
||
queryFn: async () => (await apiGet(`/manual-assets/${encodeURIComponent(name)}/history`, HistoryResponseSchema)).history,
|
||
});
|
||
}
|
||
|
||
function useAssetListings(name: string) {
|
||
return useQuery({
|
||
queryKey: ["assetListings", name],
|
||
queryFn: async () => (await apiGet(`/manual-assets/${encodeURIComponent(name)}/listings`, ListingsResponseSchema)).listings,
|
||
});
|
||
}
|
||
|
||
const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
|
||
|
||
export interface ManualAssetDetailProps {
|
||
name: string;
|
||
}
|
||
|
||
export function ManualAssetDetail({ name }: ManualAssetDetailProps) {
|
||
const router = useRouter();
|
||
const manualAssets = useManualAssets();
|
||
const history = useAssetHistory(name);
|
||
const listings = useAssetListings(name);
|
||
const mutations = useManualAssetMutations();
|
||
|
||
const [confirmRevalue, setConfirmRevalue] = React.useState(false);
|
||
const [confirmDelete, setConfirmDelete] = React.useState(false);
|
||
const [lastFinding, setLastFinding] = React.useState<RevalueResult | null>(null);
|
||
const [revalueFailed, setRevalueFailed] = React.useState(false);
|
||
|
||
const asset: ManualAsset | undefined = (manualAssets.data ?? []).find((a) => a.name === name);
|
||
|
||
async function runRevalue() {
|
||
setConfirmRevalue(false);
|
||
setRevalueFailed(false);
|
||
const result = await mutations.revalue.mutateAsync(name);
|
||
setLastFinding(result ?? null);
|
||
setRevalueFailed(!result);
|
||
await Promise.all([history.refetch(), listings.refetch()]);
|
||
}
|
||
|
||
async function runDelete() {
|
||
setConfirmDelete(false);
|
||
await mutations.delete.mutateAsync(name);
|
||
router.push("/assets");
|
||
}
|
||
|
||
if (manualAssets.isLoading && !asset) {
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||
<Skeleton height="40px" />
|
||
<Skeleton height="160px" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!asset) {
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||
<DetailHeader title={name} />
|
||
<p style={mutedStyle}>{name}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const value = Number(asset.value || "0");
|
||
const acquired = Number(asset.acquiredValue || "0");
|
||
const change = Number(asset.change || "0");
|
||
const positive = change >= 0;
|
||
const percent = acquired > 0 ? (change / acquired) * 100 : null;
|
||
|
||
const points = (history.data ?? [])
|
||
.map((p) => ({ date: new Date(p.recordedAt), value: Number(p.value) }))
|
||
.filter((p) => !Number.isNaN(p.date.getTime()) && !Number.isNaN(p.value))
|
||
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||
|
||
const fetchPoints = (history.data ?? [])
|
||
.filter((p) => p.kind === "fetch")
|
||
.slice()
|
||
.sort((a, b) => (a.recordedAt < b.recordedAt ? 1 : -1));
|
||
|
||
const listingRows = listings.data ?? [];
|
||
const categoryLabel = s.manualAssets.categories[asset.category] ?? asset.category;
|
||
const conditionLabel = s.manualAssets.conditions[asset.condition] ?? asset.condition;
|
||
|
||
const chip = assetChip(asset.category);
|
||
|
||
return (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||
<DetailHeader title={asset.name} />
|
||
|
||
<Card style={{ textAlign: "center", padding: "24px 20px" }}>
|
||
<div style={{ display: "flex", justifyContent: "center", marginBottom: 12 }}>
|
||
<IconChip icon={chip.icon} tint={chip.tint} fg={chip.fg} size={48} />
|
||
</div>
|
||
<div style={{ fontSize: 12, ...mutedStyle }}>{categoryLabel} · {conditionLabel}</div>
|
||
<div style={{ fontSize: 32, fontWeight: 700, marginTop: 8 }}>{tugrikRaw(value)}</div>
|
||
<div
|
||
style={{
|
||
marginTop: 6,
|
||
fontWeight: 600,
|
||
color: positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
|
||
}}
|
||
>
|
||
{positive ? "+" : "−"}
|
||
{tugrikRaw(Math.abs(change))}
|
||
{percent !== null && ` (${positive ? "+" : "−"}${Math.abs(percent).toFixed(1)}%)`}
|
||
</div>
|
||
</Card>
|
||
|
||
{lastFinding && (
|
||
<Card style={{ background: "color-mix(in srgb, var(--seed-color-fg-positive) 10%, transparent)" }}>
|
||
<div style={{ fontWeight: 600 }}>
|
||
{lastFinding.source} — {lastFinding.count} {s.assetDetail.findingSuffix}
|
||
</div>
|
||
<div style={{ fontSize: 13, marginTop: 4 }}>
|
||
{tugrikShortRaw(lastFinding.low)}–{tugrikShortRaw(lastFinding.high)} · {s.assetDetail.avg}{" "}
|
||
<strong>{tugrikShortRaw(lastFinding.value)}</strong>
|
||
</div>
|
||
</Card>
|
||
)}
|
||
{revalueFailed && !lastFinding && (
|
||
<Card>
|
||
<div style={{ fontWeight: 600 }}>{s.assetDetail.notFound}</div>
|
||
</Card>
|
||
)}
|
||
|
||
<Card>
|
||
<h3 style={{ margin: "0 0 12px", fontSize: 14, fontWeight: 700 }}>{s.assetDetail.chartTitle}</h3>
|
||
{history.isLoading ? <Skeleton height="160px" /> : <ValueHistoryChart points={points} positive={positive} />}
|
||
</Card>
|
||
|
||
{!history.isLoading && (
|
||
<Card>
|
||
<h3 style={{ margin: "0 0 12px", fontSize: 14, fontWeight: 700 }}>{s.assetDetail.research}</h3>
|
||
{fetchPoints.length > 0 && (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 12 }}>
|
||
{fetchPoints.map((p, i) => (
|
||
<div key={i} style={{ display: "flex", justifyContent: "space-between", fontSize: 13 }}>
|
||
<span>{p.recordedAt.slice(0, 10)}</span>
|
||
<span style={{ ...mutedStyle }}>
|
||
{p.low && p.high ? `${tugrikShortRaw(p.low)}–${tugrikShortRaw(p.high)} · ` : ""}
|
||
{p.count ?? 0} зар
|
||
</span>
|
||
<strong>{tugrikShortRaw(p.value)}</strong>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
<h4 style={{ margin: "0 0 8px", fontSize: 13, fontWeight: 700 }}>{s.assetDetail.listings(listingRows.length)}</h4>
|
||
{listingRows.length === 0 ? (
|
||
<p style={{ fontSize: 12, ...mutedStyle, margin: 0 }}>{s.assetDetail.listingsEmpty}</p>
|
||
) : (
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||
{listingRows.slice(0, 15).map((l, i) => (
|
||
<a
|
||
key={i}
|
||
href={l.url}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
style={{ display: "flex", justifyContent: "space-between", gap: 10, color: "inherit", textDecoration: "none" }}
|
||
>
|
||
<span style={{ fontSize: 13, flex: 1 }}>{l.title}</span>
|
||
<strong style={{ fontSize: 13, whiteSpace: "nowrap" }}>{tugrikShortRaw(l.price)}</strong>
|
||
</a>
|
||
))}
|
||
</div>
|
||
)}
|
||
</Card>
|
||
)}
|
||
|
||
<Card style={{ padding: "6px 20px" }}>
|
||
<DetailRow label={s.assetDetail.acquiredValue} value={tugrikRaw(acquired)} />
|
||
<Divider />
|
||
<DetailRow label={s.assetDetail.currentValue} value={tugrikRaw(value)} />
|
||
<Divider />
|
||
<DetailRow
|
||
label={s.assetDetail.change}
|
||
value={`${positive ? "+" : "−"}${tugrikRaw(Math.abs(change))}`}
|
||
color={positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)"}
|
||
/>
|
||
<Divider />
|
||
<DetailRow label={s.assetDetail.category} value={categoryLabel} />
|
||
<Divider />
|
||
<DetailRow label={s.assetDetail.condition} value={conditionLabel} />
|
||
</Card>
|
||
|
||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||
<MercuryButton variant="primary" onClick={() => setConfirmRevalue(true)} disabled={mutations.revalue.isPending}>
|
||
{s.manualAssets.revalue}
|
||
</MercuryButton>
|
||
<MercuryButton variant="ghost" onClick={() => setConfirmDelete(true)} style={{ color: "var(--seed-color-fg-critical)" }}>
|
||
{s.manualAssets.delete}
|
||
</MercuryButton>
|
||
</div>
|
||
|
||
<ConfirmDialog
|
||
open={confirmRevalue}
|
||
title={s.manualAssets.revalueTitle}
|
||
description={s.manualAssets.revalueDescription}
|
||
confirmLabel={s.manualAssets.revalue}
|
||
destructive={false}
|
||
busy={mutations.revalue.isPending}
|
||
onCancel={() => setConfirmRevalue(false)}
|
||
onConfirm={runRevalue}
|
||
/>
|
||
<ConfirmDialog
|
||
open={confirmDelete}
|
||
title={s.manualAssets.deleteTitle}
|
||
description={s.manualAssets.deleteDescription(asset.name)}
|
||
busy={mutations.delete.isPending}
|
||
onCancel={() => setConfirmDelete(false)}
|
||
onConfirm={runDelete}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function DetailRow({ label, value, color }: { label: string; value: string; color?: string }) {
|
||
return (
|
||
<div style={{ display: "flex", justifyContent: "space-between", padding: "14px 0" }}>
|
||
<span style={mutedStyle}>{label}</span>
|
||
<span style={{ fontWeight: 600, color }}>{value}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Divider() {
|
||
return <div style={{ height: 1, background: "var(--seed-color-border-neutral, #e5e5e5)" }} />;
|
||
}
|