- 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
362 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|