Initial commit

This commit is contained in:
wangxuming
2026-07-12 15:53:24 +08:00
commit 68d61700d5
252 changed files with 23291 additions and 0 deletions

466
web/src/pages/AdminPage.tsx Normal file
View File

@@ -0,0 +1,466 @@
import { useEffect, useState, type FormEvent } from "react";
import { Link, NavLink, useLocation, useNavigate } from "react-router-dom";
import { ApiError, api } from "../api";
import { AppShell } from "../components/AppShell";
import { EmptyState, FeedbackBanner, FreshnessBanner, LoadingState } from "../components/Feedback";
import { StatusBadge } from "../components/StatusBadge";
import { usePollingResource } from "../hooks/usePollingResource";
import { formatDateTime, formatTicketNumberRange, isTimestampStale } from "../lib/format";
import type { AdminOverviewDto, AdminProjectDto, AdminUserDto, CallBatchDto } from "../types";
import { forecastRows } from "./DisplayPage";
function currentBatchLabel(value: AdminProjectDto["current_batch"]): string {
if (value === null || value === undefined) return "暂无";
if (typeof value === "string" || typeof value === "number") return String(value);
const batch = value as CallBatchDto;
return formatTicketNumberRange(batch.tickets.map((ticket) => ticket.ticket_number));
}
function currentBatchNumbers(value: AdminProjectDto["current_batch"]): string {
if (!value || typeof value === "string" || typeof value === "number") return currentBatchLabel(value);
const batch = value as CallBatchDto;
const numbers = batch.tickets.map((ticket) => ticket.ticket_number).filter(Boolean);
return numbers.length ? formatTicketNumberRange(numbers) : currentBatchLabel(batch);
}
function deviceStatusLabel(value: unknown): string {
if (!value) return "未配置";
if (typeof value === "string") return value;
if (typeof value === "object") {
const record = value as Record<string, unknown>;
return String(record.label ?? record.status ?? record.message ?? "已配置");
}
return String(value);
}
function AdminNavigation() {
return (
<aside className="admin-sidebar" aria-label="管理端导航">
<nav className="admin-nav" aria-label="管理任务">
<NavLink end to="/admin"></NavLink>
<NavLink to="/admin/projects"></NavLink>
<NavLink to="/admin/accounts"></NavLink>
<NavLink to="/admin/display"></NavLink>
</nav>
</aside>
);
}
function OperationsOverview({ data, refreshing, onRefresh }: { data: AdminOverviewDto; refreshing: boolean; onRefresh: () => void }) {
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const projects = data.projects.filter((project) => {
const matchesQuery = project.name.toLowerCase().includes(query.trim().toLowerCase());
return matchesQuery && (statusFilter === "ALL" || project.status === statusFilter);
});
const summary = data.summary;
return (
<div className="operations-dashboard">
<div className="operations-dashboard__overview">
<section className="summary-strip" aria-label="运营汇总">
<article><span></span><strong>{summary.running_projects}</strong><small></small></article>
<article><span></span><strong>{summary.waiting_count}</strong><small></small></article>
</section>
</div>
<div className="operations-dashboard__body">
<section className="panel queue-overview" aria-labelledby="admin-projects-title">
<div className="queue-overview__heading">
<div><span className="section-marker" aria-hidden="true" /><h2 id="admin-projects-title"></h2></div>
<button className="button button--secondary button--small" onClick={onRefresh} disabled={refreshing}>{refreshing ? "正在更新" : "刷新数据"}</button>
</div>
<div className="queue-overview__filters">
<label><span className="sr-only"></span><select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}><option value="ALL"></option><option value="RUNNING"></option><option value="PAUSED"></option><option value="ENDED"></option></select></label>
<label className="queue-search"><span className="sr-only"></span><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索项目名称" /></label>
</div>
{projects.length ? (
<>
<div className="queue-project-list" role="list" aria-label="项目队列">
<div className="queue-project-list__header" aria-hidden="true"><span></span><span></span><span></span><span></span><span></span></div>
{projects.map((project) => (
<div className="queue-project-row" key={project.id} role="listitem">
<span className="queue-project-row__name"><strong>{project.name}</strong><small>{deviceStatusLabel(project.device_status)}</small></span>
<span className="queue-project-row__metric"><strong>{project.waiting_count ?? 0}</strong><small></small></span>
<span><strong>{currentBatchLabel(project.current_batch)}</strong></span>
<StatusBadge status={project.status} kind="project" />
<time>{formatDateTime(project.last_updated_at)}</time>
</div>
))}
</div>
<footer className="queue-overview__footer"><span> {projects.length} </span><span aria-label="当前第 1 页"> <strong>1</strong> </span></footer>
</>
) : <EmptyState title="没有符合条件的项目" />}
</section>
</div>
</div>
);
}
type SettingsDraft = {
status: string;
callBatchSize: string;
gracePeriodMinutes: string;
etaIntervalSeconds: string;
visitorNotice: string;
};
function draftFor(project: AdminProjectDto): SettingsDraft {
return {
status: project.status,
callBatchSize: String(project.call_batch_size ?? project.batch_size ?? 1),
gracePeriodMinutes: String(project.grace_period_minutes ?? 0),
etaIntervalSeconds: String(project.eta?.interval_per_number_seconds ?? 60),
visitorNotice: project.visitor_notice ?? "",
};
}
function ProjectSettingsForm({ project, onSaved }: { project: AdminProjectDto; onSaved: () => void | Promise<void> }) {
const [draft, setDraft] = useState(() => draftFor(project));
const [name, setName] = useState(project.name);
const [code, setCode] = useState(project.code ?? "");
const [saving, setSaving] = useState(false);
const [notice, setNotice] = useState<{ tone: "success" | "danger"; message?: string } | null>(null);
const update = (patch: Partial<SettingsDraft>) => {
setDraft((current) => ({ ...current, ...patch }));
setNotice(null);
};
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setSaving(true);
setNotice(null);
try {
await api.updateProject(project.id, {
name,
code: code.toUpperCase(),
timezone: project.timezone ?? "Asia/Shanghai",
ticket_prefix: project.ticket_prefix ?? "A",
});
await api.updateProjectSettings(project.id, {
status: draft.status,
call_batch_size: Number(draft.callBatchSize),
grace_period_minutes: Number(draft.gracePeriodMinutes),
eta_interval_seconds: Number(draft.etaIntervalSeconds),
visitor_notice: draft.visitorNotice,
});
setNotice({ tone: "success", message: "项目维护内容已更新。" });
await onSaved();
} catch (caught) {
setNotice({ tone: "danger", message: caught instanceof ApiError ? caught.message : "项目未保存,请检查后重试。" });
} finally {
setSaving(false);
}
};
return (
<form className="panel project-settings-form" aria-label="维护项目" onSubmit={submit}>
<div className="settings-grid">
<label className="field">
<span></span>
<input value={name} onChange={(event) => { setName(event.target.value); setNotice(null); }} maxLength={120} disabled={saving} required />
</label>
<label className="field">
<span></span>
<input value={code} onChange={(event) => { setCode(event.target.value.toUpperCase()); setNotice(null); }} pattern="[A-Z0-9][A-Z0-9_-]{1,23}" disabled={saving} required />
</label>
<label className="field">
<span></span>
<select value="00000" disabled><option value="00000">00000</option></select>
</label>
<label className="field">
<span></span>
<select value={draft.status} onChange={(event) => update({ status: event.target.value })} disabled={saving}>
<option value="NOT_OPEN"></option>
<option value="RUNNING"></option>
<option value="PAUSED"></option>
<option value="ENDED"></option>
</select>
</label>
<label className="field">
<span></span>
<input type="number" min="1" max="100" value={draft.callBatchSize} onChange={(event) => update({ callBatchSize: event.target.value })} disabled={saving} required />
</label>
<label className="field">
<span></span>
<input aria-label="单个号码预计间隔时间(秒)" type="number" min="1" max="86400" value={draft.etaIntervalSeconds} onChange={(event) => update({ etaIntervalSeconds: event.target.value })} disabled={saving} required />
<small></small>
</label>
<label className="field project-settings-form__visitor-notice">
<span></span>
<textarea aria-label="游客官方提示" value={draft.visitorNotice} onChange={(event) => update({ visitorNotice: event.target.value })} maxLength={240} rows={3} disabled={saving} placeholder="例如:请在入口附近等候,叫号后凭号码入场。" />
<small> 240 </small>
</label>
</div>
<div className="project-settings-form__actions">
{notice ? <FeedbackBanner tone={notice.tone} title={notice.tone === "success" ? "项目保存成功" : "保存失败"}>{notice.message || null}</FeedbackBanner> : null}
<button className="button button--primary" type="submit" disabled={saving}>{saving ? "正在保存" : "保存项目"}</button>
</div>
</form>
);
}
function ProjectManagement({ projects }: { projects: AdminProjectDto[] }) {
return <section className="panel">
<div className="panel__header"><div><h2></h2><p></p></div><Link className="button button--primary" to="/admin/projects/new"></Link></div>
{projects.length ? <div className="project-list" role="table" aria-label="项目列表">
<div className="project-list__row project-list__header" role="row"><span role="columnheader"></span><span role="columnheader"></span><span role="columnheader"></span><span role="columnheader"></span><span role="columnheader"></span></div>
{projects.map((project) => <div className="project-list__row" role="row" key={project.id}><strong role="cell">{project.name}</strong><span role="cell">{project.code}</span><span role="cell">00000</span><span role="cell"><StatusBadge status={project.status} kind="project" /></span><span className="project-list__actions" role="cell"><Link className="button button--secondary button--small" to={`/admin/projects/${project.id}`}></Link></span></div>)}
</div> : <EmptyState title="暂无项目" />}
</section>;
}
function ProjectProfileForm({ project, onSaved }: { project?: AdminProjectDto; onSaved: () => void | Promise<void> }) {
const navigate = useNavigate();
const [name, setName] = useState(project?.name ?? "");
const [code, setCode] = useState(project?.code ?? "");
const timezone = project?.timezone ?? "Asia/Shanghai";
const ticketPrefix = project?.ticket_prefix ?? "A";
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); setSaving(true); setError(null);
try {
const payload = { name, code: code.toUpperCase(), timezone, ticket_prefix: ticketPrefix };
if (project) await api.updateProject(project.id, payload); else await api.createProject(payload);
await onSaved();
if (!project) navigate("/admin/projects", { replace: true });
} catch (caught) { setError(caught instanceof ApiError ? caught.message : "项目保存失败,请检查后重试。"); }
finally { setSaving(false); }
};
return <section className="panel account-create-panel">
<div className="panel__header"><div><h2>{project ? "基础信息" : "创建项目"}</h2><p>{project ? "修改项目名称与基础标识。" : "创建后可在项目维护中设置运行规则。"}</p></div></div>
{error ? <FeedbackBanner tone="danger" title={error} /> : null}
<form className="form-stack" aria-label={project ? "维护项目" : "创建项目"} onSubmit={submit}>
<div className="field-row"><label className="field"><span></span><input autoFocus value={name} onChange={(event) => setName(event.target.value)} maxLength={120} required /></label><label className="field"><span></span><input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} pattern="[A-Z0-9][A-Z0-9_-]{1,23}" required /></label></div>
<div className="field-row"><label className="field"><span></span><select value="00000" disabled><option value="00000">00000</option></select></label></div>
<div className="account-create-panel__actions"><Link className="button button--secondary" to="/admin/projects"></Link><button className="button button--primary" type="submit" disabled={saving}>{saving ? "正在保存" : project ? "保存项目" : "创建项目"}</button></div>
</form>
</section>;
}
function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto; onRefresh: () => void | Promise<void> }) {
if (!project) return <section className="panel"><EmptyState title="未找到该项目" /><div className="account-maintenance__back"><Link className="button button--secondary" to="/admin/projects"></Link></div></section>;
return <div className="project-maintenance">
<div className="panel__header project-maintenance__header"><h2>{project.name}</h2><Link className="button button--secondary" to="/admin/projects"></Link></div>
<ProjectSettingsForm project={project} onSaved={onRefresh} />
</div>;
}
function DisplayCenter({ projects }: { projects: AdminProjectDto[] }) {
const [fullscreenError, setFullscreenError] = useState(false);
const enterFullscreen = async (projectId: string) => {
const tile = document.querySelector(`[data-project-screen="${projectId}"]`);
if (!(tile instanceof HTMLElement) || !tile.requestFullscreen) {
setFullscreenError(true);
return;
}
try {
await tile.requestFullscreen();
setFullscreenError(false);
} catch {
setFullscreenError(true);
}
};
return (
<>
{fullscreenError ? <FeedbackBanner tone="warning" title="浏览器未允许全屏"></FeedbackBanner> : null}
{projects.length ? (
<section className="screen-wall" aria-label="项目实时监控墙">
{projects.map((project) => <ProjectScreenTile project={project} onFullscreen={enterFullscreen} key={project.id} />)}
</section>
) : <EmptyState title="暂无可监控项目" />}
</>
);
}
function ProjectScreenTile({ project, onFullscreen }: { project: AdminProjectDto; onFullscreen: (projectId: string) => Promise<void> }) {
const current = project.current_batch && typeof project.current_batch === "object" ? project.current_batch as CallBatchDto : null;
const forecasts = forecastRows(current, project.waiting_count ?? 0, project.estimated_wait);
const pageCount = Math.max(1, Math.ceil(forecasts.length / 4));
const [page, setPage] = useState(0);
const [now, setNow] = useState(() => new Date());
useEffect(() => {
setPage((value) => Math.min(value, pageCount - 1));
if (pageCount <= 1) return undefined;
const timer = window.setInterval(() => setPage((value) => (value + 1) % pageCount), 6_000);
return () => window.clearInterval(timer);
}, [pageCount]);
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 1_000);
return () => window.clearInterval(timer);
}, []);
const visible = forecasts.slice(page * 4, (page + 1) * 4);
const latestTicketNumber = forecasts.at(-1)?.range.split("").at(-1) ?? "暂无";
const dateLabel = new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "long", day: "numeric", weekday: "long" }).format(now);
const timeLabel = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false }).format(now);
return (
<article className="screen-tile" data-project-screen={project.id}>
<header className="screen-tile__header">
<div className="screen-tile__clock" aria-label={`当前时间 ${dateLabel} ${timeLabel}`}>
<time dateTime={now.toISOString()}>{timeLabel}</time>
<span>{dateLabel}</span>
</div>
<div className="screen-tile__project">
<img className="screen-tile__logo" src="/xiaoqikong-logo.jpg" alt="" aria-hidden="true" />
<h2>{project.name}</h2>
</div>
<button className="button button--primary screen-tile__fullscreen" onClick={() => void onFullscreen(project.id)}></button>
</header>
<div className="screen-tile__current">
<strong>{currentBatchNumbers(project.current_batch)}</strong>
<small>{project.current_batch ? "当前叫号" : "等待下一次叫号"}</small>
<div className="screen-tile__current-metrics">
<h3 className="screen-tile__metric" aria-label={`最新取号码:${latestTicketNumber}${latestTicketNumber === "暂无" ? "" : "号"}`}><span></span><strong>{latestTicketNumber}</strong>{latestTicketNumber === "暂无" ? null : <small></small>}</h3>
<div className="screen-tile__metric" aria-label={`总计等待:${project.waiting_count ?? 0} 个号码`}><span></span><strong>{project.waiting_count ?? 0}</strong><small></small></div>
</div>
</div>
<section className="screen-tile__forecast" aria-label={`${project.name}最新取号信息`}>
<div className="screen-tile__forecast-list-heading" aria-hidden="true"><span></span><span></span></div>
{visible.length ? <ol>{visible.map((item) => <li key={item.range}><strong>{item.range}</strong><span>{item.wait}</span></li>)}</ol> : <p></p>}
</section>
</article>
);
}
function AccountManagement({ projects }: { projects: AdminProjectDto[] }) {
const resource = usePollingResource((signal) => api.adminUsers(signal), { intervalMs: 30_000 });
return <>
<section className="panel"><div className="panel__header"><div><h2></h2><p></p></div><Link className="button button--primary" to="/admin/accounts/new"></Link></div>
{resource.loading ? <LoadingState label="正在读取账号" /> : null}
{resource.data?.users.length === 0 ? <EmptyState title="暂无账号" /> : <div className="account-list" role="table" aria-label="账号列表">
<div className="account-list__row account-list__header" role="row"><span role="columnheader"></span><span role="columnheader"></span><span role="columnheader"></span><span role="columnheader"></span><span role="columnheader"></span></div>
{resource.data?.users.map((user) => {
const projectNames = projects.filter((project) => user.project_ids.includes(project.id)).map((project) => project.name);
return <div className="account-list__row" role="row" key={user.id}><strong role="cell">{user.username}</strong><span role="cell">{user.role === "ADMIN" ? (user.protected ? "超级管理员" : "管理员") : "员工"}</span><span role="cell"><span className={`account-state account-state--${user.active ? "active" : "inactive"}`}>{user.active ? "已启用" : "已停用"}</span></span><span role="cell">{projectNames.length ? projectNames.join("、") : "未分配"}</span><span role="cell">{user.protected ? <span className="account-state"></span> : <Link className="button button--secondary button--small" to={`/admin/accounts/${user.id}`}></Link>}</span></div>;
})}
</div>}
</section>
</>;
}
function EditAccount({ projects, accountId }: { projects: AdminProjectDto[]; accountId: string }) {
const resource = usePollingResource((signal) => api.adminUsers(signal), { intervalMs: 30_000 });
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [draft, setDraft] = useState<{ role: string; active: boolean; project_ids: string[] }>({ role: "STAFF", active: true, project_ids: [] });
const user = resource.data?.users.find((item) => item.id === accountId);
const toggleProject = (ids: string[], id: string) => ids.includes(id) ? ids.filter((value) => value !== id) : [...ids, id];
useEffect(() => {
if (user) setDraft({ role: user.role, active: user.active, project_ids: [...user.project_ids] });
}, [user?.id]);
const dirty = user ? (draft.role !== user.role || draft.active !== user.active || [...draft.project_ids].sort().join("|") !== [...user.project_ids].sort().join("|")) : false;
const resetDraft = () => {
if (!user) return;
setDraft({ role: user.role, active: user.active, project_ids: [...user.project_ids] });
setError(null);
setNotice(null);
};
const save = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!user || !dirty) return;
setError(null);
setNotice(null);
setSaving(true);
try {
await api.updateAdminUser(user.id, draft);
await resource.refresh();
setNotice("账号修改已保存。");
} catch (caught) { setError(caught instanceof ApiError ? caught.message : "账号保存失败"); }
finally { setSaving(false); }
};
if (resource.loading && !resource.data) return <LoadingState label="正在读取账号" />;
if (!user) return <section className="panel"><EmptyState title="未找到该账号" /><div className="account-maintenance__back"><Link className="button button--secondary" to="/admin/accounts"></Link></div></section>;
if (user.protected) return <section className="panel"><EmptyState title="超级管理员账号不可编辑" /><div className="account-maintenance__back"><Link className="button button--secondary" to="/admin/accounts"></Link></div></section>;
return <section className="panel account-maintenance">
<div className="panel__header"><div><h2></h2><p><strong>{user.username}</strong></p></div><Link className="button button--secondary" to="/admin/accounts"></Link></div>
{error ? <FeedbackBanner tone="danger" title={error} /> : null}
<form className="form-stack account-maintenance__form" aria-label="维护账号" onSubmit={save}>
<div className="account-maintenance__basics">
<div className="account-setting-card"><div className="account-setting-card__heading"><strong></strong><small></small></div><div className="account-role-options" role="radiogroup" aria-label="角色">{([['STAFF', '员工'], ['ADMIN', '管理员']] as const).map(([value, label]) => <label key={value} className="account-role-option"><input type="radio" name="account-role" value={value} checked={draft.role === value} onChange={() => setDraft((current) => ({ ...current, role: value }))} disabled={saving} /><span>{label}</span></label>)}</div></div>
<label className="account-toggle"><span><strong></strong><small></small></span><input type="checkbox" checked={draft.active} onChange={(event) => setDraft((current) => ({ ...current, active: event.target.checked }))} disabled={saving} /><i aria-hidden="true" /></label>
</div>
<fieldset className="account-projects">
<legend></legend>
<p>访</p>
<div className="account-projects__grid">
{projects.map((project) => {
const selected = draft.project_ids.includes(project.id);
return <label className={`account-project-option${selected ? " account-project-option--selected" : ""}`} key={project.id}><input type="checkbox" checked={selected} onChange={() => setDraft((current) => ({ ...current, project_ids: toggleProject(current.project_ids, project.id) }))} disabled={saving} /><span>{project.name}</span><small>{selected ? "已授权" : "未授权"}</small></label>;
})}
</div>
</fieldset>
<div className="account-maintenance__actions">
<div className="account-maintenance__feedback" aria-live="polite">{error ? <span className="account-save-state account-save-state--error">{error}</span> : notice ? <span className="account-save-state account-save-state--success">{notice}</span> : dirty ? <span className="account-save-state"></span> : <span className="account-save-state"></span>}</div>
<button className="button button--secondary" type="button" onClick={resetDraft} disabled={!dirty || saving}></button>
<button className="button button--primary" type="submit" disabled={!dirty || saving}>{saving ? "正在保存" : "保存修改"}</button>
</div>
</form>
</section>;
}
function CreateAccount() {
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const create = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setError(null);
setSaving(true);
try {
await api.createAdminUser({ username, password, role: "STAFF", active: true, project_ids: [] });
navigate("/admin/accounts", { replace: true });
} catch (caught) {
setError(caught instanceof ApiError ? caught.message : "账号创建失败");
} finally {
setSaving(false);
}
};
return <section className="panel account-create-panel">
<div className="panel__header"><div><h2></h2><p></p></div></div>
{error ? <FeedbackBanner tone="danger" title={error} /> : null}
<form className="form-stack" aria-label="创建账号" onSubmit={create}>
<div className="field-row"><label className="field"><span></span><input autoFocus autoComplete="username" value={username} onChange={(event) => setUsername(event.target.value)} disabled={saving} required /></label><label className="field"><span></span><input type="password" minLength={8} autoComplete="new-password" value={password} onChange={(event) => setPassword(event.target.value)} disabled={saving} required /></label></div>
<div className="account-create-panel__actions"><Link className="button button--secondary" to="/admin/accounts"></Link><button className="button button--primary" type="submit" disabled={saving}>{saving ? "正在创建" : "创建账号"}</button></div>
</form>
</section>;
}
export function AdminPage() {
const location = useLocation();
const resource = usePollingResource((signal) => api.adminOverview(signal), { intervalMs: 5_000 });
const data = resource.data;
const stale = Boolean(data) && isTimestampStale(resource.lastClientSuccessAt, 30_000);
const section = location.pathname.startsWith("/admin/projects") ? "projects" : location.pathname.startsWith("/admin/accounts") ? "accounts" : location.pathname.endsWith("/display") ? "display" : "overview";
const resourceId = location.pathname.split("/").at(-1) ?? "";
return (
<AppShell variant="admin">
<div className="admin-workbench">
<AdminNavigation />
<section className="admin-content" aria-label="管理端内容">
{resource.loading && !data ? <LoadingState label="正在汇总项目状态" /> : null}
{resource.error && !data ? (
<FeedbackBanner tone="danger" title="管理数据暂时不可用" action={<button className="button button--secondary button--small" onClick={resource.refresh}></button>}>
{resource.error.message}
</FeedbackBanner>
) : null}
{data ? (
<>
<FreshnessBanner offline={resource.offline} stale={stale} timestamp={resource.lastClientSuccessAt} refreshing={resource.refreshing} errorMessage={resource.error?.message} onRetry={resource.refresh} />
{section === "overview" ? <OperationsOverview data={data} refreshing={resource.refreshing} onRefresh={resource.refresh} /> : null}
{section === "projects" ? (location.pathname.endsWith("/new") ? <ProjectProfileForm onSaved={resource.refresh} /> : location.pathname === "/admin/projects" ? <ProjectManagement projects={data.projects} /> : <ProjectMaintenance project={data.projects.find((project) => project.id === resourceId)} onRefresh={resource.refresh} />) : null}
{section === "accounts" ? (location.pathname.endsWith("/new") ? <CreateAccount /> : location.pathname === "/admin/accounts" ? <AccountManagement projects={data.projects} /> : <EditAccount projects={data.projects} accountId={location.pathname.split("/").at(-1) ?? ""} />) : null}
{section === "display" ? <DisplayCenter projects={data.projects} /> : null}
</>
) : null}
</section>
</div>
</AppShell>
);
}