MotorSignal-App/website/components/ops/operations-incidents-list.tsx

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>
);
}