Files
guru-connect/dashboard/src/features/codes/SupportCodesPage.tsx
Mike Swanson 664f33d5ab
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 3m27s
Build and Test / Build Agent (Windows) (push) Successful in 7m11s
Build and Test / Security Audit (push) Successful in 4m32s
Build and Test / Build Summary (push) Has been skipped
feat(dashboard): GuruConnect v2 Support Codes view
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>
2026-05-30 13:59:18 -07:00

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>
);
}