import { useEffect, useRef, useState } from "react"; import { ChevronLeft, ChevronRight, Download, Save, Trash2, X } from "lucide-react"; import { api } from "./api"; import { labels, type Context, type Dataset, type Metric, type Preferences, type Session, } from "./types"; type User = NonNullable; type FormulaExpression = | { metric: Metric } | { number: number } | { operator: "add" | "subtract" | "multiply" | "divide"; left: FormulaExpression; right: FormulaExpression; }; type SavedFormulaDefinition = { id: string; definition: Record; updated_at: string; }; function describeExpression(expression: FormulaExpression): string { if ("metric" in expression) return labels[expression.metric]; if ("number" in expression) return String(expression.number); const operators = { add: "+", subtract: "−", multiply: "×", divide: "÷" }; return `(${describeExpression(expression.left)} ${operators[expression.operator]} ${describeExpression(expression.right)})`; } export function Modal({ title, onClose, wide = false, children, }: { title: string; onClose: () => void; wide?: boolean; children: React.ReactNode; }) { const ref = useRef(null); useEffect(() => { const dialog = ref.current; if (!dialog) return; dialog.showModal(); return () => dialog.close(); }, []); return (

{title}

{children}
); } export function ImportDialog({ ds, onClose, onDone, }: { ds: Dataset; onClose: () => void; onDone: () => void; }) { const [mapping, setMapping] = useState(ds.mapping), [error, setError] = useState(""), [busy, setBusy] = useState(false); async function submit() { setBusy(true); setError(""); try { await api(`/datasets/${ds.id}/mapping`, "POST", mapping); onDone(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } } function update( index: number, patch: Partial<(typeof mapping.columns)[number]>, ) { setMapping({ ...mapping, columns: mapping.columns.map((c) => c.index === index ? { ...c, ...patch } : c, ), }); } return (

{ds.name} · 检查识别字段及财务口径后确认导入。

{mapping.columns.map((c) => ( ))}
源字段 角色 维度键 说明
{c.name} {c.role === "dimension" && ( update(c.index, { key: e.target.value }) } /> )} update(c.index, { description: e.target.value }) } />
{error && (

{error}

)}
导入后该版本映射将锁定。
); } export function SettingsDialog({ ds, onClose, onSave, }: { ds: Dataset; onClose: () => void; onSave: (value: Preferences, revision: number) => void; }) { const [value, setValue] = useState(ds.preferences), [revision, setRevision] = useState(ds.revision || 0), [error, setError] = useState(""), [busy, setBusy] = useState(false); useEffect(() => { api<{ value: Preferences; revision: number }>(`/datasets/${ds.id}/settings`) .then((r) => { setValue(r.value); setRevision(r.revision); }) .catch((e) => setError(e.message)); }, [ds.id]); async function save() { setBusy(true); setError(""); try { const result = await api<{ value: Preferences; revision: number }>( `/datasets/${ds.id}/settings`, "PUT", { value, revision }, ); onSave(result.value, result.revision); onClose(); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } } return (
{error && (

{error}

)}
); } export function EvidenceDialog({ ds, ctx, user, onClose, }: { ds: Dataset; ctx: Context; user: NonNullable; onClose: () => void; }) { const pageSize = 50; const [result, setResult] = useState<{ rows: Record[]; total: number; sheet: string; }>(), [side, setSide] = useState<"current" | "baseline">("current"), [page, setPage] = useState(0), [error, setError] = useState(""); useEffect(() => { let active = true; setResult(undefined); setError(""); api<{ rows: Record[]; total: number; sheet: string }>( `/datasets/${ds.id}/evidence`, "POST", { ...ctx, side, offset: page * pageSize, limit: pageSize }, ) .then((value) => active && setResult(value)) .catch((e) => active && setError(e.message)); return () => { active = false; }; }, [ds.id, ctx, side, page]); function selectSide(value: "current" | "baseline") { setResult(undefined); setPage(0); setSide(value); } function selectPage(value: number) { setResult(undefined); setPage(value); } const first = result?.total ? page * pageSize + 1 : 0; const last = result ? Math.min((page + 1) * pageSize, result.total) : 0; return (

来源工作表:{result?.sheet || "加载中"} · {result?.total ?? 0} 条

{user.role !== "admin" &&

客户原始标识不包含在明细中。

} {error && (

{error}

)}
{ds.dimensions.map((d) => ( ))} {result?.rows.map((r, i) => ( {ds.dimensions.map((d) => ( ))} ))}
来源行 期间{d.name}收入 成本 毛利
{String(r.source_row)} {String(r.period)} {String( (r.dimensions as Record)[d.key] || "", )} {String(r.revenue)} {String(r.cost ?? "")} {String(r.gross ?? "")}
{result ? `第 ${first}-${last} 条,共 ${result.total} 条` : error ? "加载失败" : "加载中"}
); } export function Copilot({ ds, ctx, ai, onClose, onApply, }: { ds: Dataset; ctx: Context; ai: boolean; onClose: () => void; onApply: (reply: { context: Context; intent: { action: string } }) => void; }) { const [question, setQuestion] = useState(""), [consent, setConsent] = useState(false), [useModel, setUseModel] = useState(false), [answer, setAnswer] = useState(""), [error, setError] = useState(""), [busy, setBusy] = useState(false); async function submit(event: React.FormEvent) { event.preventDefault(); setBusy(true); setError(""); try { const r = await api<{ answer: string; context: Context; intent: { action: string }; }>(`/datasets/${ds.id}/assistant`, "POST", { question, context: ctx, use_model: useModel, consent, }); setAnswer(r.answer); onApply(r); } catch (e) { setError((e as Error).message); } finally { setBusy(false); } } return (