wulf-pulse/components/backup/contract-coverage-table.tsx
lorentz c518eefdb2 feat: Morning NOC Summary adaptive card for Teams
- Add MorningSummaryService with Zabbix aggregation and adaptive card builder
- Add webhook delivery system with Teams incoming webhooks
- Add admin UI at /admin/morning-summary for webhook/config management
- Add API routes: /send, /test, /webhooks, /webhooks/[id], /config, /history
- Register morning-summary cron job in SyncScheduler (Mon-Fri 6:30 AM)
- Add outages_only filter (Unavailable triggers only)
- Fix host resolution: use getTriggerEnabledHosts to exclude disabled hosts
- Fix resolved events: event.get value:1 scoped to window with r_eventid filter
- Remove emojis from fact rows and section headers in card
- Remove Open Zabbix button (duplicate of View Problems)
- Add migrations: morning_summary_config + morning_summaries tables
- Add outages_only column to morning_summary_config
2026-03-11 09:34:51 -04:00

362 lines
13 KiB
TypeScript

'use client';
import { useState, useEffect, useMemo } from 'react';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Search,
ChevronRight,
ChevronDown,
Server,
Monitor,
Mail,
Package,
Loader2,
} from 'lucide-react';
interface ServiceLine {
cs_id: number;
contract_id: number;
contract_name: string;
line_name: string;
unit_price: number | null;
unit_cost: number | null;
category: 'server' | 'workstation' | 'm365' | 'other';
}
interface CoverageRow {
company_id: number;
company_name: string;
contracted: { servers: number; workstations: number; m365: number; other: number };
deployed: { servers: number; workstations: number; other: number };
lines: ServiceLine[];
}
const CATEGORY_COLORS: Record<string, string> = {
server: 'bg-blue-500/10 text-blue-600 dark:text-blue-400',
workstation: 'bg-purple-500/10 text-purple-600 dark:text-purple-400',
m365: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
other: 'bg-muted text-muted-foreground',
};
const CATEGORY_LABELS: Record<string, string> = {
server: 'Server',
workstation: 'Workstation',
m365: 'M365',
other: 'Other',
};
function DeltaBadge({ contracted, deployed }: { contracted: number; deployed: number }) {
const delta = deployed - contracted;
if (contracted === 0 && deployed === 0) return <span className="text-muted-foreground text-xs"></span>;
if (delta === 0) return <span className="text-xs text-green-600 dark:text-green-400 font-medium"></span>;
if (delta > 0)
return (
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
+{delta}
</span>
);
return (
<span className="text-xs font-medium text-red-600 dark:text-red-400">
{delta}
</span>
);
}
function CountCell({
contracted,
deployed,
}: {
contracted: number;
deployed: number;
}) {
const delta = deployed - contracted;
const hasData = contracted > 0 || deployed > 0;
if (!hasData) return <span className="text-muted-foreground text-xs"></span>;
const color =
delta === 0
? 'text-green-600 dark:text-green-400'
: delta > 0
? 'text-amber-600 dark:text-amber-400'
: 'text-red-600 dark:text-red-400';
return (
<span className={`text-sm font-medium tabular-nums ${color}`}>
{deployed}/{contracted}
</span>
);
}
function ClientRow({ row }: { row: CoverageRow }) {
const [expanded, setExpanded] = useState(false);
const hasAnyData =
row.contracted.servers + row.contracted.workstations + row.contracted.m365 +
row.deployed.servers + row.deployed.workstations > 0;
// Group service lines by contract
const byContract = useMemo(() => {
const map = new Map<number, { contract_name: string; lines: ServiceLine[] }>();
for (const l of row.lines) {
if (!map.has(l.contract_id)) {
map.set(l.contract_id, { contract_name: l.contract_name, lines: [] });
}
map.get(l.contract_id)!.lines.push(l);
}
return [...map.values()];
}, [row.lines]);
return (
<>
<TableRow
className="cursor-pointer hover:bg-muted/40 group"
onClick={() => setExpanded((v) => !v)}
>
{/* Expand toggle + Client */}
<TableCell className="font-medium py-2.5">
<div className="flex items-center gap-2">
<span className="text-muted-foreground group-hover:text-foreground transition-colors">
{expanded ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</span>
<span className="truncate max-w-[240px]">{row.company_name}</span>
</div>
</TableCell>
{/* Servers deployed/contracted */}
<TableCell className="text-center py-2.5">
<CountCell contracted={row.contracted.servers} deployed={row.deployed.servers} />
</TableCell>
{/* Workstations deployed/contracted */}
<TableCell className="text-center py-2.5">
<CountCell contracted={row.contracted.workstations} deployed={row.deployed.workstations} />
</TableCell>
{/* M365 contracted (no Veeam deployed count for M365) */}
<TableCell className="text-center py-2.5">
{row.contracted.m365 > 0 ? (
<span className="text-sm font-medium tabular-nums text-amber-600 dark:text-amber-400">
{row.contracted.m365}
</span>
) : (
<span className="text-muted-foreground text-xs"></span>
)}
</TableCell>
{/* Total service lines */}
<TableCell className="text-center py-2.5">
<span className="text-xs text-muted-foreground tabular-nums">{row.lines.length}</span>
</TableCell>
</TableRow>
{/* Expanded detail rows */}
{expanded && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={5} className="p-0 border-b">
<div className="bg-muted/20 px-4 py-3 space-y-3">
{byContract.length === 0 ? (
<p className="text-xs text-muted-foreground py-1">No contract service lines found.</p>
) : (
byContract.map((contract) => {
const backupLines = contract.lines.filter(
(l) => l.category === 'server' || l.category === 'workstation'
);
if (backupLines.length === 0) return null;
return (
<div key={contract.contract_name} className="space-y-1.5">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
{contract.contract_name}
</p>
<div className="rounded border overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="bg-muted/40 border-b">
<th className="px-3 py-1.5 text-left font-medium">Service</th>
<th className="px-3 py-1.5 text-left font-medium w-28">Category</th>
</tr>
</thead>
<tbody>
{backupLines.map((line) => (
<tr key={line.cs_id} className="border-b last:border-0 hover:bg-muted/30">
<td className="px-3 py-1.5">
{line.category === 'server' ? (
<Server className="inline h-3 w-3 mr-1.5 text-blue-500 shrink-0" />
) : (
<Monitor className="inline h-3 w-3 mr-1.5 text-purple-500 shrink-0" />
)}
{line.line_name}
</td>
<td className="px-3 py-1.5">
<span
className={`inline-block px-1.5 py-0.5 rounded text-[11px] font-medium ${CATEGORY_COLORS[line.category]}`}
>
{CATEGORY_LABELS[line.category]}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})
)}
</div>
</TableCell>
</TableRow>
)}
</>
);
}
export function ContractCoverageTable() {
const [rows, setRows] = useState<CoverageRow[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<'all' | 'gap' | 'over' | 'matched'>('all');
useEffect(() => {
fetch('/api/veeam/contract-coverage')
.then((r) => r.json())
.then((d) => setRows(d.rows ?? []))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const filtered = useMemo(() => {
return rows.filter((r) => {
const matchesSearch = r.company_name.toLowerCase().includes(search.toLowerCase());
if (!matchesSearch) return false;
if (filter === 'all') return true;
const serverDelta = r.deployed.servers - r.contracted.servers;
const wsDelta = r.deployed.workstations - r.contracted.workstations;
if (filter === 'gap') return serverDelta < 0 || wsDelta < 0;
if (filter === 'over') return serverDelta > 0 || wsDelta > 0;
if (filter === 'matched')
return (
r.contracted.servers > 0 || r.contracted.workstations > 0
? serverDelta === 0 && wsDelta === 0
: false
);
return true;
});
}, [rows, search, filter]);
const FILTERS: { key: typeof filter; label: string }[] = [
{ key: 'all', label: 'All' },
{ key: 'gap', label: 'Under-deployed' },
{ key: 'over', label: 'Over-deployed' },
{ key: 'matched', label: 'Matched' },
];
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search clients..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9 h-8 text-sm"
/>
</div>
<div className="flex gap-1.5">
{FILTERS.map((f) => (
<button
key={f.key}
onClick={() => setFilter(f.key)}
className={`px-3 py-1 rounded text-xs font-medium transition-colors border ${
filter === f.key
? 'bg-primary text-primary-foreground border-primary'
: 'bg-transparent text-muted-foreground border-border hover:border-foreground/40'
}`}
>
{f.label}
</button>
))}
</div>
<span className="text-xs text-muted-foreground ml-auto">{filtered.length} clients</span>
</div>
{/* Legend */}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-medium">Counts: deployed / contracted</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-green-500" /> Matched
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-amber-500" /> Over-deployed
</span>
<span className="flex items-center gap-1">
<span className="inline-block w-2 h-2 rounded-full bg-red-500" /> Gap
</span>
</div>
{/* Table */}
<div className="rounded-md border">
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<Table>
<TableHeader>
<TableRow className="bg-muted/40">
<TableHead className="text-xs">Client</TableHead>
<TableHead className="text-xs text-center w-28">
<div className="flex items-center justify-center gap-1">
<Server className="h-3 w-3" /> Servers
</div>
</TableHead>
<TableHead className="text-xs text-center w-28">
<div className="flex items-center justify-center gap-1">
<Monitor className="h-3 w-3" /> Workstations
</div>
</TableHead>
<TableHead className="text-xs text-center w-24">
<div className="flex items-center justify-center gap-1">
<Mail className="h-3 w-3" /> M365
</div>
</TableHead>
<TableHead className="text-xs text-center w-20">
<div className="flex items-center justify-center gap-1">
<Package className="h-3 w-3" /> Lines
</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground py-12">
No clients match the current filter
</TableCell>
</TableRow>
) : (
filtered.map((row) => <ClientRow key={row.company_id} row={row} />)
)}
</TableBody>
</Table>
)}
</div>
</div>
);
}