wulf-pulse/components/backup/compliance-detail-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

376 lines
14 KiB
TypeScript

'use client';
import { useState, useCallback } from 'react';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Search, CheckCircle2, XCircle, Loader2, ExternalLink, Package } from 'lucide-react';
interface ComplianceMismatch {
id: number;
company_id: number | null;
company_name: string | null;
configuration_item_id: number | null;
veeam_workload_uid: string | null;
mismatch_type: string;
backup_type_udf: string | null;
device_name: string;
contract_name: string | null;
veeam_workload_name: string | null;
billing_covered: boolean | null;
billing_contract_name: string | null;
billing_contracted_qty: number | null;
billing_contract_id: number | null;
coverage_source: string | null;
}
interface ContractService {
id: number;
service_id: number | null;
display_name: string;
description: string | null;
unit_price: number | null;
unit_cost: number | null;
quantity: number | null;
adjusted_price: number | null;
period_label: string | null;
start_date: string | null;
end_date: string | null;
}
interface ContractDetail {
id: number;
contract_name: string;
company_name: string;
status: number;
contract_type: number | null;
start_date: string | null;
end_date: string | null;
description: string | null;
}
interface ComplianceDetailTableProps {
mismatches: ComplianceMismatch[];
}
const BACKUP_SERVICE_PATTERNS = [
/workstation.*backup/i,
/w\/ backup/i,
/windows server/i,
/server virtual/i,
/server phys/i,
/esxi host/i,
/wulf 365 it complete (endpoint|server)/i,
/wulf it complete \((server|endpoint)\)/i,
];
function isBackupService(name: string): boolean {
return BACKUP_SERVICE_PATTERNS.some((re) => re.test(name));
}
function ContractCoverageModal({
contractId,
companyName,
open,
onClose,
}: {
contractId: number | null;
companyName: string | null;
open: boolean;
onClose: () => void;
}) {
const [data, setData] = useState<{ contract: ContractDetail; services: ContractService[] } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loadedId, setLoadedId] = useState<number | null>(null);
const load = useCallback(async (id: number) => {
if (loadedId === id) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/data/contracts/${id}/services`);
if (!res.ok) throw new Error('Failed to load contract details');
const json = await res.json();
setData(json);
setLoadedId(id);
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [loadedId]);
if (open && contractId && loadedId !== contractId && !loading) {
load(contractId);
}
const contract = data?.contract;
const services = data?.services ?? [];
const backupServices = services.filter((s) => isBackupService(s.display_name));
const otherServices = services.filter((s) => !isBackupService(s.display_name));
return (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="h-4 w-4 text-muted-foreground" />
Contract Coverage
{contract && (
<span className="text-muted-foreground font-normal text-sm ml-1"> {contract.company_name}</span>
)}
</DialogTitle>
</DialogHeader>
{loading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{error && (
<p className="text-sm text-red-500 py-4">{error}</p>
)}
{!loading && !error && contract && (
<div className="space-y-5">
{/* Contract header */}
<div className="rounded-lg border bg-muted/30 p-4 space-y-2">
<div className="flex items-start justify-between gap-4">
<div>
<p className="font-semibold text-base">{contract.contract_name}</p>
<p className="text-sm text-muted-foreground">{contract.company_name}</p>
</div>
<a
href={`https://ww1.autotask.net/contracts/views/contractView.asp?contractID=${contract.id}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-500 hover:text-blue-600 shrink-0 mt-0.5"
>
View in Autotask <ExternalLink className="h-3 w-3" />
</a>
</div>
<div className="flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-foreground">
{contract.start_date && (
<span>Start: {new Date(contract.start_date).toLocaleDateString()}</span>
)}
{contract.end_date && (
<span>End: {new Date(contract.end_date).toLocaleDateString()}</span>
)}
<span className="flex items-center gap-1">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${contract.status === 1 ? 'bg-green-500' : 'bg-gray-400'}`} />
{contract.status === 1 ? 'Active' : 'Inactive'}
</span>
</div>
</div>
{/* Backup-relevant services */}
{backupServices.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-green-600 dark:text-green-400">Backup-Covered Services</p>
<ServiceTable services={backupServices} highlight />
</div>
)}
{/* Other services */}
{otherServices.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">All Services ({services.length})</p>
<ServiceTable services={otherServices} />
</div>
)}
{services.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">No service lines found for this contract.</p>
)}
</div>
)}
</DialogContent>
</Dialog>
);
}
function ServiceTable({ services, highlight }: { services: ContractService[]; highlight?: boolean }) {
return (
<div className="rounded-md border overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-muted/40">
<TableHead className="text-xs">Service</TableHead>
<TableHead className="text-xs w-28 text-right">Unit Price</TableHead>
<TableHead className="text-xs w-28 text-right">Unit Cost</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.map((s) => (
<TableRow key={s.id} className={highlight ? 'bg-green-500/5' : undefined}>
<TableCell className="text-sm py-2">
{highlight && <CheckCircle2 className="inline h-3 w-3 text-green-500 mr-1.5 shrink-0" />}
{s.display_name}
</TableCell>
<TableCell className="text-sm py-2 text-right">
{s.unit_price != null ? `$${Number(s.unit_price).toFixed(2)}` : '—'}
</TableCell>
<TableCell className="text-sm py-2 text-right text-muted-foreground">
{s.unit_cost != null ? `$${Number(s.unit_cost).toFixed(2)}` : '—'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
export function ComplianceDetailTable({ mismatches }: ComplianceDetailTableProps) {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [modalContractId, setModalContractId] = useState<number | null>(null);
const [modalCompanyName, setModalCompanyName] = useState<string | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const openModal = (contractId: number, companyName: string | null) => {
setModalContractId(contractId);
setModalCompanyName(companyName);
setModalOpen(true);
};
const filtered = mismatches.filter((m) => {
const matchesSearch =
(m.device_name || '').toLowerCase().includes(search.toLowerCase()) ||
(m.company_name || '').toLowerCase().includes(search.toLowerCase());
const matchesType =
typeFilter === 'all' || m.mismatch_type === typeFilter;
return matchesSearch && matchesType;
});
return (
<div className="space-y-4">
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search devices or companies..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<div className="flex gap-2">
{[
{ key: 'all', label: 'All' },
{ key: 'contracted_not_backed_up', label: 'Missing Backup' },
{ key: 'backed_up_not_contracted', label: 'No Contract' },
].map((f) => (
<Button
key={f.key}
variant={typeFilter === f.key ? 'default' : 'outline'}
size="sm"
onClick={() => setTypeFilter(f.key)}
>
{f.label}
</Button>
))}
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Company</TableHead>
<TableHead>Device</TableHead>
<TableHead>Issue</TableHead>
<TableHead>Backup UDF</TableHead>
<TableHead>Contract Coverage</TableHead>
<TableHead>Veeam Workload</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center text-muted-foreground py-8">
{mismatches.length === 0
? 'No compliance issues found — all clear!'
: 'No matching results'}
</TableCell>
</TableRow>
) : (
filtered.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-medium">{m.company_name || '-'}</TableCell>
<TableCell>{m.device_name}</TableCell>
<TableCell>
<Badge
className={
m.mismatch_type === 'contracted_not_backed_up'
? 'bg-red-500/10 text-red-500'
: 'bg-yellow-500/10 text-yellow-500'
}
>
{m.mismatch_type === 'contracted_not_backed_up'
? 'Missing Backup'
: 'No Contract'}
</Badge>
</TableCell>
<TableCell className="text-sm">{m.backup_type_udf || '-'}</TableCell>
<TableCell className="text-sm">
{m.billing_covered && m.billing_contract_id ? (
<button
onClick={() => openModal(m.billing_contract_id!, m.company_name)}
className="flex items-center gap-1.5 text-left hover:underline cursor-pointer group"
>
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
<span className="text-green-700 dark:text-green-400 font-medium group-hover:underline">
{m.billing_contract_name || 'Active'}
</span>
{m.billing_contracted_qty != null && (
<span className="text-muted-foreground">({m.billing_contracted_qty} seats)</span>
)}
</button>
) : m.billing_covered ? (
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500 shrink-0" />
<span className="text-green-700 dark:text-green-400 font-medium">
{m.billing_contract_name || 'Active'}
</span>
</div>
) : (
<div className="flex items-center gap-1.5">
<XCircle className="h-3.5 w-3.5 text-red-500 shrink-0" />
<span className="text-red-700 dark:text-red-400">No backup contract</span>
</div>
)}
</TableCell>
<TableCell className="text-sm">{m.veeam_workload_name || '-'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
<ContractCoverageModal
contractId={modalContractId}
companyName={modalCompanyName}
open={modalOpen}
onClose={() => setModalOpen(false)}
/>
</div>
);
}