MotorSignal-App/website/components/ops/operations-support.tsx

1062 lines
46 KiB
TypeScript

'use client';
import React, { useState, useEffect, useCallback } from 'react';
import {
OperationsRole,
SupportTicketSummary,
SupportTicketDetail,
SupportTicketType,
SupportTicketSeverity,
SupportTicketStatus,
SupportDiagnosticBundle,
} from './types';
import { SupportReportModal } from './support-report-modal';
interface OperationsSupportProps {
role: OperationsRole;
}
type TabFilter = 'all' | 'bug_reports' | 'contact_requests' | 'in_progress' | 'resolved';
export function OperationsSupport({ role: _role }: OperationsSupportProps) {
const [tickets, setTickets] = useState<SupportTicketSummary[]>([]);
const [totalCount, setTotalCount] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Tab & Search filters
const [activeTab, setActiveTab] = useState<TabFilter>('all');
const [searchQuery, setSearchQuery] = useState('');
const [appliedSearch, setAppliedSearch] = useState('');
// Ticket Detail state
const [selectedTicketId, setSelectedTicketId] = useState<string | null>(null);
const [ticketDetail, setTicketDetail] = useState<SupportTicketDetail | null>(null);
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
const [detailError, setDetailError] = useState<string | null>(null);
// Status transition state
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
const [statusUpdateError, setStatusUpdateError] = useState<string | null>(null);
// Append-only Note form state
const [newNoteBody, setNewNoteBody] = useState('');
const [isSubmittingNote, setIsSubmittingNote] = useState(false);
const [noteError, setNoteError] = useState<string | null>(null);
// Diagnostics Inspection Modal state
const [isDiagnosticsModalOpen, setIsDiagnosticsModalOpen] = useState(false);
const [diagnosticsReason, setDiagnosticsReason] = useState('');
const [isLoadingDiagnostics, setIsLoadingDiagnostics] = useState(false);
const [diagnosticsError, setDiagnosticsError] = useState<string | null>(null);
const [diagnosticBundle, setDiagnosticBundle] = useState<SupportDiagnosticBundle | null>(null);
// Fetch tickets list (Manual only, zero automatic polling)
const fetchTickets = useCallback(
async (isManualRefresh = false) => {
if (isManualRefresh) {
setIsRefreshing(true);
} else {
setIsLoading(true);
setErrorMessage(null);
}
const params = new URLSearchParams();
if (appliedSearch.trim()) {
params.set('q', appliedSearch.trim());
}
if (activeTab === 'bug_reports') {
params.set('type_filter', 'bug_report');
} else if (activeTab === 'contact_requests') {
params.set('type_filter', 'contact_request');
} else if (activeTab === 'in_progress') {
params.set('status_filter', 'in_progress');
} else if (activeTab === 'resolved') {
params.set('status_filter', 'resolved');
}
try {
const response = await fetch(`/api/ops/support/tickets?${params.toString()}`, {
method: 'GET',
headers: { accept: 'application/json' },
cache: 'no-store',
});
if (response.status === 401) {
setErrorMessage('Your Operations session has expired.');
return;
}
if (response.status === 403) {
setErrorMessage('You do not have authority to view support tickets.');
return;
}
if (!response.ok) {
setErrorMessage('Failed to load support tickets from platform.');
return;
}
const data = await response.json();
const items: SupportTicketSummary[] = Array.isArray(data?.items) ? data.items : [];
setTickets(items);
if (typeof data?.total === 'number') {
setTotalCount(data.total);
} else {
setTotalCount(items.length);
}
} catch {
setErrorMessage('Unable to connect to Operations platform.');
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
},
[activeTab, appliedSearch]
);
// Initial fetch on tab or applied search change
useEffect(() => {
fetchTickets(false);
}, [fetchTickets]);
// Fetch individual ticket detail
const fetchTicketDetail = useCallback(async (ticketId: string) => {
setIsLoadingDetail(true);
setDetailError(null);
setDiagnosticBundle(null);
setDiagnosticsError(null);
setNoteError(null);
setStatusUpdateError(null);
try {
const response = await fetch(`/api/ops/support/tickets/${encodeURIComponent(ticketId)}`, {
method: 'GET',
headers: { accept: 'application/json' },
cache: 'no-store',
});
if (response.status === 401) {
setDetailError('Your Operations session has expired.');
return;
}
if (response.status === 403) {
setDetailError('You do not have authority to view this ticket.');
return;
}
if (response.status === 404) {
setDetailError('Support ticket not found.');
return;
}
if (!response.ok) {
setDetailError('Failed to load ticket details.');
return;
}
const data = (await response.json()) as SupportTicketDetail;
setTicketDetail(data);
if (data.diagnostic_bundle) {
setDiagnosticBundle(data.diagnostic_bundle);
}
} catch {
setDetailError('Unable to connect to Operations platform.');
} finally {
setIsLoadingDetail(false);
}
}, []);
const handleSelectTicket = (ticketId: string) => {
setSelectedTicketId(ticketId);
void fetchTicketDetail(ticketId);
};
const handleBackToList = () => {
setSelectedTicketId(null);
setTicketDetail(null);
setDiagnosticBundle(null);
setIsDiagnosticsModalOpen(false);
setDiagnosticsReason('');
};
// Status transition action
const handleUpdateStatus = async (newStatus: SupportTicketStatus) => {
if (!selectedTicketId || isUpdatingStatus) return;
setIsUpdatingStatus(true);
setStatusUpdateError(null);
try {
const response = await fetch(`/api/ops/support/tickets/${encodeURIComponent(selectedTicketId)}`, {
method: 'PATCH',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({ status: newStatus }),
});
if (response.status === 401) {
setStatusUpdateError('Your Operations session has expired.');
return;
}
if (response.status === 403) {
setStatusUpdateError('You do not have authority to update ticket status.');
return;
}
if (!response.ok) {
const errData = await response.json().catch(() => null);
setStatusUpdateError(errData?.detail ?? 'Failed to update ticket status.');
return;
}
const updated = (await response.json()) as SupportTicketDetail;
setTicketDetail(updated);
// Refresh list to update status badge in background
void fetchTickets(false);
} catch {
setStatusUpdateError('Network error while updating ticket status.');
} finally {
setIsUpdatingStatus(false);
}
};
// Append-only Note submission
const handleAddNote = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedTicketId || !newNoteBody.trim() || isSubmittingNote) return;
setIsSubmittingNote(true);
setNoteError(null);
try {
const response = await fetch(`/api/ops/support/tickets/${encodeURIComponent(selectedTicketId)}/notes`, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({ body: newNoteBody.trim() }),
});
if (response.status === 401) {
setNoteError('Your Operations session has expired.');
return;
}
if (response.status === 403) {
setNoteError('You do not have authority to append notes.');
return;
}
if (!response.ok) {
const errData = await response.json().catch(() => null);
setNoteError(errData?.detail ?? 'Failed to append note.');
return;
}
setNewNoteBody('');
// Re-fetch detail to get updated notes list
void fetchTicketDetail(selectedTicketId);
} catch {
setNoteError('Network error while saving note.');
} finally {
setIsSubmittingNote(false);
}
};
// Inspect diagnostics with audited reason
const handleInspectDiagnostics = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedTicketId || diagnosticsReason.trim().length < 3 || isLoadingDiagnostics) return;
setIsLoadingDiagnostics(true);
setDiagnosticsError(null);
try {
const params = new URLSearchParams({ reason: diagnosticsReason.trim() });
const response = await fetch(
`/api/ops/support/tickets/${encodeURIComponent(selectedTicketId)}/diagnostics?${params.toString()}`,
{
method: 'GET',
headers: { accept: 'application/json' },
cache: 'no-store',
}
);
if (response.status === 401) {
setDiagnosticsError('Your Operations session has expired.');
return;
}
if (response.status === 403) {
setDiagnosticsError('You do not have authority to inspect diagnostics.');
return;
}
if (response.status === 404) {
setDiagnosticsError('No diagnostic bundle found for this ticket.');
return;
}
if (response.status === 422) {
setDiagnosticsError('Operational reason must be at least 3 characters.');
return;
}
if (!response.ok) {
setDiagnosticsError('Failed to retrieve diagnostic bundle.');
return;
}
const bundle = (await response.json()) as SupportDiagnosticBundle;
setDiagnosticBundle(bundle);
setIsDiagnosticsModalOpen(false);
setDiagnosticsReason('');
} catch {
setDiagnosticsError('Network error while retrieving diagnostics.');
} finally {
setIsLoadingDiagnostics(false);
}
};
// Badge render helpers
const renderTypeBadge = (type: SupportTicketType) => {
switch (type) {
case 'bug_report':
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-mono font-medium bg-error/15 text-error border border-error/30">
<span className="material-symbols-outlined text-[13px]">bug_report</span>
Bug Report
</span>
);
case 'contact_request':
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-mono font-medium bg-primary-container/15 text-primary-container border border-primary-container/30">
<span className="material-symbols-outlined text-[13px]">mail</span>
Contact
</span>
);
case 'telemetry_issue':
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-mono font-medium bg-tertiary-container/15 text-tertiary-container border border-tertiary-container/30">
<span className="material-symbols-outlined text-[13px]">sensors</span>
Telemetry
</span>
);
case 'general':
default:
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-mono font-medium bg-surface-container-highest text-on-surface-variant border border-outline-variant/30">
<span className="material-symbols-outlined text-[13px]">help_outline</span>
General
</span>
);
}
};
const renderSeverityBadge = (sev: SupportTicketSeverity) => {
switch (sev) {
case 'critical':
return (
<span className="px-2 py-0.5 rounded text-[11px] font-mono font-semibold bg-error/20 text-error border border-error/40">
Critical
</span>
);
case 'high':
return (
<span className="px-2 py-0.5 rounded text-[11px] font-mono font-medium bg-amber-500/20 text-amber-400 border border-amber-500/30">
High
</span>
);
case 'medium':
return (
<span className="px-2 py-0.5 rounded text-[11px] font-mono font-medium bg-yellow-500/15 text-yellow-300 border border-yellow-500/30">
Medium
</span>
);
case 'low':
default:
return (
<span className="px-2 py-0.5 rounded text-[11px] font-mono font-medium bg-surface-container-highest text-outline border border-outline-variant/20">
Low
</span>
);
}
};
const renderStatusBadge = (status: SupportTicketStatus) => {
switch (status) {
case 'open':
return (
<span className="px-2 py-0.5 rounded text-[11px] font-mono font-medium bg-primary-container/15 text-primary-container border border-primary-container/30">
Open
</span>
);
case 'in_progress':
return (
<span className="px-2 py-0.5 rounded text-[11px] font-mono font-medium bg-cyan-500/15 text-cyan-300 border border-cyan-500/30">
In Progress
</span>
);
case 'resolved':
return (
<span className="px-2 py-0.5 rounded text-[11px] font-mono font-medium bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
Resolved
</span>
);
case 'closed':
default:
return (
<span className="px-2 py-0.5 rounded text-[11px] font-mono font-medium bg-surface-container-highest text-outline border border-outline-variant/30">
Closed
</span>
);
}
};
// If a ticket is selected, render Ticket Detail Pane
if (selectedTicketId) {
return (
<div className="space-y-6 animate-in fade-in duration-200">
{/* Detail Top Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-outline-variant/20 pb-5">
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleBackToList}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border border-outline-variant/30 bg-surface-container-low hover:bg-surface-container-high 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"
>
<span className="material-symbols-outlined text-[18px]">arrow_back</span>
Back to Inbox
</button>
<h1 className="text-2xl sm:text-3xl font-headline-xl font-extrabold text-on-surface tracking-tight">
{ticketDetail ? ticketDetail.case_reference : 'Ticket Details'}
</h1>
</div>
{/* Action Buttons */}
{ticketDetail && (
<div className="flex flex-wrap items-center gap-2.5">
{ticketDetail.status !== 'in_progress' && (
<button
type="button"
onClick={() => handleUpdateStatus('in_progress')}
disabled={isUpdatingStatus}
className="px-4 py-2.5 rounded-xl border border-cyan-500/40 bg-cyan-500/10 hover:bg-cyan-500/20 text-cyan-300 text-xs sm:text-sm font-semibold transition-colors disabled:opacity-50"
>
Mark In Progress
</button>
)}
{ticketDetail.status !== 'resolved' && (
<button
type="button"
onClick={() => handleUpdateStatus('resolved')}
disabled={isUpdatingStatus}
className="px-4 py-2.5 rounded-xl border border-emerald-500/40 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 text-xs sm:text-sm font-semibold transition-colors disabled:opacity-50"
>
Resolve
</button>
)}
{ticketDetail.status !== 'closed' && (
<button
type="button"
onClick={() => handleUpdateStatus('closed')}
disabled={isUpdatingStatus}
className="px-4 py-2.5 rounded-xl border border-outline-variant/30 bg-surface-container hover:bg-surface-container-high text-on-surface-variant text-xs sm:text-sm font-semibold transition-colors disabled:opacity-50"
>
Close
</button>
)}
{(ticketDetail.status === 'resolved' || ticketDetail.status === 'closed') && (
<button
type="button"
onClick={() => handleUpdateStatus('open')}
disabled={isUpdatingStatus}
className="px-4 py-2.5 rounded-xl border border-primary-container/40 bg-primary-container/10 hover:bg-primary-container/20 text-primary-container text-xs sm:text-sm font-semibold transition-colors disabled:opacity-50"
>
Reopen Ticket
</button>
)}
</div>
)}
</div>
{statusUpdateError && (
<div className="p-3 rounded-lg bg-error/15 border border-error/30 text-error text-xs">
{statusUpdateError}
</div>
)}
{isLoadingDetail && (
<div className="p-8 text-center text-sm text-outline">Loading ticket details...</div>
)}
{detailError && (
<div className="p-4 rounded-lg bg-error/15 border border-error/30 text-error text-sm">
{detailError}
</div>
)}
{ticketDetail && !isLoadingDetail && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content Column (2 cols) */}
<div className="lg:col-span-2 space-y-6">
{/* Ticket Overview Card */}
<div className="p-5 rounded-xl bg-surface-container-low border border-outline-variant/30 space-y-4">
<div className="flex flex-wrap items-center gap-2">
{renderTypeBadge(ticketDetail.ticket_type)}
{renderSeverityBadge(ticketDetail.severity)}
{renderStatusBadge(ticketDetail.status)}
</div>
<div>
<h2 className="text-base font-bold text-on-surface">{ticketDetail.subject}</h2>
<p className="mt-2 text-sm text-on-surface-variant whitespace-pre-wrap leading-relaxed">
{ticketDetail.description}
</p>
</div>
</div>
{/* Submitted Diagnostics Section */}
<div className="p-5 rounded-xl bg-surface-container-low border border-outline-variant/30 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary-container text-[20px]">
terminal
</span>
<h3 className="text-sm font-bold text-on-surface">
Submitted Diagnostics
</h3>
</div>
{ticketDetail.has_diagnostics && !diagnosticBundle && (
<button
type="button"
onClick={() => setIsDiagnosticsModalOpen(true)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-primary-container/40 bg-primary-container/10 hover:bg-primary-container/20 text-primary-container text-xs font-medium transition-colors"
>
<span className="material-symbols-outlined text-[15px]">visibility</span>
Inspect Submitted Diagnostics
</button>
)}
</div>
{!ticketDetail.has_diagnostics ? (
<p className="text-xs text-outline italic">
No diagnostic bundle was submitted with this support ticket.
</p>
) : !diagnosticBundle ? (
<div className="p-4 rounded-lg bg-surface-container-lowest border border-outline-variant/20 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="space-y-1">
<span className="text-xs font-semibold text-on-surface">
Diagnostic environment & telemetry logs available
</span>
<p className="text-[11px] text-outline">
Inspection requires audit justification to ensure rider telemetry privacy.
</p>
</div>
<button
type="button"
onClick={() => setIsDiagnosticsModalOpen(true)}
className="px-3 py-1 rounded bg-primary-container text-on-primary-container text-xs font-semibold hover:opacity-90 transition-opacity"
>
Inspect Diagnostics
</button>
</div>
) : (
<div className="space-y-4 pt-2 border-t border-outline-variant/20">
{/* Environment metadata grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
<div className="p-3 rounded-lg bg-surface-container-lowest border border-outline-variant/20">
<span className="text-[10px] text-outline font-mono block">App Version</span>
<span className="font-mono text-on-surface font-medium">{diagnosticBundle.app_version}</span>
</div>
<div className="p-3 rounded-lg bg-surface-container-lowest border border-outline-variant/20">
<span className="text-[10px] text-outline font-mono block">OS Version</span>
<span className="font-mono text-on-surface font-medium">{diagnosticBundle.os_version}</span>
</div>
<div className="p-3 rounded-lg bg-surface-container-lowest border border-outline-variant/20">
<span className="text-[10px] text-outline font-mono block">Device Model</span>
<span className="font-mono text-on-surface font-medium">{diagnosticBundle.device_model}</span>
</div>
<div className="p-3 rounded-lg bg-surface-container-lowest border border-outline-variant/20">
<span className="text-[10px] text-outline font-mono block">Submitted At</span>
<span className="font-mono text-on-surface font-medium">
{new Date(diagnosticBundle.submitted_at).toLocaleTimeString()}
</span>
</div>
</div>
{/* Sensor Status */}
<div className="p-3 rounded-lg bg-surface-container-lowest border border-outline-variant/20">
<span className="text-[10px] text-outline font-mono block mb-1">
Sensor Fusion & Hardware State
</span>
<code className="text-xs font-mono text-primary-container">
{diagnosticBundle.sensor_status}
</code>
</div>
{/* Stack Trace */}
{diagnosticBundle.stack_trace && (
<div className="space-y-1">
<span className="text-[11px] font-mono text-error block">
Captured Exception Stack Trace
</span>
<pre className="p-3 rounded-lg bg-[#0d1117] text-error border border-error/30 font-mono text-[11px] overflow-x-auto whitespace-pre-wrap leading-relaxed">
{diagnosticBundle.stack_trace}
</pre>
</div>
)}
{/* Logs Snippet */}
{diagnosticBundle.logs_snippet && (
<div className="space-y-1">
<span className="text-[11px] font-mono text-on-surface-variant block">
Device Logs Snippet
</span>
<pre className="p-3 rounded-lg bg-[#0d1117] text-on-surface border border-outline-variant/30 font-mono text-[11px] overflow-x-auto whitespace-pre-wrap leading-relaxed">
{diagnosticBundle.logs_snippet}
</pre>
</div>
)}
</div>
)}
</div>
{/* Append-Only Internal Notes Section */}
<div className="p-5 rounded-xl bg-surface-container-low border border-outline-variant/30 space-y-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary-container text-[20px]">
note_stack
</span>
<h3 className="text-sm font-bold text-on-surface">
Internal Operator Notes ({ticketDetail.notes?.length ?? 0})
</h3>
</div>
{/* Notes List */}
<div className="space-y-3">
{!ticketDetail.notes || ticketDetail.notes.length === 0 ? (
<p className="text-xs text-outline italic">No operator notes appended yet.</p>
) : (
ticketDetail.notes.map((note) => (
<div
key={note.id}
className="p-3 rounded-lg bg-surface-container-lowest border border-outline-variant/20 space-y-1.5"
>
<div className="flex items-center justify-between text-[11px]">
<span className="font-mono text-primary-fixed-dim font-medium">
{note.author_identity}
</span>
<span className="text-outline font-mono">
{new Date(note.created_at).toLocaleString()}
</span>
</div>
<p className="text-xs text-on-surface whitespace-pre-wrap leading-relaxed">
{note.body}
</p>
</div>
))
)}
</div>
{/* Add Note Form */}
<form onSubmit={handleAddNote} className="space-y-3 pt-3 border-t border-outline-variant/20">
<div>
<label htmlFor="support-note-input" className="block text-xs font-medium text-on-surface-variant mb-1">
Add Internal Note
</label>
<textarea
id="support-note-input"
value={newNoteBody}
onChange={(e) => setNewNoteBody(e.target.value)}
placeholder="Add an internal note or update for other operators..."
rows={3}
className="w-full rounded-lg bg-surface-container-lowest border border-outline-variant/30 px-3 py-2 text-xs text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
/>
</div>
{noteError && (
<div className="text-xs text-error">{noteError}</div>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={!newNoteBody.trim() || isSubmittingNote}
className="px-3 py-1.5 rounded-lg bg-primary-container text-on-primary-container text-xs font-semibold hover:opacity-90 transition-opacity disabled:opacity-40"
>
{isSubmittingNote ? 'Saving...' : 'Add Note'}
</button>
</div>
</form>
</div>
</div>
{/* Sidebar Meta Column (1 col) */}
<div className="space-y-4">
<div className="p-4 rounded-xl bg-surface-container-low border border-outline-variant/30 space-y-3">
<h4 className="text-xs font-bold text-on-surface">
Ticket Metadata
</h4>
<div className="space-y-2 text-xs">
<div>
<span className="text-outline block text-[10px] font-mono">Case Reference</span>
<span className="font-mono text-on-surface font-bold">{ticketDetail.case_reference}</span>
</div>
<div>
<span className="text-outline block text-[10px] font-mono">Reporter Email</span>
<span className="text-on-surface break-all">{ticketDetail.reporter_email}</span>
</div>
<div>
<span className="text-outline block text-[10px] font-mono">Submitted Date</span>
<span className="text-on-surface">
{new Date(ticketDetail.created_at).toLocaleString()}
</span>
</div>
<div>
<span className="text-outline block text-[10px] font-mono">Last Updated</span>
<span className="text-on-surface">
{new Date(ticketDetail.updated_at).toLocaleString()}
</span>
</div>
{ticketDetail.resolved_at && (
<div>
<span className="text-outline block text-[10px] font-mono">Resolved Date</span>
<span className="text-emerald-400">
{new Date(ticketDetail.resolved_at).toLocaleString()}
</span>
</div>
)}
</div>
</div>
</div>
</div>
)}
{/* Diagnostics Reason Confirmation Modal */}
{isDiagnosticsModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/75 backdrop-blur-sm animate-in fade-in duration-150">
<div className="w-full max-w-2xl lg:max-w-3xl rounded-2xl bg-surface-container-low border border-outline-variant/40 p-6 sm:p-8 shadow-2xl space-y-6">
<div className="flex items-center gap-3 text-primary-container">
<span className="material-symbols-outlined text-[28px]">shield</span>
<h3 className="text-lg sm:text-xl font-bold font-headline-sm text-on-surface">
Operational Justification Required
</h3>
</div>
<div className="p-4 sm:p-5 rounded-xl bg-surface-container-lowest border border-outline-variant/20 text-xs sm:text-sm text-on-surface-variant leading-relaxed">
<p className="font-semibold text-on-surface mb-1.5 text-sm">Privacy & Telemetry Boundary</p>
Rider diagnostics and telemetry logs contain sensitive hardware and environment data.
Viewing this bundle requires an operational justification (minimum 3 characters).
Your operator identity and stated reason will be immutably recorded in the security audit log.
</div>
<form onSubmit={handleInspectDiagnostics} className="space-y-5">
<div>
<label htmlFor="diagnostic-reason" className="block text-xs sm:text-sm font-medium text-on-surface-variant mb-1.5">
Audit Reason (min 3 characters)
</label>
<input
id="diagnostic-reason"
type="text"
value={diagnosticsReason}
onChange={(e) => setDiagnosticsReason(e.target.value)}
placeholder="e.g. Investigating sensor failure report from rider"
className="w-full rounded-xl bg-surface-container-lowest border border-outline-variant/30 px-4 py-3 text-xs sm:text-sm text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
autoFocus
/>
</div>
{diagnosticsError && (
<div className="p-3 rounded-xl bg-error/15 border border-error/30 text-xs sm:text-sm text-error">
{diagnosticsError}
</div>
)}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={() => {
setIsDiagnosticsModalOpen(false);
setDiagnosticsReason('');
setDiagnosticsError(null);
}}
disabled={isLoadingDiagnostics}
className="px-4 py-2.5 rounded-xl border border-outline-variant/30 bg-surface-container text-xs sm:text-sm font-semibold text-on-surface hover:bg-surface-container-high transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={diagnosticsReason.trim().length < 3 || isLoadingDiagnostics}
className="px-5 py-2.5 rounded-xl bg-primary-container text-on-primary-container text-xs sm:text-sm font-bold hover:brightness-110 transition-all shadow-[0_0_12px_rgba(0,242,254,0.25)] disabled:opacity-40"
>
{isLoadingDiagnostics ? 'Inspecting...' : 'Confirm & Inspect'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
// Otherwise, render Ticket Inbox (List view)
return (
<div className="space-y-6">
<SupportReportModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSuccess={(ticket: SupportTicketSummary) => {
fetchTickets(true);
handleSelectTicket(ticket.id);
}}
/>
{/* Top Title Bar */}
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-4 border-b border-outline-variant/30">
<div>
<span className="text-[11px] font-mono text-primary-fixed-dim block font-semibold">
Customer Service &amp; Triage
</span>
<h1 className="font-headline-xl text-2xl sm:text-3xl lg:text-4xl font-extrabold text-on-surface tracking-tight mt-1">
Support Center &amp; Diagnostics
</h1>
<p className="text-xs sm:text-sm text-outline mt-1.5 max-w-2xl">
Customer inquiries, reported bugs, and diagnostic telemetry triage.
</p>
</div>
{/* Manual Refresh Only */}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsModalOpen(true)}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border border-primary/50 bg-primary/10 hover:bg-primary/20 text-xs sm:text-sm font-semibold text-primary transition-colors shadow-sm"
>
<span className="material-symbols-outlined text-[18px]">add</span>
New Support Report
</button>
<button
type="button"
onClick={() => fetchTickets(true)}
disabled={isRefreshing || isLoading}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border border-outline-variant/30 bg-surface-container-low hover:bg-surface-container-high text-xs sm:text-sm font-semibold text-on-surface transition-colors disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none self-start sm:self-auto shrink-0 shadow-sm"
>
<span
className={`material-symbols-outlined text-[18px] ${
isRefreshing ? 'animate-spin' : ''
}`}
>
refresh
</span>
<span>{isRefreshing ? 'Refreshing...' : 'Refresh'}</span>
</button>
</div>
</div>
{/* Filter Tabs & Search Bar */}
<div className="p-4 sm:p-5 rounded-2xl bg-surface-container-low border border-outline-variant/30 flex flex-col md:flex-row md:items-center justify-between gap-4 shadow-sm">
{/* Filter Tabs */}
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setActiveTab('all')}
className={`px-3.5 py-2 rounded-xl text-xs sm:text-sm font-semibold transition-colors ${
activeTab === 'all'
? 'bg-surface-container-high text-primary-container shadow-sm'
: 'text-outline hover:text-on-surface hover:bg-surface-container-highest/50'
}`}
>
All
</button>
<button
type="button"
onClick={() => setActiveTab('bug_reports')}
className={`px-3.5 py-2 rounded-xl text-xs sm:text-sm font-semibold transition-colors ${
activeTab === 'bug_reports'
? 'bg-surface-container-high text-primary-container shadow-sm'
: 'text-outline hover:text-on-surface hover:bg-surface-container-highest/50'
}`}
>
Bug Reports
</button>
<button
type="button"
onClick={() => setActiveTab('contact_requests')}
className={`px-3.5 py-2 rounded-xl text-xs sm:text-sm font-semibold transition-colors ${
activeTab === 'contact_requests'
? 'bg-surface-container-high text-primary-container shadow-sm'
: 'text-outline hover:text-on-surface hover:bg-surface-container-highest/50'
}`}
>
Contact Requests
</button>
<button
type="button"
onClick={() => setActiveTab('in_progress')}
className={`px-3.5 py-2 rounded-xl text-xs sm:text-sm font-semibold transition-colors ${
activeTab === 'in_progress'
? 'bg-surface-container-high text-primary-container shadow-sm'
: 'text-outline hover:text-on-surface hover:bg-surface-container-highest/50'
}`}
>
In Progress
</button>
<button
type="button"
onClick={() => setActiveTab('resolved')}
className={`px-3.5 py-2 rounded-xl text-xs sm:text-sm font-semibold transition-colors ${
activeTab === 'resolved'
? 'bg-surface-container-high text-primary-container shadow-sm'
: 'text-outline hover:text-on-surface hover:bg-surface-container-highest/50'
}`}
>
Resolved
</button>
</div>
{/* Search Bar */}
<form
onSubmit={(e) => {
e.preventDefault();
setAppliedSearch(searchQuery);
}}
className="relative max-w-md w-full"
>
<span className="material-symbols-outlined absolute left-3.5 top-1/2 -translate-y-1/2 text-outline text-[18px]">
search
</span>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search tickets by case ref, email, subject..."
className="w-full pl-10 pr-4 py-2.5 rounded-xl bg-surface-container-lowest border border-outline-variant/30 text-xs sm:text-sm text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
/>
</form>
</div>
{/* Error Message */}
{errorMessage && (
<div className="p-4 rounded-xl bg-error/15 border border-error/30 text-error text-xs sm:text-sm">
{errorMessage}
</div>
)}
{/* Total count badge */}
<div className="text-xs sm:text-sm text-outline px-1">
Showing <span className="text-on-surface font-semibold">{tickets.length}</span>
{totalCount !== null && ` of ${totalCount}`} tickets
</div>
{/* Desktop Table View */}
<div className="hidden md:block overflow-x-auto rounded-2xl border border-outline-variant/30 bg-surface-container-lowest shadow-sm">
<table className="w-full text-left border-collapse text-xs sm:text-sm">
<thead>
<tr className="border-b border-outline-variant/30 bg-surface-container-low text-[11px] font-mono text-outline">
<th className="py-4 px-5 sm:px-6 font-semibold">Case Ref</th>
<th className="py-4 px-5 sm:px-6 font-semibold">Type</th>
<th className="py-4 px-5 sm:px-6 font-semibold">Reporter</th>
<th className="py-4 px-5 sm:px-6 font-semibold">Subject</th>
<th className="py-4 px-5 sm:px-6 font-semibold">Severity</th>
<th className="py-4 px-5 sm:px-6 font-semibold">Status</th>
<th className="py-4 px-5 sm:px-6 font-semibold">Diagnostics</th>
<th className="py-4 px-5 sm:px-6 font-semibold">Notes</th>
<th className="py-4 px-5 sm:px-6 font-semibold">Submitted</th>
</tr>
</thead>
<tbody className="divide-y divide-outline-variant/20">
{isLoading ? (
<tr>
<td colSpan={9} className="py-12 text-center text-outline">
Loading tickets...
</td>
</tr>
) : tickets.length === 0 ? (
<tr>
<td colSpan={9} className="py-12 text-center text-outline">
No support tickets found matching current filters.
</td>
</tr>
) : (
tickets.map((t) => (
<tr
key={t.id}
onClick={() => handleSelectTicket(t.id)}
className="hover:bg-surface-container-high/40 cursor-pointer transition-colors"
>
<td className="py-4 px-5 sm:px-6 font-mono font-bold text-primary-container">
{t.case_reference}
</td>
<td className="py-4 px-5 sm:px-6">{renderTypeBadge(t.ticket_type)}</td>
<td className="py-4 px-5 sm:px-6 text-on-surface-variant font-mono">{t.reporter_email}</td>
<td className="py-4 px-5 sm:px-6 font-medium text-on-surface max-w-md lg:max-w-xl truncate">
{t.subject}
</td>
<td className="py-4 px-5 sm:px-6">{renderSeverityBadge(t.severity)}</td>
<td className="py-4 px-5 sm:px-6">{renderStatusBadge(t.status)}</td>
<td className="py-4 px-5 sm:px-6">
{t.has_diagnostics ? (
<span className="inline-flex items-center gap-1.5 text-xs font-mono text-cyan-300">
<span className="material-symbols-outlined text-[16px]">terminal</span>
Bundle
</span>
) : (
<span className="text-outline font-mono text-xs">—</span>
)}
</td>
<td className="py-4 px-5 sm:px-6 font-mono text-outline">{t.notes_count ?? 0}</td>
<td className="py-4 px-5 sm:px-6 text-outline whitespace-nowrap">
{new Date(t.created_at).toLocaleDateString()}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Mobile Stacked Cards View */}
<div className="md:hidden space-y-3">
{isLoading ? (
<div className="p-6 text-center text-xs text-outline">Loading tickets...</div>
) : tickets.length === 0 ? (
<div className="p-6 text-center text-xs text-outline">
No support tickets found matching current filters.
</div>
) : (
tickets.map((t) => (
<div
key={t.id}
onClick={() => handleSelectTicket(t.id)}
className="p-4 rounded-xl bg-surface-container-lowest border border-outline-variant/30 space-y-2.5 active:bg-surface-container-low transition-colors"
>
<div className="flex items-center justify-between">
<span className="font-mono text-xs font-bold text-primary-container">
{t.case_reference}
</span>
<div className="flex items-center gap-1.5">
{renderSeverityBadge(t.severity)}
{renderStatusBadge(t.status)}
</div>
</div>
<div>
<span className="text-xs font-semibold text-on-surface block truncate">
{t.subject}
</span>
<span className="text-[11px] text-outline font-mono block truncate">
{t.reporter_email}
</span>
</div>
<div className="flex items-center justify-between text-[10px] text-outline pt-1 border-t border-outline-variant/10 font-mono">
<span>{renderTypeBadge(t.ticket_type)}</span>
<span>{new Date(t.created_at).toLocaleDateString()}</span>
</div>
</div>
))
)}
</div>
</div>
);
}