Pages used zinc and sky classes with every dark: variant paired by hand, and several pairs failed WCAG AA: white on the sky-600 buttons (4.1:1), zinc-500 secondary text on dark backgrounds (3.7-4.1:1), the 10px zinc-400 labels (2.6:1) and the dashboard's green "Connected" (3.3:1). index.css now defines theme tokens once, light and dark side by side (page, surface, subtle, line, line-strong, fg, muted, primary and its soft/line/ink shades, link, focus), exposed to Tailwind with @theme inline. Pages use those names, so a palette change is one file. Blue #2563eb from the logo for buttons and links, the logo's navy #0b1120 for the dark page. Every text pair is at least 4.5:1 in both themes, most 7:1. Cards and lists sit on the surface color with a light shadow on an off-white page, at rounded-xl; native controls follow the theme (color-scheme). Zinc and sky stay only as data colors: status dots and chart swatches.
186 lines
6.4 KiB
TypeScript
186 lines
6.4 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// "Where should the call go?" (05 §3): a type, then the target (or a number).
|
|
// Every type there is (M6-T6; queues M8-T1, conference rooms M8-T3).
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { useId, useState } from 'react';
|
|
import { normalizeNumber, type Destination } from '@selfphoneng/shared';
|
|
import {
|
|
announcementsApi,
|
|
conferencesApi,
|
|
extensionsApi,
|
|
ivrsApi,
|
|
queuesApi,
|
|
ringGroupsApi,
|
|
timeConditionsApi,
|
|
} from '../api/client.ts';
|
|
import { inputClass } from './ui.tsx';
|
|
|
|
type IdKind =
|
|
| 'user'
|
|
| 'voicemail'
|
|
| 'ring_group'
|
|
| 'queue'
|
|
| 'ivr'
|
|
| 'time_condition'
|
|
| 'conference'
|
|
| 'announcement';
|
|
type Kind = IdKind | 'external' | 'hangup';
|
|
|
|
const KINDS: { kind: Kind; label: string }[] = [
|
|
{ kind: 'user', label: 'Ring a user' },
|
|
{ kind: 'voicemail', label: 'Voicemail of a user' },
|
|
{ kind: 'ring_group', label: 'Ring group' },
|
|
{ kind: 'queue', label: 'Queue' },
|
|
{ kind: 'ivr', label: 'Digital receptionist' },
|
|
{ kind: 'time_condition', label: 'Office hours' },
|
|
{ kind: 'conference', label: 'Conference room' },
|
|
{ kind: 'announcement', label: 'Announcement' },
|
|
{ kind: 'external', label: 'Forward to an outside number' },
|
|
{ kind: 'hangup', label: 'Hang up' },
|
|
];
|
|
/** Names the second select for screen readers (and the e2e scripts). */
|
|
const TARGET_NOUN: Record<IdKind, string> = {
|
|
user: 'user',
|
|
voicemail: 'user',
|
|
ring_group: 'ring group',
|
|
queue: 'queue',
|
|
ivr: 'digital receptionist',
|
|
time_condition: 'office hours',
|
|
conference: 'conference room',
|
|
announcement: 'announcement',
|
|
};
|
|
const isIdKind = (k: Kind): k is IdKind => k !== 'external' && k !== 'hangup';
|
|
/** Kinds whose targets are the same rows: switching between them keeps the pick. */
|
|
const family = (k: Kind) => (k === 'voicemail' ? 'user' : k);
|
|
|
|
export function DestinationPicker({
|
|
label,
|
|
value,
|
|
onChange,
|
|
error,
|
|
exclude,
|
|
}: {
|
|
label: string;
|
|
value: Destination;
|
|
onChange: (d: Destination | null) => void; // null = incomplete (e.g. half-typed number)
|
|
error?: string;
|
|
/** The id of the thing being edited, so it can't send calls to itself. */
|
|
exclude?: string;
|
|
}) {
|
|
const id = useId();
|
|
const [kind, setKind] = useState<Kind>(value.type as Kind);
|
|
const [number, setNumber] = useState(value.type === 'external' ? value.number : '');
|
|
const [numberError, setNumberError] = useState<string>();
|
|
const targetId = 'id' in value ? value.id : '';
|
|
|
|
const all = useTargets();
|
|
const targets = (k: IdKind) => (all[k] ?? []).filter((t) => t.id !== exclude);
|
|
|
|
function pickKind(k: Kind) {
|
|
setKind(k);
|
|
if (k === 'hangup') onChange({ type: 'hangup' });
|
|
else if (k === 'external') typed(number);
|
|
else if (targetId && family(k) === family(kind)) onChange({ type: k, id: targetId });
|
|
else onChange(null); // the admin picks the target: never guess
|
|
}
|
|
|
|
function typed(v: string) {
|
|
setNumber(v);
|
|
try {
|
|
onChange({ type: 'external', number: normalizeNumber(v) });
|
|
setNumberError(undefined);
|
|
} catch {
|
|
onChange(null);
|
|
setNumberError(v ? 'A phone number like (312) 555-1234' : undefined);
|
|
}
|
|
}
|
|
|
|
const what = KINDS.find((k) => k.kind === kind)?.label.toLowerCase() ?? '';
|
|
return (
|
|
<fieldset className="flex flex-col gap-2" aria-describedby={error ? `${id}-err` : undefined}>
|
|
<legend className="mb-1 text-sm font-medium">{label}</legend>
|
|
<select
|
|
aria-label={`${label}: type`}
|
|
value={kind}
|
|
onChange={(e) => pickKind(e.target.value as Kind)}
|
|
className={inputClass}
|
|
>
|
|
{KINDS.map((k) => (
|
|
<option key={k.kind} value={k.kind}>
|
|
{k.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{isIdKind(kind) && (
|
|
<select
|
|
aria-label={`${label}: ${TARGET_NOUN[kind]}`}
|
|
value={targetId}
|
|
onChange={(e) => onChange({ type: kind, id: e.target.value })}
|
|
className={inputClass}
|
|
>
|
|
{!targetId && <option value="">Pick one…</option>}
|
|
{targets(kind).map((t) => (
|
|
<option key={t.id} value={t.id}>
|
|
{t.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
{kind === 'external' && (
|
|
<input
|
|
aria-label={`${label}: number`}
|
|
value={number}
|
|
onChange={(e) => typed(e.target.value)}
|
|
inputMode="tel"
|
|
placeholder="(312) 555-1234"
|
|
aria-invalid={Boolean(numberError)}
|
|
className={inputClass}
|
|
/>
|
|
)}
|
|
{(numberError || error) && (
|
|
<p id={`${id}-err`} className="text-xs text-red-600 dark:text-red-400">
|
|
{numberError ?? error}
|
|
</p>
|
|
)}
|
|
{isIdKind(kind) && all[kind] && targets(kind).length === 0 && (
|
|
<p className="text-xs text-muted">Nothing to pick yet ({what}).</p>
|
|
)}
|
|
</fieldset>
|
|
);
|
|
}
|
|
|
|
/** Everything a destination can point at, per kind, as {id, label} ("101 Dan Doe"). */
|
|
function useTargets() {
|
|
const exts = useQuery({ queryKey: ['extensions'], queryFn: extensionsApi.list });
|
|
const groups = useQuery({ queryKey: ['ring-groups'], queryFn: ringGroupsApi.list });
|
|
const queues = useQuery({ queryKey: ['queues'], queryFn: queuesApi.list });
|
|
const ivrs = useQuery({ queryKey: ['ivrs'], queryFn: ivrsApi.list });
|
|
const tcs = useQuery({ queryKey: ['time-conditions'], queryFn: timeConditionsApi.list });
|
|
const anns = useQuery({ queryKey: ['announcements'], queryFn: announcementsApi.list });
|
|
const rooms = useQuery({ queryKey: ['conferences'], queryFn: conferencesApi.list });
|
|
const rows = (q: { data?: { id: string; name: string; number?: string }[] }) =>
|
|
q.data?.map((x) => ({ id: x.id, label: x.number ? `${x.number} ${x.name}` : x.name }));
|
|
return {
|
|
user: rows(exts),
|
|
voicemail: rows(exts),
|
|
ring_group: rows(groups),
|
|
queue: rows(queues),
|
|
ivr: rows(ivrs),
|
|
time_condition: rows(tcs),
|
|
conference: rows(rooms),
|
|
announcement: rows(anns),
|
|
} satisfies Record<IdKind, unknown>;
|
|
}
|
|
|
|
/** Client-side "Ring a user: 101 Dan Doe" for destinations not saved yet (the API has its own). */
|
|
export function useDescribeDestination() {
|
|
const targets = useTargets();
|
|
return (d: Destination): string => {
|
|
if (d.type === 'hangup') return 'Hang up';
|
|
if (d.type === 'external') return `Call ${d.number}`;
|
|
const what = KINDS.find((k) => k.kind === d.type)?.label ?? d.type;
|
|
const t = targets[d.type as IdKind]?.find((x) => x.id === d.id);
|
|
return t ? `${what}: ${t.label}` : what;
|
|
};
|
|
}
|