138 lines
4.6 KiB
TypeScript
138 lines
4.6 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
/* Queue preferences popover — gear button on the Queue posture card.
|
||
|
|
*
|
||
|
|
* Lists every active queue with a Switch per row. Toggling persists to
|
||
|
|
* /api/me/queue-preferences (full-set PUT) and calls onSaved() so the
|
||
|
|
* parent can refetch trends and the heatmap drops the hidden rows.
|
||
|
|
*
|
||
|
|
* State is owned by this component; it lazy-loads queue data on first
|
||
|
|
* open to keep the dashboard's initial paint cheap. */
|
||
|
|
|
||
|
|
import { useEffect, useState } from 'react';
|
||
|
|
import { Settings2, Loader2, Search } from 'lucide-react';
|
||
|
|
import { toast } from 'sonner';
|
||
|
|
import { Button } from '@/components/ui/button';
|
||
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||
|
|
import { Switch } from '@/components/ui/switch';
|
||
|
|
import { Input } from '@/components/ui/input';
|
||
|
|
|
||
|
|
interface QueueRow {
|
||
|
|
id: number;
|
||
|
|
label: string;
|
||
|
|
hidden: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Props {
|
||
|
|
onSaved: () => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function QueuePreferencesPopover({ onSaved }: Props) {
|
||
|
|
const [open, setOpen] = useState(false);
|
||
|
|
const [queues, setQueues] = useState<QueueRow[] | null>(null);
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [saving, setSaving] = useState<number | null>(null);
|
||
|
|
const [filter, setFilter] = useState('');
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
if (!open || queues !== null) return;
|
||
|
|
setLoading(true);
|
||
|
|
fetch('/api/me/queue-preferences')
|
||
|
|
.then((r) => r.json())
|
||
|
|
.then((data: { queues: QueueRow[] }) => setQueues(data.queues))
|
||
|
|
.catch(() => toast.error('Failed to load queues'))
|
||
|
|
.finally(() => setLoading(false));
|
||
|
|
}, [open, queues]);
|
||
|
|
|
||
|
|
async function toggle(queueId: number, nextHidden: boolean) {
|
||
|
|
if (!queues) return;
|
||
|
|
const previous = queues;
|
||
|
|
const next = queues.map((q) => (q.id === queueId ? { ...q, hidden: nextHidden } : q));
|
||
|
|
setQueues(next);
|
||
|
|
setSaving(queueId);
|
||
|
|
try {
|
||
|
|
const hiddenIds = next.filter((q) => q.hidden).map((q) => q.id);
|
||
|
|
const res = await fetch('/api/me/queue-preferences', {
|
||
|
|
method: 'PUT',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({ hiddenIds }),
|
||
|
|
});
|
||
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||
|
|
onSaved();
|
||
|
|
} catch {
|
||
|
|
setQueues(previous);
|
||
|
|
toast.error('Failed to update queue preference');
|
||
|
|
} finally {
|
||
|
|
setSaving(null);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const visible = queues?.filter((q) =>
|
||
|
|
filter ? q.label.toLowerCase().includes(filter.toLowerCase()) : true,
|
||
|
|
);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Popover open={open} onOpenChange={setOpen}>
|
||
|
|
<PopoverTrigger asChild>
|
||
|
|
<Button
|
||
|
|
variant="ghost"
|
||
|
|
size="icon"
|
||
|
|
className="h-7 w-7"
|
||
|
|
aria-label="Configure visible queues"
|
||
|
|
>
|
||
|
|
<Settings2 className="h-4 w-4" />
|
||
|
|
</Button>
|
||
|
|
</PopoverTrigger>
|
||
|
|
<PopoverContent align="end" className="w-80 p-0">
|
||
|
|
<div className="p-3 border-b">
|
||
|
|
<p className="text-sm font-medium">Visible queues</p>
|
||
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||
|
|
Toggle off to hide a queue from your dashboard.
|
||
|
|
</p>
|
||
|
|
</div>
|
||
|
|
<div className="p-3 border-b">
|
||
|
|
<div className="relative">
|
||
|
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
|
||
|
|
<Input
|
||
|
|
value={filter}
|
||
|
|
onChange={(e) => setFilter(e.target.value)}
|
||
|
|
placeholder="Filter queues…"
|
||
|
|
className="pl-7 h-8 text-sm"
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="max-h-80 overflow-y-auto">
|
||
|
|
{loading || queues === null ? (
|
||
|
|
<div className="flex items-center justify-center py-8">
|
||
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||
|
|
</div>
|
||
|
|
) : visible && visible.length === 0 ? (
|
||
|
|
<p className="text-sm text-muted-foreground text-center py-6">
|
||
|
|
No queues match.
|
||
|
|
</p>
|
||
|
|
) : (
|
||
|
|
<ul className="py-1">
|
||
|
|
{visible!.map((q) => (
|
||
|
|
<li
|
||
|
|
key={q.id}
|
||
|
|
className="flex items-center justify-between gap-3 px-3 py-2 hover:bg-accent/40"
|
||
|
|
>
|
||
|
|
<span className="text-sm truncate" title={q.label}>
|
||
|
|
{q.label}
|
||
|
|
</span>
|
||
|
|
<Switch
|
||
|
|
checked={!q.hidden}
|
||
|
|
onCheckedChange={(checked) => toggle(q.id, !checked)}
|
||
|
|
disabled={saving === q.id}
|
||
|
|
aria-label={`${q.hidden ? 'Show' : 'Hide'} ${q.label}`}
|
||
|
|
/>
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</PopoverContent>
|
||
|
|
</Popover>
|
||
|
|
);
|
||
|
|
}
|