TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import { Check, RotateCcw } from "lucide-react";5import { Button } from "@/components/ui";6import { api } from "@/lib/api";7import { toast } from "@/lib/store";8import { cn } from "@/lib/utils";9import type { ProgressionPatch } from "./types";10import { DenseInput, Pill, Toggle } from "./primitives";11import { describeError, int } from "./format";1213export interface ProgressionItem {14 key: string;15 name: string;16 description: string;17 target: number;18 rewardCredits: number;19 rewardXp: number;20 enabled: boolean;21 metric: string;22 /** Extra chip shown next to the key (period / category). */23 tag?: string;24 /** Right-hand statistics columns. */25 stats: { label: string; value: React.ReactNode }[];26}2728type Draft = Partial<Pick<ProgressionItem, "name" | "description" | "target" | "rewardCredits" | "rewardXp">>;2930/** Inline-editable table shared by Missions and Achievements. Each row saves through PATCH `${endpoint}/${key}`. */31export function ProgressionTable({ items, endpoint, onSaved, statHeaders }: { items: ProgressionItem[]; endpoint: string; onSaved: () => void; statHeaders: string[] }) {32 const [drafts, setDrafts] = React.useState<Record<string, Draft>>({});33 const [busy, setBusy] = React.useState<string | null>(null);3435 function edit(key: string, patch: Draft) {36 setDrafts((d) => ({ ...d, [key]: { ...d[key], ...patch } }));37 }38 function reset(key: string) {39 setDrafts((d) => {40 const n = { ...d };41 delete n[key];42 return n;43 });44 }4546 function validate(item: ProgressionItem, draft: Draft): string | null {47 const name = draft.name ?? item.name;48 const desc = draft.description ?? item.description;49 if (name.trim().length < 2) return "Name must be at least 2 characters.";50 if (desc.trim().length < 2) return "Description must be at least 2 characters.";51 const t = draft.target ?? item.target;52 if (!Number.isInteger(t) || t < 1) return "Target must be a positive integer.";53 const rc = draft.rewardCredits ?? item.rewardCredits;54 const rx = draft.rewardXp ?? item.rewardXp;55 if (!Number.isInteger(rc) || rc < 0 || !Number.isInteger(rx) || rx < 0) return "Rewards must be non-negative integers.";56 return null;57 }5859 async function save(item: ProgressionItem) {60 const draft = drafts[item.key];61 if (!draft) return;62 const err = validate(item, draft);63 if (err) {64 toast({ title: "Invalid values", description: err, tone: "danger" });65 return;66 }67 const patch: ProgressionPatch = {};68 if (draft.name !== undefined && draft.name !== item.name) patch.name = draft.name.trim();69 if (draft.description !== undefined && draft.description !== item.description) patch.description = draft.description.trim();70 if (draft.target !== undefined && draft.target !== item.target) patch.target = draft.target;71 if (draft.rewardCredits !== undefined && draft.rewardCredits !== item.rewardCredits) patch.rewardCredits = draft.rewardCredits;72 if (draft.rewardXp !== undefined && draft.rewardXp !== item.rewardXp) patch.rewardXp = draft.rewardXp;73 if (Object.keys(patch).length === 0) {74 reset(item.key);75 return;76 }77 setBusy(item.key);78 try {79 await api(`${endpoint}/${encodeURIComponent(item.key)}`, { method: "PATCH", json: patch });80 toast({ title: `${item.name} saved`, tone: "success" });81 reset(item.key);82 onSaved();83 } catch (e) {84 toast({ title: "Save failed", description: describeError(e), tone: "danger" });85 } finally {86 setBusy(null);87 }88 }8990 async function toggleEnabled(item: ProgressionItem, enabled: boolean) {91 setBusy(item.key);92 try {93 await api(`${endpoint}/${encodeURIComponent(item.key)}`, { method: "PATCH", json: { enabled } });94 onSaved();95 } catch (e) {96 toast({ title: "Update failed", description: describeError(e), tone: "danger" });97 } finally {98 setBusy(null);99 }100 }101102 const numInput = (item: ProgressionItem, field: "target" | "rewardCredits" | "rewardXp", width: string) => {103 const draft = drafts[item.key];104 const v = draft?.[field] ?? item[field];105 return <DenseInput inputMode="numeric" className={cn(width, "text-right font-mono", draft?.[field] !== undefined && draft[field] !== item[field] && "border-accent/60")} value={String(v)} onChange={(e) => edit(item.key, { [field]: Number(e.target.value.replace(/\D/g, "") || 0) })} aria-label={`${field} of ${item.name}`} />;106 };107108 return (109 <div className="overflow-x-auto">110 <table className="w-full min-w-[980px] border-collapse text-[13px]">111 <thead>112 <tr className="border-b border-line text-left text-[11px] font-semibold uppercase tracking-wider text-fg-3">113 <th className="px-3 py-2">On</th>114 <th className="px-3 py-2">Key</th>115 <th className="px-3 py-2">Name & description</th>116 <th className="px-3 py-2 text-right">Target</th>117 <th className="px-3 py-2 text-right">Reward SC</th>118 <th className="px-3 py-2 text-right">Reward XP</th>119 {statHeaders.map((h) => (120 <th key={h} className="px-3 py-2 text-right">121 {h}122 </th>123 ))}124 <th className="px-3 py-2" />125 </tr>126 </thead>127 <tbody>128 {items.map((item) => {129 const draft = drafts[item.key];130 const dirty = !!draft;131 return (132 <tr key={item.key} className={cn("border-b border-line/60 align-top last:border-0", !item.enabled && "opacity-60", dirty && "bg-accent-soft/30")}>133 <td className="px-3 py-2.5">134 <Toggle checked={item.enabled} disabled={busy === item.key} onChange={(v) => void toggleEnabled(item, v)} label={`Enable ${item.name}`} />135 </td>136 <td className="px-3 py-2.5">137 <div className="font-mono text-[12px] text-fg-2">{item.key}</div>138 <div className="mt-1 flex flex-wrap gap-1">139 {item.tag ? <Pill tone={item.tag === "weekly" ? "info" : "muted"}>{item.tag}</Pill> : null}140 <Pill tone="muted">{item.metric}</Pill>141 </div>142 </td>143 <td className="px-3 py-2.5">144 <DenseInput className={cn("mb-1.5 h-8 font-medium", draft?.name !== undefined && draft.name !== item.name && "border-accent/60")} value={draft?.name ?? item.name} onChange={(e) => edit(item.key, { name: e.target.value })} aria-label={`Name of ${item.key}`} />145 <DenseInput className={cn("h-8 text-fg-2", draft?.description !== undefined && draft.description !== item.description && "border-accent/60")} value={draft?.description ?? item.description} onChange={(e) => edit(item.key, { description: e.target.value })} aria-label={`Description of ${item.key}`} />146 </td>147 <td className="px-3 py-2.5 text-right">{numInput(item, "target", "w-24")}</td>148 <td className="px-3 py-2.5 text-right">{numInput(item, "rewardCredits", "w-24")}</td>149 <td className="px-3 py-2.5 text-right">{numInput(item, "rewardXp", "w-20")}</td>150 {item.stats.map((s) => (151 <td key={s.label} className="px-3 py-2.5 text-right tabular text-fg-2">152 {s.value}153 </td>154 ))}155 <td className="px-3 py-2.5 text-right">156 {dirty ? (157 <div className="inline-flex gap-1">158 <Button size="sm" variant="accent" onClick={() => void save(item)} loading={busy === item.key} aria-label="Save">159 <Check className="h-3.5 w-3.5" /> Save160 </Button>161 <Button size="sm" variant="ghost" onClick={() => reset(item.key)} disabled={busy === item.key} aria-label="Discard changes">162 <RotateCcw className="h-3.5 w-3.5" />163 </Button>164 </div>165 ) : (166 <span className="text-[11px] text-fg-4">{int(item.target)} target</span>167 )}168 </td>169 </tr>170 );171 })}172 </tbody>173 </table>174 </div>175 );176}177