wulf-pulse/components/dashboard/active-engineers.tsx
lorentz 672f17b7f9 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
2026-07-18 06:34:57 -04:00

266 lines
8.7 KiB
TypeScript

/* ActiveEngineers — today's engineers grouped by working vs PTO.
*
* 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 { 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 {
working: Engineer[];
pto: Engineer[];
}
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}
title="No time logged today"
description="Engineers will appear here as they post time entries."
size="sm"
/>
);
}
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-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 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>
);
}