mercury-web/src/features/assets/ManualAssetDetail.tsx

268 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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)" }} />;
}