- DetailModal: thread tz through resolveLabel(...) module helper + default export's 3 inline date/time calls. - IntegrationStatusTabs: thread tz through fmtDate helper + VeeamTab sub-component prop. - SyncScheduler: thread tz into closure-scoped formatDate helper. - audit-log-table, user-table, user-sessions, active-sessions: inline toLocale calls in component body. - analysis-view: useUserTimezone in AnalysisView; thread tz into 4 toLocaleString calls. - resolution-trend, volume-trend (recharts): module-scope fmtDate(iso) → fmtDate(iso, tz); useUserTimezone in named export; thread tz into axis tickFormatter + tooltip labelFormatter. - ticket-detail-modal: thread tz into formatDate arrow inside TicketDetailModal. - TimelineView: useUserTimezone; thread tz into 4 toLocale*String calls (hour/day/month/event-time formatters). - ScoreCard: useUserTimezone in AggregateScoreCard; thread tz into the date-range latest call. - addigy-tab: useUserTimezone in AddigyTab; thread tz into 2 inline calls. - activity-sparkline: module-scope fmtHour(iso) → fmtHour(iso, tz); useUserTimezone in ActivitySparkline; update 3 callsites in title/aria. - compliance-detail-table: thread tz from ComplianceDetailTable into ContractCoverageModal sub-component (2 inline date calls). - company-backup-detail: module-scope formatDate(d) → formatDate(d, tz); useUserTimezone in CompanyBackupDetail; update 3 callsites. Migrates 31 of 81 audit leak callsites.
381 lines
14 KiB
TypeScript
381 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';
|
|
import { useUserTimezone } from '@/lib/hooks/use-user-timezone';
|
|
|
|
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,
|
|
tz,
|
|
}: {
|
|
contractId: number | null;
|
|
companyName: string | null;
|
|
open: boolean;
|
|
onClose: () => void;
|
|
tz: string;
|
|
}) {
|
|
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(undefined, { timeZone: tz })}</span>
|
|
)}
|
|
{contract.end_date && (
|
|
<span>End: {new Date(contract.end_date).toLocaleDateString(undefined, { timeZone: tz })}</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 tz = useUserTimezone();
|
|
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)}
|
|
tz={tz}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|