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>
This commit is contained in:
249
dashboard/src/features/codes/SupportCodesPage.tsx
Normal file
249
dashboard/src/features/codes/SupportCodesPage.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user