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:
153
dashboard/src/features/codes/GenerateCodeModal.tsx
Normal file
153
dashboard/src/features/codes/GenerateCodeModal.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ApiError } from "../../api/client";
|
||||
import type { SupportCode } from "../../api/types";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { Modal } from "../../components/ui/Modal";
|
||||
import { Spinner } from "../../components/ui/Spinner";
|
||||
import { ErrorState } from "../../components/ui/States";
|
||||
import { CopyIcon } from "../../components/layout/icons";
|
||||
import { useClipboard } from "../../lib/useClipboard";
|
||||
import { useGenerateCode } from "./hooks";
|
||||
import "./codes.css";
|
||||
|
||||
interface GenerateCodeModalProps {
|
||||
/** Whether the generate dialog is open. */
|
||||
open: boolean;
|
||||
/** Operator name to attribute the code to (server stamps `created_by`). */
|
||||
technicianName?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate-a-code flow. Opening the dialog mints a fresh code immediately, then
|
||||
* reveals it large in JetBrains Mono so the tech can read it to the end user
|
||||
* over the phone. The code is the single high-signal element on this surface;
|
||||
* everything else is secondary. There is no per-second countdown — the
|
||||
* SupportCode the API returns has no `expires_at`, and a redeem/cancel surfaces
|
||||
* through the table's poll, so a timer here would be both impossible to source
|
||||
* accurately and a needless render storm.
|
||||
*/
|
||||
export function GenerateCodeModal({
|
||||
open,
|
||||
technicianName,
|
||||
onClose,
|
||||
}: GenerateCodeModalProps) {
|
||||
const generate = useGenerateCode();
|
||||
const { copied, copy } = useClipboard();
|
||||
const [result, setResult] = useState<SupportCode | null>(null);
|
||||
// Minting a code is a durable single-use side effect. Guard it behind a ref so
|
||||
// StrictMode's mount->cleanup->mount double-invoke can't fire two real POSTs
|
||||
// per open; re-arm on close so the next open mints fresh.
|
||||
const mintedFor = useRef(false);
|
||||
|
||||
// Mint once when the dialog opens; reset on close so a re-open mints a fresh
|
||||
// code. Minting in an effect (not on a button click) lets the dialog own the
|
||||
// loading/error/success states cleanly, mirroring JoinSessionModal.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setResult(null);
|
||||
generate.reset();
|
||||
mintedFor.current = false; // re-arm for the next open
|
||||
return;
|
||||
}
|
||||
if (mintedFor.current) return; // StrictMode remount: already minted
|
||||
mintedFor.current = true;
|
||||
let cancelled = false;
|
||||
generate
|
||||
.mutateAsync({ technician_name: technicianName })
|
||||
.then((res) => {
|
||||
if (!cancelled) setResult(res);
|
||||
})
|
||||
.catch(() => {
|
||||
// Surfaced via generate.isError below.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Mint exactly once per open. The mutation object is stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title="Support code"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<Button variant="primary" onClick={onClose}>
|
||||
Done
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{generate.isPending && !result ? (
|
||||
<div className="codegen__loading">
|
||||
<Spinner label="Generating code…" />
|
||||
</div>
|
||||
) : generate.isError ? (
|
||||
<ErrorState
|
||||
title="Could not generate a code"
|
||||
message={
|
||||
generate.error instanceof ApiError
|
||||
? generate.error.message
|
||||
: "The relay did not return a code. Check the relay status, then try again."
|
||||
}
|
||||
action={
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
void generate
|
||||
.mutateAsync({ technician_name: technicianName })
|
||||
.then(setResult)
|
||||
.catch(() => {})
|
||||
}
|
||||
>
|
||||
Try again
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : result ? (
|
||||
<>
|
||||
<p className="codegen__lede">
|
||||
Read this code to the end user. It starts an attended support session
|
||||
and can be used once.
|
||||
</p>
|
||||
|
||||
<div className="codegen__codewrap">
|
||||
<output className="codegen__code" aria-label={`Support code ${spell(result.code)}`}>
|
||||
{result.code}
|
||||
</output>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void copy(result.code)}
|
||||
aria-label={copied ? "Code copied to clipboard" : "Copy code to clipboard"}
|
||||
>
|
||||
<CopyIcon width={14} height={14} />
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="codegen__hint">
|
||||
It stays active until the user redeems it or you cancel it. Once
|
||||
redeemed it cannot be used again.
|
||||
</p>
|
||||
</>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Spell a grouped code out for the screen-reader label so it is announced
|
||||
* character by character ("K, 7, P, ...") instead of as a mangled word. The
|
||||
* visible code stays the compact `XXX-XXX-XXX` form.
|
||||
*/
|
||||
function spell(code: string): string {
|
||||
return code
|
||||
.replace(/-/g, " ")
|
||||
.split("")
|
||||
.filter((c) => c !== " ")
|
||||
.join(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user