chore: check in pending work — queue preferences, QBO AR diagnostics, mobile engagement fixes, ops scripts
Bundles several in-progress efforts that were sitting uncommitted: - User queue-preferences (migration 087, API route, popover component) - QBO invoice soft-delete (migration 088) and AR diagnostics route - Dashboard/mobile engagement route and page adjustments - Docker Compose log-rotation config - One-off ticket/RMM investigation scripts (scripts/) - Planning docs: phase verification/pattern notes, mobile shell design spec - .gitignore: exclude local scratch financial/inventory data and Claude Code worktree/local-settings runtime state (never meant for version control) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6RuWdiUiXrPK6FLBHjtpY
This commit is contained in:
parent
b638189cb0
commit
672f17b7f9
35 changed files with 2801 additions and 92 deletions
|
|
@ -1,26 +1,66 @@
|
|||
/* ActiveEngineers — top engineers today by hours logged.
|
||||
/* ActiveEngineers — today's engineers grouped by working vs PTO.
|
||||
*
|
||||
* Compact list: name + ticket count + hours bar. Sorted by hours
|
||||
* desc upstream. Empty when no time has been logged yet today. */
|
||||
* Top section: engineers with billable/work time entries today. Each row's
|
||||
* ticket count is a button that opens a dialog listing the specific tickets
|
||||
* they logged time against (and hours per ticket).
|
||||
*
|
||||
* Bottom section: collapsible list of engineers whose only time today is on a
|
||||
* PTO/Vacation allocation code. Empty when nobody is out. */
|
||||
|
||||
'use client';
|
||||
|
||||
import { Activity } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Activity, ChevronDown, Palmtree } from 'lucide-react';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Ticket {
|
||||
id: string;
|
||||
ticketNumber: string | null;
|
||||
title: string | null;
|
||||
description: string | null;
|
||||
statusLabel: string | null;
|
||||
hours: number;
|
||||
}
|
||||
|
||||
interface Engineer {
|
||||
resourceId: string;
|
||||
name: string;
|
||||
hours: number;
|
||||
ticketsTouched: number;
|
||||
tickets: Ticket[];
|
||||
isPto: boolean;
|
||||
ptoNote: string | null;
|
||||
}
|
||||
|
||||
interface ActiveEngineersProps {
|
||||
data: Engineer[];
|
||||
working: Engineer[];
|
||||
pto: Engineer[];
|
||||
}
|
||||
|
||||
export function ActiveEngineers({ data }: ActiveEngineersProps) {
|
||||
if (data.length === 0) {
|
||||
export function ActiveEngineers({ working, pto }: ActiveEngineersProps) {
|
||||
const [ticketsFor, setTicketsFor] = useState<Engineer | null>(null);
|
||||
const [ptoOpen, setPtoOpen] = useState(false);
|
||||
|
||||
if (working.length === 0 && pto.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={Activity}
|
||||
|
|
@ -31,43 +71,196 @@ export function ActiveEngineers({ data }: ActiveEngineersProps) {
|
|||
);
|
||||
}
|
||||
|
||||
const max = data.reduce((m, e) => Math.max(m, e.hours), 0) || 1;
|
||||
const totalHours = data.reduce((s, e) => s + e.hours, 0);
|
||||
const totalTickets = data.reduce((s, e) => s + e.ticketsTouched, 0);
|
||||
const max = working.reduce((m, e) => Math.max(m, e.hours), 0) || 1;
|
||||
const totalHours = working.reduce((s, e) => s + e.hours, 0);
|
||||
const totalTickets = working.reduce((s, e) => s + e.ticketsTouched, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{data.map((e) => {
|
||||
const pct = (e.hours / max) * 100;
|
||||
return (
|
||||
<div key={e.resourceId} className="grid grid-cols-[1fr_auto] items-center gap-3 py-1">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{e.name}</div>
|
||||
<div className="relative h-1 w-full bg-muted rounded-sm mt-1 overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-primary/70"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
<div className="space-y-3">
|
||||
{working.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground py-2">
|
||||
No working time logged yet today.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{working.map((e) => {
|
||||
const pct = (e.hours / max) * 100;
|
||||
return (
|
||||
<div
|
||||
key={e.resourceId}
|
||||
className="grid grid-cols-[1fr_auto] items-center gap-3 py-1"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{e.name}</div>
|
||||
<div className="relative h-1 w-full bg-muted rounded-sm mt-1 overflow-hidden">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 bg-primary/70"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="num text-sm">{e.hours.toFixed(1)}h</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTicketsFor(e)}
|
||||
disabled={e.ticketsTouched === 0}
|
||||
className={cn(
|
||||
'text-xs text-muted-foreground',
|
||||
e.ticketsTouched > 0
|
||||
? 'hover:text-foreground hover:underline cursor-pointer'
|
||||
: 'cursor-default',
|
||||
)}
|
||||
>
|
||||
<span className="num">{e.ticketsTouched}</span>{' '}
|
||||
ticket{e.ticketsTouched === 1 ? '' : 's'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<div className="num text-sm">{e.hours.toFixed(1)}h</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
<span className="num">{e.ticketsTouched}</span>{' '}
|
||||
ticket{e.ticketsTouched === 1 ? '' : 's'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="border-t pt-2 mt-2 flex justify-between text-xs text-muted-foreground">
|
||||
<span>Total today</span>
|
||||
<span>
|
||||
<span className="num">{totalHours.toFixed(1)}h</span>{' '}
|
||||
across <span className="num">{totalTickets}</span>{' '}
|
||||
ticket{totalTickets === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="border-t pt-2 mt-2 flex justify-between text-xs text-muted-foreground">
|
||||
<span>Total today</span>
|
||||
<span>
|
||||
<span className="num">{totalHours.toFixed(1)}h</span>{' '}
|
||||
across <span className="num">{totalTickets}</span>{' '}
|
||||
ticket{totalTickets === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pto.length > 0 && (
|
||||
<Collapsible open={ptoOpen} onOpenChange={setPtoOpen}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-2 rounded-md border bg-muted/40',
|
||||
'px-2 py-1.5 text-xs text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Palmtree className="h-3.5 w-3.5" />
|
||||
<span>
|
||||
<span className="num">{pto.length}</span> on PTO / Vacation
|
||||
</span>
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 transition-transform',
|
||||
ptoOpen && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="pt-2">
|
||||
<ul className="space-y-1">
|
||||
{pto.map((e) => (
|
||||
<li
|
||||
key={e.resourceId}
|
||||
className="flex items-center justify-between gap-3 text-sm py-0.5"
|
||||
>
|
||||
<span className="truncate">{e.name}</span>
|
||||
{e.ptoNote ? (
|
||||
<span
|
||||
className="text-xs text-muted-foreground truncate max-w-[60%]"
|
||||
title={e.ptoNote}
|
||||
>
|
||||
{e.ptoNote}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
|
||||
<Dialog open={!!ticketsFor} onOpenChange={(open) => !open && setTicketsFor(null)}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{ticketsFor?.name} · today</DialogTitle>
|
||||
<DialogDescription>
|
||||
{ticketsFor
|
||||
? `${ticketsFor.hours.toFixed(1)}h across ${ticketsFor.ticketsTouched} ticket${
|
||||
ticketsFor.ticketsTouched === 1 ? '' : 's'
|
||||
}`
|
||||
: ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="divide-y max-h-[60vh] overflow-y-auto">
|
||||
{ticketsFor?.tickets.map((t) => (
|
||||
<TicketRow key={t.id} ticket={t} />
|
||||
))}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* Single ticket row inside the per-engineer dialog. Hovering anywhere on the
|
||||
* row reveals a popover with the ticket's current status and description. */
|
||||
function TicketRow({ ticket: t }: { ticket: Ticket }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const hasHoverDetail = !!(t.description || t.statusLabel);
|
||||
|
||||
const row = (
|
||||
<li
|
||||
className="py-2 flex items-start justify-between gap-3"
|
||||
onMouseEnter={hasHoverDetail ? () => setOpen(true) : undefined}
|
||||
onMouseLeave={hasHoverDetail ? () => setOpen(false) : undefined}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<a
|
||||
href={`https://ww1.autotask.net/Mvc/ServiceDesk/TicketDetail.mvc?workspace=False&ids%5B0%5D=${t.id}&ticketId=${t.id}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium hover:underline"
|
||||
>
|
||||
{t.ticketNumber ?? `#${t.id}`}
|
||||
</a>
|
||||
{t.title ? (
|
||||
<div className="text-xs text-muted-foreground truncate">{t.title}</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="num text-sm shrink-0">{t.hours.toFixed(2)}h</span>
|
||||
</li>
|
||||
);
|
||||
|
||||
if (!hasHoverDetail) return row;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{row}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="left"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-80"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{t.statusLabel ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">Status</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t.statusLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
) : null}
|
||||
{t.description ? (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Description</div>
|
||||
<p className="text-sm whitespace-pre-wrap break-words max-h-48 overflow-y-auto">
|
||||
{t.description}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
No description on this ticket.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
137
components/dashboard/queue-preferences-popover.tsx
Normal file
137
components/dashboard/queue-preferences-popover.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
'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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue