437 lines
18 KiB
TypeScript
437 lines
18 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
SafeIncidentSummary,
|
|
IncidentSeverity,
|
|
IncidentStatus,
|
|
OperationsRole,
|
|
} from './types';
|
|
|
|
interface OperationsIncidentsListProps {
|
|
role: OperationsRole;
|
|
onSelectIncident: (caseRef: string) => void;
|
|
onCreateIncident: () => void;
|
|
}
|
|
|
|
export function OperationsIncidentsList({
|
|
role,
|
|
onSelectIncident,
|
|
onCreateIncident,
|
|
}: OperationsIncidentsListProps) {
|
|
const [incidents, setIncidents] = useState<SafeIncidentSummary[]>([]);
|
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
// Filters
|
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
|
const [severityFilter, setSeverityFilter] = useState<string>('all');
|
|
const [caseSearch, setCaseSearch] = useState<string>('');
|
|
|
|
async function fetchIncidents(cursor?: string) {
|
|
if (cursor) {
|
|
setIsLoadingMore(true);
|
|
} else {
|
|
setIsLoading(true);
|
|
setErrorMessage(null);
|
|
}
|
|
|
|
const params = new URLSearchParams();
|
|
if (statusFilter !== 'all') params.set('status', statusFilter);
|
|
if (severityFilter !== 'all') params.set('severity', severityFilter);
|
|
if (caseSearch.trim()) params.set('case_reference', caseSearch.trim());
|
|
if (cursor) params.set('cursor', cursor);
|
|
|
|
try {
|
|
const response = await fetch(`/api/ops/incidents?${params.toString()}`, {
|
|
method: 'GET',
|
|
headers: { accept: 'application/json' },
|
|
cache: 'no-store',
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
setErrorMessage('Your Operations session has expired.');
|
|
setIsLoading(false);
|
|
setIsLoadingMore(false);
|
|
return;
|
|
}
|
|
if (response.status === 403) {
|
|
setErrorMessage('You do not have authority to view incidents.');
|
|
setIsLoading(false);
|
|
setIsLoadingMore(false);
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
setErrorMessage('Failed to load operational incidents.');
|
|
setIsLoading(false);
|
|
setIsLoadingMore(false);
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
const items: SafeIncidentSummary[] = Array.isArray(data?.items) ? data.items : [];
|
|
|
|
if (cursor) {
|
|
setIncidents((prev) => [...prev, ...items]);
|
|
} else {
|
|
setIncidents(items);
|
|
}
|
|
setNextCursor(data?.next_cursor ?? null);
|
|
} catch {
|
|
setErrorMessage('Unable to connect to Operations platform.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
setIsLoadingMore(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchIncidents();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [statusFilter, severityFilter]);
|
|
|
|
function handleSearchSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
fetchIncidents();
|
|
}
|
|
|
|
function getSeverityBadge(severity: IncidentSeverity) {
|
|
switch (severity) {
|
|
case 'critical':
|
|
return (
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-bold tracking-wider bg-error/15 text-error border border-error/30">
|
|
CRITICAL
|
|
</span>
|
|
);
|
|
case 'high':
|
|
return (
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-bold tracking-wider bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
|
HIGH
|
|
</span>
|
|
);
|
|
case 'medium':
|
|
return (
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-primary-container/15 text-primary-container border border-primary-container/30">
|
|
MEDIUM
|
|
</span>
|
|
);
|
|
case 'low':
|
|
default:
|
|
return (
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-surface-container text-on-surface-variant border border-outline-variant/30">
|
|
LOW
|
|
</span>
|
|
);
|
|
}
|
|
}
|
|
|
|
function getStatusBadge(status: IncidentStatus) {
|
|
switch (status) {
|
|
case 'open':
|
|
return (
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-blue-500/15 text-blue-400 border border-blue-500/30">
|
|
OPEN
|
|
</span>
|
|
);
|
|
case 'investigating':
|
|
return (
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
|
INVESTIGATING
|
|
</span>
|
|
);
|
|
case 'monitoring':
|
|
return (
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-purple-500/15 text-purple-300 border border-purple-500/30">
|
|
MONITORING
|
|
</span>
|
|
);
|
|
case 'resolved':
|
|
return (
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
|
RESOLVED
|
|
</span>
|
|
);
|
|
case 'closed':
|
|
return (
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-surface-container-high text-outline border border-outline-variant/30">
|
|
CLOSED
|
|
</span>
|
|
);
|
|
case 'archived':
|
|
default:
|
|
return (
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-surface-container-highest/60 text-outline border border-outline-variant/20">
|
|
ARCHIVED
|
|
</span>
|
|
);
|
|
}
|
|
}
|
|
|
|
function formatDate(iso: string) {
|
|
try {
|
|
const d = new Date(iso);
|
|
return d.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
hour12: false,
|
|
});
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Page Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-outline-variant/20 pb-4">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-xl font-headline-sm font-bold tracking-tight text-on-surface uppercase">
|
|
Operational Incidents
|
|
</h1>
|
|
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase tracking-wider bg-primary-container/10 text-primary-container border border-primary-container/20">
|
|
OPS-4
|
|
</span>
|
|
</div>
|
|
<p className="text-xs text-on-surface-variant mt-0.5">
|
|
Isolated reliability & incident tracking with immutable case references.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => fetchIncidents()}
|
|
disabled={isLoading}
|
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-medium text-on-surface-variant hover:text-on-surface hover:bg-surface-container-high transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
title="Refresh incidents list"
|
|
>
|
|
<span className={`material-symbols-outlined text-[16px] ${isLoading ? 'animate-spin' : ''}`}>
|
|
refresh
|
|
</span>
|
|
<span>Refresh</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={onCreateIncident}
|
|
className="flex items-center gap-1.5 px-3.5 py-2 rounded-lg bg-primary-container text-on-primary-container text-xs font-bold shadow-md hover:bg-primary-container/90 transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">add_circle</span>
|
|
<span>New Incident</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filter Bar */}
|
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 bg-surface-container-low p-3.5 rounded-xl border border-outline-variant/20">
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
{/* Status Filter */}
|
|
<div className="flex items-center gap-1.5 text-xs">
|
|
<span className="text-outline font-medium">Status:</span>
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
className="bg-surface-container px-2.5 py-1.5 rounded-lg border border-outline-variant/30 text-xs text-on-surface font-medium focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
>
|
|
<option value="all">All Statuses</option>
|
|
<option value="open">Open</option>
|
|
<option value="investigating">Investigating</option>
|
|
<option value="monitoring">Monitoring</option>
|
|
<option value="resolved">Resolved</option>
|
|
<option value="closed">Closed</option>
|
|
<option value="archived">Archived</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Severity Filter */}
|
|
<div className="flex items-center gap-1.5 text-xs">
|
|
<span className="text-outline font-medium">Severity:</span>
|
|
<select
|
|
value={severityFilter}
|
|
onChange={(e) => setSeverityFilter(e.target.value)}
|
|
className="bg-surface-container px-2.5 py-1.5 rounded-lg border border-outline-variant/30 text-xs text-on-surface font-medium focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
>
|
|
<option value="all">All Severities</option>
|
|
<option value="critical">Critical</option>
|
|
<option value="high">High</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="low">Low</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Case Reference Search */}
|
|
<form onSubmit={handleSearchSubmit} className="flex items-center gap-2">
|
|
<div className="relative flex-1 md:w-56">
|
|
<input
|
|
type="text"
|
|
value={caseSearch}
|
|
onChange={(e) => setCaseSearch(e.target.value)}
|
|
placeholder="Search Case Reference..."
|
|
className="w-full bg-surface-container px-3 py-1.5 pr-8 rounded-lg border border-outline-variant/30 text-xs font-mono text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
/>
|
|
{caseSearch && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setCaseSearch('');
|
|
fetchIncidents();
|
|
}}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-outline hover:text-on-surface"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">close</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
className="px-3 py-1.5 bg-surface-container-high border border-outline-variant/40 rounded-lg text-xs font-medium text-on-surface hover:bg-surface-container-highest transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
>
|
|
Filter
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Error Banner */}
|
|
{errorMessage && (
|
|
<div className="p-4 rounded-xl bg-error/10 border border-error/30 text-error flex items-center gap-3 text-xs">
|
|
<span className="material-symbols-outlined text-[18px]">error</span>
|
|
<span>{errorMessage}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Incidents Content: Desktop Table & Mobile Cards */}
|
|
{isLoading ? (
|
|
<div className="p-12 text-center text-outline bg-surface-container-lowest rounded-xl border border-outline-variant/20 flex flex-col items-center justify-center gap-3">
|
|
<span className="material-symbols-outlined text-3xl animate-spin text-primary-container">
|
|
progress_activity
|
|
</span>
|
|
<span className="text-xs font-medium">Loading operational incidents...</span>
|
|
</div>
|
|
) : incidents.length === 0 ? (
|
|
<div className="p-12 text-center text-outline bg-surface-container-lowest rounded-xl border border-outline-variant/20 flex flex-col items-center justify-center gap-3">
|
|
<span className="material-symbols-outlined text-3xl text-outline-variant">
|
|
assignment_turned_in
|
|
</span>
|
|
<div className="space-y-1">
|
|
<p className="text-xs font-semibold text-on-surface">No operational incidents found</p>
|
|
<p className="text-[11px] text-outline">
|
|
{statusFilter !== 'all' || severityFilter !== 'all' || caseSearch
|
|
? 'No incidents match the selected filter criteria.'
|
|
: 'No incidents recorded in the Operations platform.'}
|
|
</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={onCreateIncident}
|
|
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-container text-on-primary-container text-xs font-semibold hover:bg-primary-container/90 transition-all"
|
|
>
|
|
<span className="material-symbols-outlined text-[14px]">add</span>
|
|
<span>Create First Incident</span>
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Desktop Table View (hidden on small screens) */}
|
|
<div className="hidden md:block overflow-hidden rounded-xl border border-outline-variant/30 bg-surface-container-lowest shadow-sm">
|
|
<table className="w-full text-left text-xs border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-outline-variant/20 bg-surface-container-low text-[11px] font-mono text-outline uppercase tracking-wider">
|
|
<th className="py-3 px-4 font-semibold">CASE</th>
|
|
<th className="py-3 px-4 font-semibold">TITLE</th>
|
|
<th className="py-3 px-4 font-semibold">SEVERITY</th>
|
|
<th className="py-3 px-4 font-semibold">STATUS</th>
|
|
<th className="py-3 px-4 font-semibold">UPDATED</th>
|
|
<th className="py-3 px-4 font-semibold">CREATED</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-outline-variant/10 text-on-surface">
|
|
{incidents.map((incident) => (
|
|
<tr
|
|
key={incident.case_reference}
|
|
onClick={() => onSelectIncident(incident.case_reference)}
|
|
className="hover:bg-surface-container/50 cursor-pointer transition-colors group focus-within:bg-surface-container"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onSelectIncident(incident.case_reference);
|
|
}
|
|
}}
|
|
>
|
|
<td className="py-3 px-4 font-mono font-bold text-primary-container group-hover:underline">
|
|
{incident.case_reference}
|
|
</td>
|
|
<td className="py-3 px-4 font-medium text-on-surface max-w-xs truncate">
|
|
{incident.title}
|
|
</td>
|
|
<td className="py-3 px-4">{getSeverityBadge(incident.severity)}</td>
|
|
<td className="py-3 px-4">{getStatusBadge(incident.status)}</td>
|
|
<td className="py-3 px-4 text-[11px] font-mono text-on-surface-variant">
|
|
{formatDate(incident.updated_at)}
|
|
</td>
|
|
<td className="py-3 px-4 text-[11px] font-mono text-outline">
|
|
{formatDate(incident.created_at)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Mobile Stacked Card View (shown only on small screens) */}
|
|
<div className="md:hidden space-y-3">
|
|
{incidents.map((incident) => (
|
|
<button
|
|
key={incident.case_reference}
|
|
type="button"
|
|
onClick={() => onSelectIncident(incident.case_reference)}
|
|
className="w-full text-left p-4 rounded-xl border border-outline-variant/20 bg-surface-container-lowest hover:bg-surface-container-low transition-colors shadow-sm focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none space-y-2.5"
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="font-mono font-bold text-xs text-primary-container">
|
|
{incident.case_reference}
|
|
</span>
|
|
<div className="flex items-center gap-1.5">
|
|
{getSeverityBadge(incident.severity)}
|
|
{getStatusBadge(incident.status)}
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-xs font-semibold text-on-surface line-clamp-2">
|
|
{incident.title}
|
|
</p>
|
|
|
|
<div className="flex items-center justify-between text-[10px] font-mono text-outline pt-1 border-t border-outline-variant/10">
|
|
<span>Updated: {formatDate(incident.updated_at)}</span>
|
|
<span className="text-primary-fixed-dim">View Details →</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Cursor Pagination */}
|
|
{nextCursor && (
|
|
<div className="pt-2 text-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => fetchIncidents(nextCursor)}
|
|
disabled={isLoadingMore}
|
|
className="px-4 py-2 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-semibold text-on-surface hover:bg-surface-container-high transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
|
>
|
|
{isLoadingMore ? 'Loading More...' : 'Load More Incidents'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|