Generate, list, and cancel attended-support codes (XXX-XXX-XXX), built on the v2 codes API and existing UI primitives. - Codes table: code in mono, status badge (pending+pulse/connected/ completed/cancelled), bound client/machine, created-by, created (relative + absolute tooltip). Sticky header, skeleton load, actionable empty/error states. - Generate opens a focused reveal modal showing the code large in JetBrains Mono with copy and a read-aloud instruction; the code is announced character-by-character for screen readers. Mint is ref- guarded so it creates exactly one code per open (no StrictMode dupe). - Cancel via confirm dialog (POST /api/codes/:code/cancel), disabled for non-cancellable statuses; invalidates the codes query. List polls 7s. - Shared API client now tolerates non-JSON 200 bodies, so the cancel endpoint's plain-text "Code cancelled" success no longer surfaces as a failure. Error-envelope handling unchanged. Passed Code Review (no blockers after fixes) and local gates (tsc/lint/build green). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
250 lines
7.9 KiB
TypeScript
250 lines
7.9 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { ApiError } from "../../api/client";
|
|
import type { SupportCode } from "../../api/types";
|
|
import { useAuth } from "../../auth/AuthContext";
|
|
import { PageHeader } from "../../components/layout/PageHeader";
|
|
import { PlusIcon, RefreshIcon, SearchIcon, TrashIcon } from "../../components/layout/icons";
|
|
import { Badge } from "../../components/ui/Badge";
|
|
import { Button } from "../../components/ui/Button";
|
|
import { Input } from "../../components/ui/Input";
|
|
import { Panel } from "../../components/ui/Panel";
|
|
import { EmptyState, ErrorState } from "../../components/ui/States";
|
|
import { codeLabel, codeTone } from "../../components/ui/status";
|
|
import { Table, type Column } from "../../components/ui/Table";
|
|
import { TableSkeleton } from "../../components/ui/TableSkeleton";
|
|
import { absoluteTime, relativeTime } from "../../lib/time";
|
|
import { CancelCodeDialog } from "./CancelCodeDialog";
|
|
import { GenerateCodeModal } from "./GenerateCodeModal";
|
|
import { useSupportCodes } from "./hooks";
|
|
import "./codes.css";
|
|
|
|
/** A code is still cancellable only while it is pending or connected. */
|
|
function canCancel(status: string): boolean {
|
|
return status === "pending" || status === "connected";
|
|
}
|
|
|
|
export function SupportCodesPage() {
|
|
const { user } = useAuth();
|
|
const codesQuery = useSupportCodes();
|
|
const [filter, setFilter] = useState("");
|
|
const [generating, setGenerating] = useState(false);
|
|
const [cancelFor, setCancelFor] = useState<SupportCode | null>(null);
|
|
|
|
const { data } = codesQuery;
|
|
const codes = useMemo(() => data ?? [], [data]);
|
|
|
|
// Newest first: the in-memory map the server returns has no guaranteed order,
|
|
// and the code a tech just generated should be at the top where they expect
|
|
// it.
|
|
const sorted = useMemo(
|
|
() =>
|
|
[...codes].sort(
|
|
(a, b) =>
|
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
|
),
|
|
[codes],
|
|
);
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = filter.trim().toLowerCase();
|
|
if (!q) return sorted;
|
|
return sorted.filter(
|
|
(c) =>
|
|
c.code.toLowerCase().includes(q) ||
|
|
c.created_by.toLowerCase().includes(q) ||
|
|
(c.client_machine?.toLowerCase().includes(q) ?? false),
|
|
);
|
|
}, [sorted, filter]);
|
|
|
|
const pendingCount = useMemo(
|
|
() => codes.filter((c) => c.status === "pending").length,
|
|
[codes],
|
|
);
|
|
|
|
const columns: Column<SupportCode>[] = [
|
|
{
|
|
key: "code",
|
|
header: "Code",
|
|
render: (c) => <span className="cdt__code">{c.code}</span>,
|
|
},
|
|
{
|
|
key: "status",
|
|
header: "Status",
|
|
render: (c) => (
|
|
<Badge tone={codeTone(c.status)} dot>
|
|
{codeLabel(c.status)}
|
|
</Badge>
|
|
),
|
|
},
|
|
{
|
|
key: "bound",
|
|
header: "Bound to",
|
|
render: (c) =>
|
|
c.client_machine || c.client_name ? (
|
|
<div className="cdt__bound">
|
|
<span className="dt__strong">
|
|
{c.client_machine ?? c.client_name}
|
|
</span>
|
|
{c.client_machine && c.client_name && (
|
|
<span className="cdt__boundsub">{c.client_name}</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<span className="dt__muted">Not redeemed</span>
|
|
),
|
|
},
|
|
{
|
|
key: "created_by",
|
|
header: "Created by",
|
|
render: (c) => <span className="dt__strong">{c.created_by}</span>,
|
|
},
|
|
{
|
|
key: "created",
|
|
header: "Created",
|
|
render: (c) => (
|
|
<span className="dt__mono" title={absoluteTime(c.created_at)}>
|
|
{relativeTime(c.created_at)}
|
|
</span>
|
|
),
|
|
},
|
|
{
|
|
key: "actions",
|
|
header: "",
|
|
cellClass: "dt__actions",
|
|
render: (c) => {
|
|
const cancellable = canCancel(c.status);
|
|
return (
|
|
<span className="dt__rowactions" onClick={(e) => e.stopPropagation()}>
|
|
<Button
|
|
variant="danger"
|
|
size="sm"
|
|
onClick={() => setCancelFor(c)}
|
|
disabled={!cancellable}
|
|
title={
|
|
cancellable
|
|
? undefined
|
|
: `${codeLabel(c.status)} codes cannot be cancelled`
|
|
}
|
|
aria-label={`Cancel code ${c.code}`}
|
|
>
|
|
<TrashIcon width={14} height={14} />
|
|
Cancel
|
|
</Button>
|
|
</span>
|
|
);
|
|
},
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="page">
|
|
<PageHeader
|
|
title="Support codes"
|
|
subtitle="One-time codes for attended support. Generate a code, read it to the end user, and they redeem it to start a session."
|
|
actions={
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => void codesQuery.refetch()}
|
|
loading={codesQuery.isFetching}
|
|
>
|
|
<RefreshIcon width={15} height={15} />
|
|
Refresh
|
|
</Button>
|
|
<Button variant="primary" onClick={() => setGenerating(true)}>
|
|
<PlusIcon width={15} height={15} />
|
|
Generate code
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<Panel flush>
|
|
<div style={{ padding: "14px 16px 0" }}>
|
|
<div className="toolbar">
|
|
<div className="searchbox">
|
|
<span className="searchbox__icon">
|
|
<SearchIcon width={15} height={15} />
|
|
</span>
|
|
<Input
|
|
placeholder="Filter by code, machine, or creator"
|
|
value={filter}
|
|
onChange={(e) => setFilter(e.target.value)}
|
|
aria-label="Filter support codes"
|
|
/>
|
|
</div>
|
|
<div className="toolbar__count">
|
|
<span className="mono">{pendingCount}</span> awaiting redeem ·{" "}
|
|
<span className="mono">{codes.length}</span> active
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{codesQuery.isLoading ? (
|
|
<>
|
|
<span className="visually-hidden" role="status">
|
|
Loading support codes
|
|
</span>
|
|
<TableSkeleton
|
|
headers={[
|
|
"Code",
|
|
"Status",
|
|
"Bound to",
|
|
"Created by",
|
|
"Created",
|
|
"",
|
|
]}
|
|
/>
|
|
</>
|
|
) : codesQuery.isError ? (
|
|
<ErrorState
|
|
title="Could not load support codes"
|
|
message={
|
|
codesQuery.error instanceof ApiError
|
|
? codesQuery.error.message
|
|
: "The GuruConnect relay did not respond. Check the relay status, then retry."
|
|
}
|
|
action={
|
|
<Button variant="primary" onClick={() => void codesQuery.refetch()}>
|
|
Retry
|
|
</Button>
|
|
}
|
|
/>
|
|
) : filtered.length === 0 ? (
|
|
filter ? (
|
|
<EmptyState
|
|
title="No matching codes"
|
|
message={`Nothing matches "${filter}". Clear the filter to see every active code.`}
|
|
action={
|
|
<Button variant="ghost" onClick={() => setFilter("")}>
|
|
Clear filter
|
|
</Button>
|
|
}
|
|
/>
|
|
) : (
|
|
<EmptyState
|
|
title="No active codes"
|
|
message="Generate a code, read it to the end user over the phone, and they redeem it to start an attended session. Each code works once."
|
|
action={
|
|
<Button variant="primary" onClick={() => setGenerating(true)}>
|
|
<PlusIcon width={15} height={15} />
|
|
Generate code
|
|
</Button>
|
|
}
|
|
/>
|
|
)
|
|
) : (
|
|
<Table columns={columns} rows={filtered} rowKey={(c) => c.code} />
|
|
)}
|
|
</Panel>
|
|
|
|
<GenerateCodeModal
|
|
open={generating}
|
|
technicianName={user?.username}
|
|
onClose={() => setGenerating(false)}
|
|
/>
|
|
<CancelCodeDialog code={cancelFor} onClose={() => setCancelFor(null)} />
|
|
</div>
|
|
);
|
|
}
|