"use client"; import * as React from "react"; import { Check, RotateCcw } from "lucide-react"; import { Button } from "@/components/ui"; import { api } from "@/lib/api"; import { toast } from "@/lib/store"; import { cn } from "@/lib/utils"; import type { ProgressionPatch } from "./types"; import { DenseInput, Pill, Toggle } from "./primitives"; import { describeError, int } from "./format"; export interface ProgressionItem { key: string; name: string; description: string; target: number; rewardCredits: number; rewardXp: number; enabled: boolean; metric: string; /** Extra chip shown next to the key (period / category). */ tag?: string; /** Right-hand statistics columns. */ stats: { label: string; value: React.ReactNode }[]; } type Draft = Partial>; /** Inline-editable table shared by Missions and Achievements. Each row saves through PATCH `${endpoint}/${key}`. */ export function ProgressionTable({ items, endpoint, onSaved, statHeaders }: { items: ProgressionItem[]; endpoint: string; onSaved: () => void; statHeaders: string[] }) { const [drafts, setDrafts] = React.useState>({}); const [busy, setBusy] = React.useState(null); function edit(key: string, patch: Draft) { setDrafts((d) => ({ ...d, [key]: { ...d[key], ...patch } })); } function reset(key: string) { setDrafts((d) => { const n = { ...d }; delete n[key]; return n; }); } function validate(item: ProgressionItem, draft: Draft): string | null { const name = draft.name ?? item.name; const desc = draft.description ?? item.description; if (name.trim().length < 2) return "Name must be at least 2 characters."; if (desc.trim().length < 2) return "Description must be at least 2 characters."; const t = draft.target ?? item.target; if (!Number.isInteger(t) || t < 1) return "Target must be a positive integer."; const rc = draft.rewardCredits ?? item.rewardCredits; const rx = draft.rewardXp ?? item.rewardXp; if (!Number.isInteger(rc) || rc < 0 || !Number.isInteger(rx) || rx < 0) return "Rewards must be non-negative integers."; return null; } async function save(item: ProgressionItem) { const draft = drafts[item.key]; if (!draft) return; const err = validate(item, draft); if (err) { toast({ title: "Invalid values", description: err, tone: "danger" }); return; } const patch: ProgressionPatch = {}; if (draft.name !== undefined && draft.name !== item.name) patch.name = draft.name.trim(); if (draft.description !== undefined && draft.description !== item.description) patch.description = draft.description.trim(); if (draft.target !== undefined && draft.target !== item.target) patch.target = draft.target; if (draft.rewardCredits !== undefined && draft.rewardCredits !== item.rewardCredits) patch.rewardCredits = draft.rewardCredits; if (draft.rewardXp !== undefined && draft.rewardXp !== item.rewardXp) patch.rewardXp = draft.rewardXp; if (Object.keys(patch).length === 0) { reset(item.key); return; } setBusy(item.key); try { await api(`${endpoint}/${encodeURIComponent(item.key)}`, { method: "PATCH", json: patch }); toast({ title: `${item.name} saved`, tone: "success" }); reset(item.key); onSaved(); } catch (e) { toast({ title: "Save failed", description: describeError(e), tone: "danger" }); } finally { setBusy(null); } } async function toggleEnabled(item: ProgressionItem, enabled: boolean) { setBusy(item.key); try { await api(`${endpoint}/${encodeURIComponent(item.key)}`, { method: "PATCH", json: { enabled } }); onSaved(); } catch (e) { toast({ title: "Update failed", description: describeError(e), tone: "danger" }); } finally { setBusy(null); } } const numInput = (item: ProgressionItem, field: "target" | "rewardCredits" | "rewardXp", width: string) => { const draft = drafts[item.key]; const v = draft?.[field] ?? item[field]; return edit(item.key, { [field]: Number(e.target.value.replace(/\D/g, "") || 0) })} aria-label={`${field} of ${item.name}`} />; }; return (
{statHeaders.map((h) => ( ))} {items.map((item) => { const draft = drafts[item.key]; const dirty = !!draft; return ( {item.stats.map((s) => ( ))} ); })}
On Key Name & description Target Reward SC Reward XP {h}
void toggleEnabled(item, v)} label={`Enable ${item.name}`} />
{item.key}
{item.tag ? {item.tag} : null} {item.metric}
edit(item.key, { name: e.target.value })} aria-label={`Name of ${item.key}`} /> edit(item.key, { description: e.target.value })} aria-label={`Description of ${item.key}`} /> {numInput(item, "target", "w-24")} {numInput(item, "rewardCredits", "w-24")} {numInput(item, "rewardXp", "w-20")} {s.value} {dirty ? (
) : ( {int(item.target)} target )}
); }