Files
codecraft/web/src/components/Budget.tsx
T
Richard ZinkandClaude Opus 5.5 a0d0e707f2 Add Web Push, guided start screen, SVG icons, chunked uploads, rename
- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished
  notifications while no app window is visible; settings with test button
- Guided new-session flow: task, craft target (website/app/game/old
  software/fresh), clean room or deep, description, model, folder
  (new, existing or cloned git repository); originals upload to original/
- Craft phase bar (craft_phase tool) for study/specify/build/check/ready
- Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB)
- All UI glyphs and emojis replaced by themeable SVG icons
- Rename dialog with manual name or model-generated suggestion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:03:26 +01:00

100 lines
4.2 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.
import { Icon } from "./Icon";
import { useFetch } from "../hooks";
import { fmtCost, fmtPercent, useT } from "../i18n";
interface BudgetRow {
provider: string;
budget: number | null;
spent: number;
remaining: number | null;
share: number | null;
warning: boolean;
exceeded: boolean;
images?: boolean;
remote?: { currency?: string; remaining?: number | null; limit?: number | null; used?: number | null; spent_month?: number; scope?: string; error?: string };
}
interface BudgetData {
currency: string;
total: BudgetRow;
providers: BudgetRow[];
}
function Bar({ share, warning, exceeded }: { share: number; warning: boolean; exceeded: boolean }) {
const color = exceeded ? "var(--red)" : warning ? "var(--accent2)" : "var(--green)";
return (
<div className="budget-bar" role="meter" aria-valuenow={Math.round(share * 100)} aria-valuemin={0} aria-valuemax={100}>
<div style={{ width: `${Math.min(share, 1) * 100}%`, background: color }} />
</div>
);
}
function Remote({ r, t }: { r: BudgetRow["remote"]; t: ReturnType<typeof useT> }) {
if (!r) return <span className="muted">–</span>;
if (r.error) return <span className="muted" title={r.error}><Icon name="warning" /> {t("budget.remoteError")}</span>;
const cur = r.currency ?? "USD";
if (r.spent_month !== undefined) return <span title={t("budget.orgWide")}>{t("budget.providerSpent", { v: fmtCost(r.spent_month, cur) })}</span>;
if (r.remaining !== null && r.remaining !== undefined) return <span>{t("budget.balance", { v: fmtCost(r.remaining, cur) })}</span>;
if (r.used !== null && r.used !== undefined) return <span>{t("budget.providerSpent", { v: fmtCost(r.used, cur) })}</span>;
return <span className="muted">–</span>;
}
/** Monthly budgets per provider (admins). */
export function BudgetPanel() {
const t = useT();
const { data, error } = useFetch<BudgetData>("/budget");
if (error || !data) return null;
const rows = [data.total, ...data.providers];
return (
<div className="panel">
<h3>{t("budget.title")}</h3>
<div style={{ overflowX: "auto" }}>
<table className="grid">
<thead>
<tr>
<th>{t("stats.provider")}</th>
<th className="num">{t("budget.spent")}</th>
<th className="num">{t("budget.budget")}</th>
<th style={{ minWidth: 140 }} />
<th className="num">{t("budget.remaining")}</th>
<th>{t("budget.provider")}</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.provider}>
<td>
{r.provider === "total" ? <b>{t("stats.total")}</b> : r.provider}
{r.images && <span className="chip" style={{ marginLeft: 6 }}>{t("nav.images")}</span>}
</td>
<td className="num">{fmtCost(r.spent, data.currency)}</td>
<td className="num">{r.budget ? fmtCost(r.budget, data.currency) : "–"}</td>
<td>{r.share !== null && <Bar share={r.share} warning={r.warning} exceeded={r.exceeded} />}</td>
<td className="num" style={{ color: r.exceeded ? "var(--red)" : r.warning ? "var(--accent2)" : undefined }}>
{r.remaining !== null ? fmtCost(r.remaining, data.currency) : "–"}
{r.share !== null && <span className="muted"> ({fmtPercent(r.share)})</span>}
</td>
<td>{r.provider === "total" ? null : <Remote r={r.remote} t={t} />}</td>
</tr>
))}
</tbody>
</table>
</div>
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 6 }}>{t("budget.hint")}</div>
</div>
);
}
/** Small header chip when the overall budget is nearly used up. */
export function BudgetChip() {
const t = useT();
const { data } = useFetch<BudgetData>("/budget");
const worst = data ? [data.total, ...data.providers].filter((r) => r.warning).sort((a, b) => (b.share ?? 0) - (a.share ?? 0))[0] : undefined;
if (!worst) return null;
return (
<span className="chip warn hide-mobile" title={t("budget.chipHint")}>
{t("budget.chip", { name: worst.provider === "total" ? t("stats.total") : worst.provider, p: fmtPercent(worst.share ?? 0) })}
</span>
);
}