798 lines
31 KiB
TypeScript
798 lines
31 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
SafeIncidentDetail,
|
|
SafeIncidentNote,
|
|
IncidentSeverity,
|
|
IncidentStatus,
|
|
OperationsRole,
|
|
} from './types';
|
|
import {
|
|
OperationsIncidentModal,
|
|
IncidentModalAction,
|
|
} from './operations-incident-modal';
|
|
import { isValidStatusTransition } from '../../lib/ops-data';
|
|
|
|
interface OperationsIncidentDetailProps {
|
|
caseReference: string;
|
|
role: OperationsRole;
|
|
onBack: () => void;
|
|
}
|
|
|
|
export function OperationsIncidentDetail({
|
|
caseReference,
|
|
role,
|
|
onBack,
|
|
}: OperationsIncidentDetailProps) {
|
|
const isOwner = role === 'owner';
|
|
const [incident, setIncident] = useState<SafeIncidentDetail | null>(null);
|
|
const [notes, setNotes] = useState<SafeIncidentNote[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
|
|
// Note form state
|
|
const [newNoteBody, setNewNoteBody] = useState('');
|
|
const [isAddingNote, setIsAddingNote] = useState(false);
|
|
const [noteError, setNoteError] = useState<string | null>(null);
|
|
|
|
// Mutation state
|
|
const [isMutating, setIsMutating] = useState(false);
|
|
const [mutationSuccess, setMutationSuccess] = useState<string | null>(null);
|
|
|
|
// Confirmation Modal state
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [modalAction, setModalAction] = useState<IncidentModalAction>('close');
|
|
const [isSubmittingModal, setIsSubmittingModal] = useState(false);
|
|
|
|
async function fetchIncidentData() {
|
|
setIsLoading(true);
|
|
setErrorMessage(null);
|
|
|
|
try {
|
|
const [detailRes, notesRes] = await Promise.all([
|
|
fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}`, {
|
|
method: 'GET',
|
|
headers: { accept: 'application/json' },
|
|
cache: 'no-store',
|
|
}),
|
|
fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}/notes`, {
|
|
method: 'GET',
|
|
headers: { accept: 'application/json' },
|
|
cache: 'no-store',
|
|
}),
|
|
]);
|
|
|
|
if (detailRes.status === 401 || notesRes.status === 401) {
|
|
setErrorMessage('Your Operations session has expired.');
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
if (detailRes.status === 403 || notesRes.status === 403) {
|
|
setErrorMessage('You do not have authority to view this incident.');
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
if (detailRes.status === 404) {
|
|
setErrorMessage('Incident not found.');
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
if (!detailRes.ok) {
|
|
setErrorMessage('Failed to load incident detail.');
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
const detailData = (await detailRes.json()) as SafeIncidentDetail;
|
|
setIncident(detailData);
|
|
|
|
if (notesRes.ok) {
|
|
const notesData = await notesRes.json();
|
|
setNotes(Array.isArray(notesData?.notes) ? notesData.notes : []);
|
|
}
|
|
} catch {
|
|
setErrorMessage('Unable to connect to Operations platform.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchIncidentData();
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [caseReference]);
|
|
|
|
// Handle Add Note
|
|
async function handleAddNote(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
if (!newNoteBody.trim()) {
|
|
setNoteError('Note body cannot be empty.');
|
|
return;
|
|
}
|
|
|
|
setIsAddingNote(true);
|
|
setNoteError(null);
|
|
|
|
try {
|
|
const res = await fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}/notes`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
accept: 'application/json',
|
|
},
|
|
body: JSON.stringify({ body: newNoteBody.trim() }),
|
|
});
|
|
|
|
if (res.status === 401) {
|
|
setNoteError('Your Operations session has expired.');
|
|
setIsAddingNote(false);
|
|
return;
|
|
}
|
|
if (res.status === 403) {
|
|
setNoteError('You do not have authority to add notes.');
|
|
setIsAddingNote(false);
|
|
return;
|
|
}
|
|
if (res.status === 409) {
|
|
setNoteError('Archived incidents cannot be modified.');
|
|
setIsAddingNote(false);
|
|
return;
|
|
}
|
|
if (!res.ok) {
|
|
setNoteError('Failed to append note.');
|
|
setIsAddingNote(false);
|
|
return;
|
|
}
|
|
|
|
const addedNote = (await res.json()) as SafeIncidentNote;
|
|
setNotes((prev) => [addedNote, ...prev]);
|
|
setNewNoteBody('');
|
|
} catch {
|
|
setNoteError('Unable to connect to Operations platform.');
|
|
} finally {
|
|
setIsAddingNote(false);
|
|
}
|
|
}
|
|
|
|
// Handle Severity Change
|
|
async function handleSeverityChange(newSeverity: IncidentSeverity) {
|
|
if (!incident || incident.severity === newSeverity || isMutating) return;
|
|
setIsMutating(true);
|
|
setMutationSuccess(null);
|
|
setErrorMessage(null);
|
|
|
|
try {
|
|
const res = await fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
accept: 'application/json',
|
|
},
|
|
body: JSON.stringify({ severity: newSeverity }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => null);
|
|
setErrorMessage(data?.detail ?? 'Failed to update severity.');
|
|
setIsMutating(false);
|
|
return;
|
|
}
|
|
|
|
const updated = (await res.json()) as SafeIncidentDetail;
|
|
setIncident(updated);
|
|
setMutationSuccess(`Severity updated to ${newSeverity.toUpperCase()}`);
|
|
setTimeout(() => setMutationSuccess(null), 4000);
|
|
} catch {
|
|
setErrorMessage('Unable to connect to Operations platform.');
|
|
} finally {
|
|
setIsMutating(false);
|
|
}
|
|
}
|
|
|
|
// Handle Status Transition via Select
|
|
async function handleStatusChange(targetStatus: IncidentStatus) {
|
|
if (!incident || incident.status === targetStatus || isMutating) return;
|
|
|
|
// If target status is closed, route to modal to capture resolution
|
|
if (targetStatus === 'closed') {
|
|
setModalAction('close');
|
|
setModalOpen(true);
|
|
return;
|
|
}
|
|
|
|
// If target status is archived, route to modal
|
|
if (targetStatus === 'archived') {
|
|
setModalAction('archive');
|
|
setModalOpen(true);
|
|
return;
|
|
}
|
|
|
|
// If target status is resolved without existing resolution, prompt for modal
|
|
if (targetStatus === 'resolved' && (!incident.resolution || !incident.resolution.trim())) {
|
|
setModalAction('close');
|
|
setModalOpen(true);
|
|
return;
|
|
}
|
|
|
|
setIsMutating(true);
|
|
setMutationSuccess(null);
|
|
setErrorMessage(null);
|
|
|
|
try {
|
|
const res = await fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
accept: 'application/json',
|
|
},
|
|
body: JSON.stringify({ status: targetStatus }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => null);
|
|
setErrorMessage(data?.detail ?? 'Failed to update incident status.');
|
|
setIsMutating(false);
|
|
return;
|
|
}
|
|
|
|
const updated = (await res.json()) as SafeIncidentDetail;
|
|
setIncident(updated);
|
|
setMutationSuccess(`Status transitioned to ${targetStatus.toUpperCase()}`);
|
|
setTimeout(() => setMutationSuccess(null), 4000);
|
|
} catch {
|
|
setErrorMessage('Unable to connect to Operations platform.');
|
|
} finally {
|
|
setIsMutating(false);
|
|
}
|
|
}
|
|
|
|
// Handle Modal Confirmation
|
|
async function handleModalConfirm(modalResolution?: string) {
|
|
if (!incident || isSubmittingModal) return;
|
|
setIsSubmittingModal(true);
|
|
setErrorMessage(null);
|
|
|
|
let patchPayload: Record<string, string> = {};
|
|
if (modalAction === 'close') {
|
|
patchPayload = {
|
|
status: 'closed',
|
|
resolution: modalResolution ?? '',
|
|
};
|
|
} else if (modalAction === 'reopen') {
|
|
patchPayload = { status: 'investigating' };
|
|
} else if (modalAction === 'archive') {
|
|
patchPayload = { status: 'archived' };
|
|
}
|
|
|
|
try {
|
|
const res = await fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}`, {
|
|
method: 'PATCH',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
accept: 'application/json',
|
|
},
|
|
body: JSON.stringify(patchPayload),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const data = await res.json().catch(() => null);
|
|
setErrorMessage(data?.detail ?? `Failed to perform ${modalAction} action.`);
|
|
setIsSubmittingModal(false);
|
|
setModalOpen(false);
|
|
return;
|
|
}
|
|
|
|
const updated = (await res.json()) as SafeIncidentDetail;
|
|
setIncident(updated);
|
|
setModalOpen(false);
|
|
setMutationSuccess(`Incident successfully ${modalAction === 'close' ? 'closed' : modalAction === 'reopen' ? 'reopened' : 'archived'}.`);
|
|
setTimeout(() => setMutationSuccess(null), 4000);
|
|
} catch {
|
|
setErrorMessage('Unable to connect to Operations platform.');
|
|
} finally {
|
|
setIsSubmittingModal(false);
|
|
}
|
|
}
|
|
|
|
function formatDate(iso?: string | null) {
|
|
if (!iso) return '—';
|
|
try {
|
|
const d = new Date(iso);
|
|
return d.toLocaleString('en-US', {
|
|
month: 'short',
|
|
day: 'numeric',
|
|
year: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit',
|
|
hour12: false,
|
|
});
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function getSeverityBadge(severity: IncidentSeverity) {
|
|
switch (severity) {
|
|
case 'critical':
|
|
return (
|
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-semibold bg-error/15 text-error border border-error/30">
|
|
Critical
|
|
</span>
|
|
);
|
|
case 'high':
|
|
return (
|
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
|
High
|
|
</span>
|
|
);
|
|
case 'medium':
|
|
return (
|
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-semibold bg-primary-container/15 text-primary-container border border-primary-container/30">
|
|
Medium
|
|
</span>
|
|
);
|
|
case 'low':
|
|
default:
|
|
return (
|
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-medium 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.5 py-1 rounded text-xs font-mono font-semibold bg-blue-500/15 text-blue-400 border border-blue-500/30">
|
|
Open
|
|
</span>
|
|
);
|
|
case 'investigating':
|
|
return (
|
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-semibold bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
|
Investigating
|
|
</span>
|
|
);
|
|
case 'monitoring':
|
|
return (
|
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-semibold bg-purple-500/15 text-purple-300 border border-purple-500/30">
|
|
Monitoring
|
|
</span>
|
|
);
|
|
case 'resolved':
|
|
return (
|
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-semibold bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
|
Resolved
|
|
</span>
|
|
);
|
|
case 'closed':
|
|
return (
|
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-medium bg-surface-container-high text-outline border border-outline-variant/30">
|
|
Closed
|
|
</span>
|
|
);
|
|
case 'archived':
|
|
default:
|
|
return (
|
|
<span className="px-2.5 py-1 rounded text-xs font-mono font-medium bg-surface-container-highest/60 text-outline border border-outline-variant/20">
|
|
Archived
|
|
</span>
|
|
);
|
|
}
|
|
}
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="p-16 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 incident details...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!incident) {
|
|
return (
|
|
<div className="space-y-4">
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-semibold text-on-surface hover:bg-surface-container-high transition-colors"
|
|
>
|
|
← Back to Incidents
|
|
</button>
|
|
<div className="p-8 text-center text-error bg-error/10 border border-error/30 rounded-xl text-xs">
|
|
{errorMessage ?? 'Incident not found.'}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isArchived = incident.status === 'archived';
|
|
const isClosed = incident.status === 'closed';
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Navigation & Actions Topbar */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-5 border-b border-outline-variant/30">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={onBack}
|
|
className="inline-flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-surface-container hover:bg-surface-container-high border border-outline-variant/30 text-xs sm:text-sm font-semibold text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none cursor-pointer"
|
|
>
|
|
← Back
|
|
</button>
|
|
<span className="font-mono font-bold text-sm sm:text-base text-primary-container">
|
|
{incident.case_reference}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => fetchIncidentData()}
|
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-surface-container border border-outline-variant/30 text-xs sm:text-sm font-medium text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none cursor-pointer"
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">refresh</span>
|
|
<span>Refresh</span>
|
|
</button>
|
|
|
|
{/* Quick Modal Triggers */}
|
|
{!isArchived && !isClosed && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setModalAction('close');
|
|
setModalOpen(true);
|
|
}}
|
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-surface-container-high border border-outline-variant/40 text-xs sm:text-sm font-semibold text-on-surface hover:bg-surface-container-highest transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none cursor-pointer"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px] text-emerald-400">
|
|
task_alt
|
|
</span>
|
|
<span>Close Incident...</span>
|
|
</button>
|
|
)}
|
|
|
|
{isClosed && isOwner && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setModalAction('reopen');
|
|
setModalOpen(true);
|
|
}}
|
|
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-amber-500/15 border border-amber-500/30 text-xs font-bold text-amber-300 hover:bg-amber-500/25 transition-all focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:outline-none"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">replay</span>
|
|
<span>Reopen (Owner)</span>
|
|
</button>
|
|
)}
|
|
|
|
{!isArchived && isOwner && (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setModalAction('archive');
|
|
setModalOpen(true);
|
|
}}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-error/10 border border-error/25 text-xs font-semibold text-error hover:bg-error/20 transition-all focus-visible:ring-2 focus-visible:ring-error focus-visible:outline-none"
|
|
>
|
|
<span className="material-symbols-outlined text-[16px]">archive</span>
|
|
<span>Archive (Owner)</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Alerts */}
|
|
{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>
|
|
)}
|
|
|
|
{mutationSuccess && (
|
|
<div className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 flex items-center gap-3 text-xs">
|
|
<span className="material-symbols-outlined text-[18px]">check_circle</span>
|
|
<span>{mutationSuccess}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Incident Header Card */}
|
|
<div className="bg-surface-container-low p-6 sm:p-8 rounded-2xl border border-outline-variant/30 shadow-sm space-y-6">
|
|
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap items-center gap-2.5">
|
|
<span className="font-mono text-xs sm:text-sm font-bold text-primary-fixed-dim">
|
|
{incident.case_reference}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
{getSeverityBadge(incident.severity)}
|
|
{getStatusBadge(incident.status)}
|
|
</div>
|
|
</div>
|
|
<h1 className="font-headline-xl text-2xl sm:text-3xl font-extrabold text-on-surface tracking-tight mt-1">
|
|
{incident.title}
|
|
</h1>
|
|
</div>
|
|
|
|
{/* Mutation Selects (Active incidents only) */}
|
|
{!isArchived && (
|
|
<div className="flex flex-wrap items-center gap-3 bg-surface-container p-3 rounded-xl border border-outline-variant/30 shrink-0">
|
|
{/* Severity Selector */}
|
|
<div className="space-y-1">
|
|
<span className="block text-[10px] font-mono text-outline">
|
|
Severity
|
|
</span>
|
|
<select
|
|
value={incident.severity}
|
|
disabled={isMutating}
|
|
onChange={(e) => handleSeverityChange(e.target.value as IncidentSeverity)}
|
|
className="bg-surface-container-high px-2.5 py-1 rounded-lg border border-outline-variant/40 text-xs font-mono font-semibold capitalize text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50"
|
|
>
|
|
<option value="critical">Critical</option>
|
|
<option value="high">High</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="low">Low</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Status Transition Selector */}
|
|
<div className="space-y-1">
|
|
<span className="block text-[10px] font-mono text-outline">
|
|
Transition Status
|
|
</span>
|
|
<select
|
|
value={incident.status}
|
|
disabled={isMutating}
|
|
onChange={(e) => handleStatusChange(e.target.value as IncidentStatus)}
|
|
className="bg-surface-container-high px-2.5 py-1 rounded-lg border border-outline-variant/40 text-xs font-mono font-semibold text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50"
|
|
>
|
|
<option value={incident.status} disabled>
|
|
Current ({incident.status.charAt(0).toUpperCase() + incident.status.slice(1)})
|
|
</option>
|
|
{(['open', 'investigating', 'monitoring', 'resolved', 'closed', 'archived'] as IncidentStatus[])
|
|
.filter((s) => isValidStatusTransition(incident.status, s, role))
|
|
.map((s) => (
|
|
<option key={s} value={s}>
|
|
→ {s.charAt(0).toUpperCase() + s.slice(1)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Metadata Grid */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 pt-4 border-t border-outline-variant/20 text-xs">
|
|
<div>
|
|
<span className="block text-[10px] font-mono text-outline">Created By</span>
|
|
<span className="font-mono text-on-surface-variant break-all">
|
|
{incident.created_by_identity}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="block text-[10px] font-mono text-outline">Created At</span>
|
|
<span className="font-mono text-on-surface-variant">
|
|
{formatDate(incident.created_at)}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="block text-[10px] font-mono text-outline">Last Updated</span>
|
|
<span className="font-mono text-on-surface-variant">
|
|
{formatDate(incident.updated_at)}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="block text-[10px] font-mono text-outline">Closed</span>
|
|
<span className="font-mono text-on-surface-variant">
|
|
{incident.closed_at ? formatDate(incident.closed_at) : '—'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Summary Card */}
|
|
<div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant/30 space-y-2">
|
|
<h2 className="text-xs font-headline-sm font-semibold text-outline">
|
|
Incident Summary
|
|
</h2>
|
|
<div className="text-xs text-on-surface whitespace-pre-wrap leading-relaxed">
|
|
{incident.summary}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Resolution Card (if present) */}
|
|
{incident.resolution && (
|
|
<div className="bg-surface-container-low p-6 rounded-2xl border border-emerald-500/30 space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-emerald-400 text-[18px]">
|
|
task_alt
|
|
</span>
|
|
<h2 className="text-xs font-headline-sm font-semibold text-emerald-400">
|
|
Resolution Record
|
|
</h2>
|
|
</div>
|
|
<div className="text-xs text-on-surface whitespace-pre-wrap leading-relaxed bg-surface-container/60 p-4 rounded-xl border border-outline-variant/20 font-mono">
|
|
{incident.resolution}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Two Column Layout: Operational Timeline & Append-only Notes */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Append-only Notes Section */}
|
|
<div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant/30 space-y-5">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-[18px] text-primary-container">
|
|
notes
|
|
</span>
|
|
<h2 className="text-xs font-headline-sm font-semibold text-on-surface">
|
|
Operator Notes
|
|
</h2>
|
|
</div>
|
|
<span className="text-[10px] font-mono text-outline">
|
|
Append-Only ({notes.length})
|
|
</span>
|
|
</div>
|
|
|
|
{/* Add Note Form */}
|
|
{!isArchived && (
|
|
<form onSubmit={handleAddNote} className="space-y-3 bg-surface-container p-4 rounded-xl border border-outline-variant/30">
|
|
<div className="flex items-center justify-between">
|
|
<label htmlFor="incident-note-body" className="block text-[11px] font-semibold text-on-surface">
|
|
Add Operator Note
|
|
</label>
|
|
<span className="text-[10px] font-mono text-outline">
|
|
{newNoteBody.length} / 4000
|
|
</span>
|
|
</div>
|
|
<textarea
|
|
id="incident-note-body"
|
|
required
|
|
rows={3}
|
|
maxLength={4000}
|
|
value={newNoteBody}
|
|
onChange={(e) => setNewNoteBody(e.target.value)}
|
|
placeholder="Record operational observations, actions taken, or debugging findings..."
|
|
className="w-full bg-surface-container-high px-3 py-2 rounded-lg border border-outline-variant/40 text-xs text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none resize-y"
|
|
/>
|
|
{noteError && (
|
|
<p className="text-[11px] text-error">{noteError}</p>
|
|
)}
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="submit"
|
|
disabled={isAddingNote || !newNoteBody.trim()}
|
|
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-primary-container text-on-primary-container text-xs font-bold shadow-sm hover:bg-primary-container/90 transition-all disabled:opacity-50"
|
|
>
|
|
{isAddingNote && (
|
|
<span className="material-symbols-outlined text-[14px] animate-spin">
|
|
progress_activity
|
|
</span>
|
|
)}
|
|
<span>Add Note</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{/* Notes Feed */}
|
|
{notes.length === 0 ? (
|
|
<p className="text-xs text-outline text-center py-6">
|
|
No operator notes recorded for this incident yet.
|
|
</p>
|
|
) : (
|
|
<div className="space-y-3 max-h-96 overflow-y-auto pr-1">
|
|
{notes.map((note, idx) => (
|
|
<div
|
|
key={`${note.created_at}-${idx}`}
|
|
className="p-3.5 rounded-xl bg-surface-container/60 border border-outline-variant/20 space-y-1.5 text-xs"
|
|
>
|
|
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
|
<span className="text-primary-fixed-dim font-bold">
|
|
{note.created_by_identity}
|
|
</span>
|
|
<span>{formatDate(note.created_at)}</span>
|
|
</div>
|
|
<div className="text-on-surface whitespace-pre-wrap font-mono text-xs leading-relaxed">
|
|
{note.body}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Operational Timeline Section */}
|
|
<div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant/30 space-y-4">
|
|
<div className="flex items-center gap-2">
|
|
<span className="material-symbols-outlined text-[18px] text-primary-container">
|
|
history
|
|
</span>
|
|
<h2 className="text-xs font-headline-sm font-semibold text-on-surface">
|
|
Operational Timeline
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="relative pl-6 space-y-6 before:absolute before:left-2 before:top-2 before:bottom-2 before:w-0.5 before:bg-outline-variant/30">
|
|
{/* Created Event */}
|
|
<div className="relative space-y-1">
|
|
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-blue-500 border-2 border-surface-container-low" />
|
|
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
|
<span className="font-semibold text-on-surface">Incident Opened</span>
|
|
<span>{formatDate(incident.created_at)}</span>
|
|
</div>
|
|
<p className="text-xs text-on-surface-variant">
|
|
Created by <span className="font-mono text-primary-container">{incident.created_by_identity}</span> with initial severity <span className="font-mono capitalize font-medium">{incident.severity}</span>.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Notes Count Event */}
|
|
{notes.length > 0 && (
|
|
<div className="relative space-y-1">
|
|
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-purple-400 border-2 border-surface-container-low" />
|
|
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
|
<span className="font-semibold text-on-surface">Operator Notes ({notes.length})</span>
|
|
<span>{formatDate(notes[0]?.created_at)}</span>
|
|
</div>
|
|
<p className="text-xs text-on-surface-variant">
|
|
{notes.length} append-only operational {notes.length === 1 ? 'note' : 'notes'} recorded.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Resolution Event (if present) */}
|
|
{incident.resolution && (
|
|
<div className="relative space-y-1">
|
|
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-emerald-400 border-2 border-surface-container-low" />
|
|
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
|
<span className="font-semibold text-emerald-400">Resolution Documented</span>
|
|
<span>{formatDate(incident.updated_at)}</span>
|
|
</div>
|
|
<p className="text-xs text-on-surface-variant">
|
|
Resolution summary recorded for this case.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Closed Event (if closed) */}
|
|
{incident.closed_at && (
|
|
<div className="relative space-y-1">
|
|
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-outline border-2 border-surface-container-low" />
|
|
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
|
<span className="font-semibold text-on-surface">Incident Closed</span>
|
|
<span>{formatDate(incident.closed_at)}</span>
|
|
</div>
|
|
<p className="text-xs text-on-surface-variant">
|
|
Closed by <span className="font-mono text-on-surface">{incident.closed_by_identity ?? 'operator'}</span>.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Confirmation Dialog Modal */}
|
|
<OperationsIncidentModal
|
|
isOpen={modalOpen}
|
|
action={modalAction}
|
|
caseReference={incident.case_reference}
|
|
initialResolution={incident.resolution ?? ''}
|
|
isSubmitting={isSubmittingModal}
|
|
onConfirm={handleModalConfirm}
|
|
onClose={() => setModalOpen(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|