feat(design): nav/visual overhaul — brand layer, /status route, KPI dashboard, TanStack DataTable
Major UI refresh on the nav-design-improvements branch. Drops 2013-era
inline styles and consolidates patterns behind shared primitives.
Foundation
- New Wulf brand layer in app/styles/brand.css repointing --primary to
the standards-guide blue (#0075AD) with utility classes for numerics
(.num / .num-lg / .num-xl), metric labels, surface tints, and the
wolf-mark watermark
- Switch primary face to IBM Plex Sans + IBM Plex Mono via next/font;
Helvetica/Arial stays in the fallback chain for brand fidelity
- Wordmark subtitle changed from "PSA Management System" to
"Operations console" everywhere it appeared
- Tagline footer ("Don't be afraid to cry") on every non-mobile page
Status moved out of /dashboard
- New /status route with integration tiles grouped by category, sync
health table, worker pulse cards (analyzer / RMM / sync scheduler),
token-expiry section, conditional alert banner
- Top-bar StatusIndicator polls integration health every 60s and links
to /status
- INTEGRATIONS_DISABLED env var suppresses operator-disabled
integrations (e.g. SentinelOne) — no failure noise from broken-on-
purpose entries. Aliases supported (sentinelone → s1, etc.)
Dashboard rebuilt around KPIs
- /api/dashboard/overview adds today snapshot (opened, resolved, open
total, SLA breaches) with delta math
- /api/dashboard/trends backs queue × priority heatmap, 30-day volume
area chart, 30-day mean resolution time line chart, today's active
engineers leaderboard
Components
- StatusBadge driven by lib/status-registry.ts (priority, ticket
status, classification, source, company type, publish, active /
yes-no / billable / approved registries)
- StatusLight (8px geometric square, five states, three sizes)
- EmptyState (shared dashed panel with icon + headline + optional CTA)
- KpiCard with delta indicator and tonal left border
- WulfMark (mark / wordmark variants from /public/branding)
- Skeleton helpers (SkeletonRow / Rows / Card / Chart / Header / Table)
Navigation
- Admin flat link → dropdown with seven shortcuts
- New UserMenu (initials avatar, role badge, settings + sign-out)
- Active-route highlight is now a 2px Wulf-blue underline echoing the
PageHeader rule (consistent across flat links and submenu triggers);
active children inside dropdowns use bg-primary/10
- Submenu width is content-driven (min-w 320 / max-w 440, single col)
- Mobile hamburger via Sheet, reuses the same nav config
Pages migrated
- 16 admin sub-pages adopt PageHeader (with accent prop)
- /addigy-devices: shadcn Table + Checkbox; PageHeader; status badges
- 10 raw <table> blocks across admin/sync/* migrated to shadcn Table
- /veeam-analysis migrated to shadcn Table (kept its expansion logic)
- Detail routes (analyzer ticket, analyzer analysis) get breadcrumbs
DataTable
- Rewritten on @tanstack/react-table v8 in manual mode; external API
unchanged so all 10+ data-browser pages keep working
- New optional props for drill-down rows: getRowCanExpand + renderSubRow
Mobile
- Multi-select Popover gets max-w-[calc(100vw-1rem)] and
collisionPadding so dropdowns can't overflow narrow viewports
- CI filter bar wraps and shrinks; stat pill flows below
Docs
- New ARCHITECTURE.md (load-bearing reference for runtime, data flow,
workers, analyzer pipeline, auth, deployment, gotchas)
- New DESIGN.md (tokens, layout, navigation IA, component vocabulary,
rolling backlog of remaining cleanup)
- CLAUDE.md refreshed with pointers to the two new docs and the
INTEGRATIONS_DISABLED operator config note
- shadcn registry registered as project-level MCP server (.mcp.json)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1112a06afe
commit
9bfb57553d
75 changed files with 9352 additions and 1827 deletions
|
|
@ -1,35 +1,100 @@
|
|||
/* DataTable — paginated, sortable, optionally expandable list table.
|
||||
*
|
||||
* Backed by @tanstack/react-table v8 in **manual** mode: the parent owns
|
||||
* data fetching, sort + search dispatch, and the page/pageSize state.
|
||||
* The table itself just renders what it's given and emits user-input
|
||||
* callbacks.
|
||||
*
|
||||
* External API is intentionally stable from the previous custom
|
||||
* implementation:
|
||||
*
|
||||
* <DataTable
|
||||
* columns={[{ key, label, sortable?, render? }]}
|
||||
* data={rows}
|
||||
* totalCount={n}
|
||||
* page={1} pageSize={25}
|
||||
* onPageChange={fn}
|
||||
* onSort={(col, dir) => …} // optional
|
||||
* onSearch={(q) => …} // optional
|
||||
* onRowClick={(row) => …} // optional
|
||||
* isLoading={false} // optional
|
||||
*
|
||||
* // NEW since the TanStack rewrite:
|
||||
* getRowCanExpand={(row) => boolean} // optional, default false
|
||||
* renderSubRow={(row) => <…>} // optional; required when expandable
|
||||
* />
|
||||
*
|
||||
* The Column shape is the same as before (key/label/sortable/render).
|
||||
* Internally we translate to ColumnDef so existing consumers keep working
|
||||
* without code changes. */
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { useMemo, useState } from 'react';
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ExpandedState,
|
||||
type Row,
|
||||
type SortingState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getExpandedRowModel,
|
||||
useReactTable,
|
||||
} from '@tanstack/react-table';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Search, ArrowUpDown, ArrowUp, ArrowDown, Loader2 } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { EmptyState } from '@/components/ui/empty-state';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
Loader2,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Column {
|
||||
export interface Column<TData = any> {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
render?: (value: any, row: any) => React.ReactNode;
|
||||
render?: (value: any, row: TData) => React.ReactNode;
|
||||
}
|
||||
|
||||
interface DataTableProps {
|
||||
columns: Column[];
|
||||
data: any[];
|
||||
export interface DataTableProps<TData = any> {
|
||||
columns: Column<TData>[];
|
||||
data: TData[];
|
||||
totalCount: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
onPageChange: (page: number) => void;
|
||||
onSort?: (column: string, direction: 'asc' | 'desc') => void;
|
||||
onSearch?: (query: string) => void;
|
||||
onRowClick?: (row: any) => void;
|
||||
onRowClick?: (row: TData) => void;
|
||||
isLoading?: boolean;
|
||||
/** Per-row gate for expansion. Return true to enable a chevron toggle. */
|
||||
getRowCanExpand?: (row: TData) => boolean;
|
||||
/** Renders the expanded sub-row body when a row is open. */
|
||||
renderSubRow?: (row: TData) => React.ReactNode;
|
||||
/** Empty-state slot. Defaults to a neutral "No results" message. */
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
}
|
||||
|
||||
export default function DataTable({
|
||||
export default function DataTable<TData = any>({
|
||||
columns,
|
||||
data,
|
||||
totalCount,
|
||||
|
|
@ -40,37 +105,97 @@ export default function DataTable({
|
|||
onSearch,
|
||||
onRowClick,
|
||||
isLoading = false,
|
||||
}: DataTableProps) {
|
||||
getRowCanExpand,
|
||||
renderSubRow,
|
||||
emptyTitle = 'No data found',
|
||||
emptyDescription = 'Try adjusting your search or filters.',
|
||||
}: DataTableProps<TData>) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortColumn, setSortColumn] = useState<string | null>(null);
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({});
|
||||
|
||||
const totalPages = Math.ceil(totalCount / pageSize);
|
||||
const expandable = !!renderSubRow;
|
||||
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
|
||||
|
||||
const handleSort = (columnKey: string) => {
|
||||
if (!onSort) return;
|
||||
|
||||
const newDirection = sortColumn === columnKey && sortDirection === 'asc' ? 'desc' : 'asc';
|
||||
setSortColumn(columnKey);
|
||||
setSortDirection(newDirection);
|
||||
onSort(columnKey, newDirection);
|
||||
};
|
||||
// Translate the legacy Column shape into TanStack ColumnDef.
|
||||
const tanstackColumns = useMemo<ColumnDef<TData>[]>(() => {
|
||||
const cols: ColumnDef<TData>[] = [];
|
||||
|
||||
// Lead expansion column when expansion is enabled.
|
||||
if (expandable) {
|
||||
cols.push({
|
||||
id: '__expand',
|
||||
header: () => null,
|
||||
cell: ({ row }) =>
|
||||
row.getCanExpand() ? (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
row.toggleExpanded();
|
||||
}}
|
||||
aria-label={row.getIsExpanded() ? 'Collapse row' : 'Expand row'}
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-sm hover:bg-muted/60"
|
||||
>
|
||||
{row.getIsExpanded() ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
) : null,
|
||||
size: 32,
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of columns) {
|
||||
cols.push({
|
||||
id: c.key,
|
||||
accessorKey: c.key,
|
||||
enableSorting: !!c.sortable,
|
||||
header: c.label,
|
||||
cell: ({ row, getValue }) =>
|
||||
c.render ? c.render(getValue(), row.original) : (getValue() as React.ReactNode),
|
||||
});
|
||||
}
|
||||
|
||||
return cols;
|
||||
}, [columns, expandable]);
|
||||
|
||||
const table = useReactTable<TData>({
|
||||
data,
|
||||
columns: tanstackColumns,
|
||||
state: { sorting, expanded },
|
||||
onSortingChange: (updater) => {
|
||||
const next = typeof updater === 'function' ? updater(sorting) : updater;
|
||||
setSorting(next);
|
||||
// Defer to caller for actual data fetch.
|
||||
if (onSort && next.length > 0) {
|
||||
onSort(next[0].id, next[0].desc ? 'desc' : 'asc');
|
||||
}
|
||||
},
|
||||
onExpandedChange: setExpanded,
|
||||
getRowCanExpand: getRowCanExpand
|
||||
? (row) => getRowCanExpand(row.original)
|
||||
: () => expandable,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getExpandedRowModel: getExpandedRowModel(),
|
||||
manualPagination: true,
|
||||
manualSorting: true,
|
||||
pageCount: totalPages,
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
if (onSearch) {
|
||||
onSearch(searchQuery);
|
||||
}
|
||||
onSearch?.(searchQuery);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Search Bar */}
|
||||
{onSearch && (
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
placeholder="Search…"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
|
|
@ -84,92 +209,82 @@ export default function DataTable({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="border rounded-lg overflow-hidden bg-card">
|
||||
<div className="border rounded-md overflow-hidden bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/50 hover:bg-muted/50">
|
||||
{columns.map((column) => (
|
||||
<TableHead key={column.key} className="font-semibold">
|
||||
{column.sortable ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleSort(column.key)}
|
||||
className="h-8 -ml-3 hover:bg-muted/80 transition-colors"
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="bg-muted/50 hover:bg-muted/50">
|
||||
{headerGroup.headers.map((header) => {
|
||||
const sortable = header.column.getCanSort();
|
||||
const sortDir = header.column.getIsSorted();
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={header.column.columnDef.size ? { width: header.column.columnDef.size } : undefined}
|
||||
className="font-semibold"
|
||||
>
|
||||
{column.label}
|
||||
{sortColumn === column.key ? (
|
||||
sortDirection === 'asc' ? (
|
||||
<ArrowUp className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="ml-2 h-4 w-4" />
|
||||
)
|
||||
{header.isPlaceholder ? null : sortable ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
className="h-8 -ml-3"
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
<SortIcon dir={sortDir === 'asc' ? 'asc' : sortDir === 'desc' ? 'desc' : null} />
|
||||
</Button>
|
||||
) : (
|
||||
<ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
flexRender(header.column.columnDef.header, header.getContext())
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
column.label
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
Array.from({ length: 5 }).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.key}>
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : data.length === 0 ? (
|
||||
renderLoadingRows(table)
|
||||
) : table.getRowModel().rows.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="text-center py-12">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<Search className="w-8 h-8 opacity-50" />
|
||||
<p className="text-sm font-medium">No data found</p>
|
||||
<p className="text-xs">Try adjusting your search or filters</p>
|
||||
</div>
|
||||
<TableCell colSpan={tanstackColumns.length} className="py-8">
|
||||
<EmptyState icon={Search} title={emptyTitle} description={emptyDescription} size="sm" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
data.map((row, index) => (
|
||||
<TableRow
|
||||
key={row.id || index}
|
||||
className={onRowClick ? 'cursor-pointer hover:bg-muted/50 transition-colors' : ''}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
>
|
||||
{columns.map((column) => (
|
||||
<TableCell key={column.key}>
|
||||
{column.render ? column.render(row[column.key], row) : row[column.key]}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<ExpandableRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
onRowClick={onRowClick}
|
||||
renderSubRow={renderSubRow}
|
||||
colSpan={tanstackColumns.length}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 px-2">
|
||||
<div className="text-sm text-muted-foreground font-medium">
|
||||
Showing <span className="font-semibold text-foreground">{Math.min((page - 1) * pageSize + 1, totalCount)}</span> to{' '}
|
||||
<span className="font-semibold text-foreground">{Math.min(page * pageSize, totalCount)}</span> of{' '}
|
||||
<span className="font-semibold text-foreground">{totalCount}</span> results
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing <span className="font-medium text-foreground num">
|
||||
{totalCount === 0 ? 0 : Math.min((page - 1) * pageSize + 1, totalCount)}
|
||||
</span>{' '}
|
||||
to <span className="font-medium text-foreground num">
|
||||
{Math.min(page * pageSize, totalCount)}
|
||||
</span>{' '}
|
||||
of <span className="font-medium text-foreground num">{totalCount}</span> results
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(1)}
|
||||
disabled={page === 1 || isLoading}
|
||||
disabled={page <= 1 || isLoading}
|
||||
className="h-8 w-8"
|
||||
aria-label="First page"
|
||||
>
|
||||
<ChevronsLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
|
@ -177,22 +292,22 @@ export default function DataTable({
|
|||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(page - 1)}
|
||||
disabled={page === 1 || isLoading}
|
||||
disabled={page <= 1 || isLoading}
|
||||
className="h-8 w-8"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 px-3">
|
||||
<span className="text-sm font-medium">
|
||||
Page {page} of {totalPages || 1}
|
||||
</span>
|
||||
</div>
|
||||
<span className="px-3 text-sm font-medium num">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page === totalPages || isLoading}
|
||||
disabled={page >= totalPages || isLoading}
|
||||
className="h-8 w-8"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
|
|
@ -200,8 +315,9 @@ export default function DataTable({
|
|||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => onPageChange(totalPages)}
|
||||
disabled={page === totalPages || isLoading}
|
||||
disabled={page >= totalPages || isLoading}
|
||||
className="h-8 w-8"
|
||||
aria-label="Last page"
|
||||
>
|
||||
<ChevronsRight className="w-4 h-4" />
|
||||
</Button>
|
||||
|
|
@ -210,3 +326,57 @@ export default function DataTable({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ExpandableRow<TData>({
|
||||
row,
|
||||
onRowClick,
|
||||
renderSubRow,
|
||||
colSpan,
|
||||
}: {
|
||||
row: Row<TData>;
|
||||
onRowClick?: (row: TData) => void;
|
||||
renderSubRow?: (row: TData) => React.ReactNode;
|
||||
colSpan: number;
|
||||
}) {
|
||||
const clickable = !!onRowClick;
|
||||
return (
|
||||
<>
|
||||
<TableRow
|
||||
className={cn(clickable && 'cursor-pointer')}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{row.getIsExpanded() && renderSubRow && (
|
||||
<TableRow className="bg-muted/20 hover:bg-muted/20">
|
||||
<TableCell colSpan={colSpan} className="px-6 py-3">
|
||||
{renderSubRow(row.original)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SortIcon({ dir }: { dir: 'asc' | 'desc' | null }) {
|
||||
if (dir === 'asc') return <ArrowUp className="ml-2 h-4 w-4" />;
|
||||
if (dir === 'desc') return <ArrowDown className="ml-2 h-4 w-4" />;
|
||||
return <ArrowUpDown className="ml-2 h-4 w-4 opacity-50" />;
|
||||
}
|
||||
|
||||
function renderLoadingRows<TData>(table: ReturnType<typeof useReactTable<TData>>) {
|
||||
const cols = table.getAllLeafColumns().length;
|
||||
return Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={`loading-${i}`}>
|
||||
{Array.from({ length: cols }).map((_, j) => (
|
||||
<TableCell key={j}>
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,79 +6,23 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
|||
import { Separator } from '@/components/ui/separator';
|
||||
import { Calendar, Check, X, Copy, CheckCircle2, Code2, LayoutTemplate, ExternalLink, Phone, Globe, Loader2, User, Building2, MessageSquare, Clock } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { StatusBadge } from '@/components/ui/status-badge';
|
||||
import {
|
||||
priorityBadge,
|
||||
ticketStatusBadge,
|
||||
sourceBadge,
|
||||
classificationBadge,
|
||||
companyTypeBadge,
|
||||
publishBadge,
|
||||
activeBadge,
|
||||
yesNoBadge,
|
||||
billableBadge,
|
||||
approvedBadge,
|
||||
toneClass,
|
||||
paletteClass,
|
||||
} from '@/lib/status-registry';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
// ── Static picklist maps (Autotask standard values from DB) ──────────────────
|
||||
|
||||
const PRIORITY_MAP: Record<number, { label: string; cls: string }> = {
|
||||
2: { label: 'Critical', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
|
||||
3: { label: 'High', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
|
||||
4: { label: 'Medium', cls: 'bg-yellow-500/15 text-yellow-700 border border-yellow-500/30' },
|
||||
6: { label: 'Low', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
|
||||
7: { label: 'Very Low', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
|
||||
8: { label: 'Critical', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
|
||||
9: { label: 'High', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
|
||||
10: { label: 'Medium', cls: 'bg-yellow-500/15 text-yellow-700 border border-yellow-500/30' },
|
||||
11: { label: 'Low', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
|
||||
};
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
'New': 'bg-blue-500/15 text-blue-600 border border-blue-500/30',
|
||||
'In Progress': 'bg-indigo-500/15 text-indigo-600 border border-indigo-500/30',
|
||||
'Complete': 'bg-green-500/15 text-green-600 border border-green-500/30',
|
||||
'Waiting Customer': 'bg-amber-500/15 text-amber-700 border border-amber-500/30',
|
||||
'Waiting Materials': 'bg-orange-500/15 text-orange-600 border border-orange-500/30',
|
||||
'Waiting Vendor': 'bg-orange-500/15 text-orange-600 border border-orange-500/30',
|
||||
'Waiting Approval': 'bg-purple-500/15 text-purple-600 border border-purple-500/30',
|
||||
'On Hold': 'bg-slate-500/15 text-slate-500 border border-slate-500/30',
|
||||
'Escalate': 'bg-red-500/15 text-red-600 border border-red-500/30',
|
||||
'Escalate to Wulf': 'bg-red-500/15 text-red-600 border border-red-500/30',
|
||||
'Escalate to MC': 'bg-red-500/15 text-red-600 border border-red-500/30',
|
||||
'Resource Assigned': 'bg-cyan-500/15 text-cyan-600 border border-cyan-500/30',
|
||||
'Service Call Scheduled': 'bg-teal-500/15 text-teal-600 border border-teal-500/30',
|
||||
'Dispatched': 'bg-teal-500/15 text-teal-600 border border-teal-500/30',
|
||||
'Resolved <CSAT Survey>': 'bg-green-500/15 text-green-600 border border-green-500/30',
|
||||
};
|
||||
|
||||
const SOURCE_MAP: Record<number, string> = {
|
||||
[-2]: 'System', [-1]: 'Internal',
|
||||
1: 'Phone', 2: 'Email', 4: 'Web Portal', 6: 'Monitoring Alert',
|
||||
8: 'RMM Alert', 17: 'Chat', 21: 'API', 22: 'Automation',
|
||||
27: 'In Person', 29: 'Client Portal', 30: 'Microsoft Teams',
|
||||
31: 'Webhook', 33: 'Datto RMM', 34: 'Rewst', 35: 'TimeZest',
|
||||
36: 'DeskDirector', 38: 'Huntress', 39: 'Blumira', 40: 'SentinelOne',
|
||||
};
|
||||
|
||||
const CLASSIFICATION_MAP: Record<number, { label: string; cls: string }> = {
|
||||
5: { label: 'Block Hour', cls: 'bg-sky-500/15 text-sky-600 border border-sky-500/30' },
|
||||
9: { label: 'Canceled', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
|
||||
202: { label: 'Co-Managed', cls: 'bg-cyan-500/15 text-cyan-600 border border-cyan-500/30' },
|
||||
16: { label: 'Gold (Legacy)', cls: 'bg-yellow-500/15 text-yellow-600 border border-yellow-500/30' },
|
||||
206: { label: 'IT Complete w/ Gold Security', cls: 'bg-amber-500/15 text-amber-600 border border-amber-500/30' },
|
||||
207: { label: 'IT Core / Silver Security', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
|
||||
203: { label: 'IT Foundation / Bronze Security', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
|
||||
205: { label: 'IT Premier / Platinum Security', cls: 'bg-violet-500/15 text-violet-600 border border-violet-500/30' },
|
||||
14: { label: 'Jeopardy Company', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
|
||||
201: { label: 'Partner', cls: 'bg-purple-500/15 text-purple-600 border border-purple-500/30' },
|
||||
15: { label: 'Platinum (Legacy)', cls: 'bg-violet-500/15 text-violet-600 border border-violet-500/30' },
|
||||
13: { label: 'Residential (no-pay)', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
|
||||
17: { label: 'Silver (Legacy)', cls: 'bg-zinc-500/15 text-zinc-600 border border-zinc-500/30' },
|
||||
12: { label: 'T&M', cls: 'bg-teal-500/15 text-teal-600 border border-teal-500/30' },
|
||||
7: { label: 'Target', cls: 'bg-emerald-500/15 text-emerald-600 border border-emerald-500/30' },
|
||||
200: { label: 'Tools Only', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
|
||||
18: { label: 'Bronze (Legacy)', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
|
||||
};
|
||||
|
||||
const COMPANY_TYPE_MAP: Record<number, { label: string; cls: string }> = {
|
||||
1: { label: 'Customer', cls: 'bg-green-500/15 text-green-600 border border-green-500/30' },
|
||||
2: { label: 'Lead', cls: 'bg-blue-500/15 text-blue-600 border border-blue-500/30' },
|
||||
3: { label: 'Prospect', cls: 'bg-purple-500/15 text-purple-600 border border-purple-500/30' },
|
||||
4: { label: 'Dead', cls: 'bg-slate-500/15 text-slate-500 border border-slate-500/30' },
|
||||
6: { label: 'Cancelation', cls: 'bg-red-500/15 text-red-600 border border-red-500/30' },
|
||||
7: { label: 'Vendor', cls: 'bg-orange-500/15 text-orange-600 border border-orange-500/30' },
|
||||
8: { label: 'Partner', cls: 'bg-cyan-500/15 text-cyan-600 border border-cyan-500/30' },
|
||||
};
|
||||
|
||||
// ── Live lookup types (fetched from DB) ───────────────────────────────────────
|
||||
|
||||
interface Lookups {
|
||||
|
|
@ -188,23 +132,24 @@ const COMPANY_GROUPS: FieldGroup[] = [
|
|||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function ColorBadge({ cls, children }: { cls: string; children: React.ReactNode }) {
|
||||
return <span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cls}`}>{children}</span>;
|
||||
}
|
||||
|
||||
function resolveLabel(key: string, value: any, type: FieldType | undefined, lookups: Lookups): { display: React.ReactNode; isEmpty: boolean } {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return { display: <span className="text-muted-foreground/40 italic text-xs">—</span>, isEmpty: true };
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'bool':
|
||||
case 'bool': {
|
||||
const badge = yesNoBadge(Boolean(value));
|
||||
return {
|
||||
display: value
|
||||
? <ColorBadge cls="bg-green-500/15 text-green-600 border border-green-500/30"><Check className="w-3 h-3 mr-1" />Yes</ColorBadge>
|
||||
: <ColorBadge cls="bg-slate-500/15 text-slate-500 border border-slate-500/30"><X className="w-3 h-3 mr-1" />No</ColorBadge>,
|
||||
display: (
|
||||
<StatusBadge variantClass={badge.variantClass}>
|
||||
{value ? <Check className="w-3 h-3 mr-1" /> : <X className="w-3 h-3 mr-1" />}
|
||||
{badge.label}
|
||||
</StatusBadge>
|
||||
),
|
||||
isEmpty: false,
|
||||
};
|
||||
}
|
||||
case 'date': {
|
||||
try {
|
||||
const d = new Date(value);
|
||||
|
|
@ -221,28 +166,24 @@ function resolveLabel(key: string, value: any, type: FieldType | undefined, look
|
|||
}
|
||||
case 'status': {
|
||||
const label = lookups.statuses[Number(value)] ?? `Status ${value}`;
|
||||
const cls = STATUS_COLOR[label] ?? 'bg-muted text-muted-foreground border border-border';
|
||||
return { display: <ColorBadge cls={cls}>{label}</ColorBadge>, isEmpty: false };
|
||||
const badge = ticketStatusBadge(label);
|
||||
return { display: <StatusBadge {...badge} />, isEmpty: false };
|
||||
}
|
||||
case 'priority': {
|
||||
const p = PRIORITY_MAP[Number(value)];
|
||||
return { display: <ColorBadge cls={p?.cls ?? 'bg-muted text-muted-foreground border border-border'}>{p?.label ?? `Priority ${value}`}</ColorBadge>, isEmpty: false };
|
||||
return { display: <StatusBadge {...priorityBadge(Number(value))} />, isEmpty: false };
|
||||
}
|
||||
case 'source': {
|
||||
const label = SOURCE_MAP[Number(value)] ?? `Source ${value}`;
|
||||
return { display: <ColorBadge cls="bg-violet-500/15 text-violet-600 border border-violet-500/30">{label}</ColorBadge>, isEmpty: false };
|
||||
return { display: <StatusBadge {...sourceBadge(Number(value))} />, isEmpty: false };
|
||||
}
|
||||
case 'queue': {
|
||||
const qLabel = lookups.queues[Number(value)] ?? `Queue ${value}`;
|
||||
return { display: <ColorBadge cls="bg-indigo-500/15 text-indigo-600 border border-indigo-500/30">{qLabel}</ColorBadge>, isEmpty: false };
|
||||
const label = lookups.queues[Number(value)] ?? `Queue ${value}`;
|
||||
return { display: <StatusBadge variantClass={paletteClass('indigo')}>{label}</StatusBadge>, isEmpty: false };
|
||||
}
|
||||
case 'company_type': {
|
||||
const ct = COMPANY_TYPE_MAP[Number(value)];
|
||||
return { display: <ColorBadge cls={ct?.cls ?? 'bg-muted text-muted-foreground border border-border'}>{ct?.label ?? `Type ${value}`}</ColorBadge>, isEmpty: false };
|
||||
return { display: <StatusBadge {...companyTypeBadge(Number(value))} />, isEmpty: false };
|
||||
}
|
||||
case 'classification': {
|
||||
const cl = CLASSIFICATION_MAP[Number(value)];
|
||||
return { display: <ColorBadge cls={cl?.cls ?? 'bg-muted text-muted-foreground border border-border'}>{cl?.label ?? `Classification ${value}`}</ColorBadge>, isEmpty: false };
|
||||
return { display: <StatusBadge {...classificationBadge(Number(value))} />, isEmpty: false };
|
||||
}
|
||||
case 'resource': {
|
||||
const name = lookups.resources[Number(value)];
|
||||
|
|
@ -264,11 +205,11 @@ function resolveLabel(key: string, value: any, type: FieldType | undefined, look
|
|||
}
|
||||
case 'issue_type': {
|
||||
const label = lookups.issueTypes[Number(value)] ?? `Issue ${value}`;
|
||||
return { display: <ColorBadge cls="bg-sky-500/15 text-sky-600 border border-sky-500/30">{label}</ColorBadge>, isEmpty: false };
|
||||
return { display: <StatusBadge variantClass={paletteClass('sky')}>{label}</StatusBadge>, isEmpty: false };
|
||||
}
|
||||
case 'sub_issue_type': {
|
||||
const label = lookups.subIssueTypes[Number(value)] ?? `Sub-Issue ${value}`;
|
||||
return { display: <ColorBadge cls="bg-sky-500/10 text-sky-500 border border-sky-500/20">{label}</ColorBadge>, isEmpty: false };
|
||||
return { display: <StatusBadge variantClass="bg-sky-500/10 text-sky-700 dark:text-sky-400">{label}</StatusBadge>, isEmpty: false };
|
||||
}
|
||||
case 'config_item': {
|
||||
const name = lookups.configItems[Number(value)];
|
||||
|
|
@ -413,8 +354,7 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
<div className="shrink-0 flex flex-col items-end gap-1">
|
||||
{(() => {
|
||||
const label = lookups.statuses[Number(data.status)] ?? `Status ${data.status}`;
|
||||
const cls = STATUS_COLOR[label] ?? 'bg-muted text-muted-foreground border border-border';
|
||||
return <ColorBadge cls={cls}>{label}</ColorBadge>;
|
||||
return <StatusBadge {...ticketStatusBadge(label)} />;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -427,9 +367,7 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
</DialogDescription>
|
||||
</div>
|
||||
{'is_active' in data && (
|
||||
<ColorBadge cls={data.is_active ? 'bg-green-500/15 text-green-600 border border-green-500/30' : 'bg-slate-500/15 text-slate-500 border border-slate-500/30'}>
|
||||
{data.is_active ? 'Active' : 'Inactive'}
|
||||
</ColorBadge>
|
||||
<StatusBadge {...activeBadge(Boolean(data.is_active))} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -652,10 +590,10 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
</span>
|
||||
)}
|
||||
{entry.billable && (
|
||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-green-500/15 text-green-600 border border-green-500/30">Billable</span>
|
||||
<StatusBadge {...billableBadge(true)} />
|
||||
)}
|
||||
{entry.approved && (
|
||||
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-blue-500/15 text-blue-600 border border-blue-500/30">Approved</span>
|
||||
<StatusBadge {...approvedBadge(true)} />
|
||||
)}
|
||||
</div>
|
||||
{entry.notes && (
|
||||
|
|
@ -695,14 +633,6 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
) : (
|
||||
<div className="space-y-3">
|
||||
{notes.map((note) => {
|
||||
const publishCls: Record<number, string> = {
|
||||
1: 'bg-green-500/15 text-green-600 border border-green-500/30',
|
||||
2: 'bg-amber-500/15 text-amber-700 border border-amber-500/30',
|
||||
4: 'bg-slate-500/15 text-slate-500 border border-slate-500/30',
|
||||
};
|
||||
const publishLabel: Record<number, string> = {
|
||||
1: 'All Users', 2: 'Internal', 4: 'Internal Only',
|
||||
};
|
||||
return (
|
||||
<div key={note.id} className="rounded-lg border p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
|
|
@ -714,9 +644,7 @@ export default function DetailModal({ open, onOpenChange, title, data, fields }:
|
|||
</span>
|
||||
)}
|
||||
{note.publish != null && (
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${publishCls[note.publish] ?? 'bg-muted text-muted-foreground border border-border'}`}>
|
||||
{publishLabel[note.publish] ?? `Publish ${note.publish}`}
|
||||
</span>
|
||||
<StatusBadge {...publishBadge(Number(note.publish))} />
|
||||
)}
|
||||
{note.title && (
|
||||
<span className="text-sm font-semibold text-foreground">{note.title}</span>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue