251 lines
14 KiB
TypeScript
251 lines
14 KiB
TypeScript
import { useState, useMemo, useCallback } from 'react';
|
|
import { useAuth } from '../../contexts/AuthContext';
|
|
import { formatDate, formatDateTime } from '../../utils/format';
|
|
import type { BugReport, BugSeverity, BugStatus, BugReportNote } from '../../types';
|
|
|
|
function StatusBadge({ status }: { status: BugStatus }) {
|
|
const map: Record<BugStatus, string> = { open: 'badge-open', in_progress: 'badge-progress', resolved: 'badge-resolved', closed: 'badge-closed' };
|
|
const labels: Record<BugStatus, string> = { open: 'Open', in_progress: 'In Progress', resolved: 'Resolved', closed: 'Closed' };
|
|
return <span className={`badge ${map[status]}`}>{labels[status]}</span>;
|
|
}
|
|
|
|
function SeverityBadge({ severity }: { severity: BugSeverity }) {
|
|
const map: Record<BugSeverity, string> = { low: 'badge-low', medium: 'badge-medium', high: 'badge-high', critical: 'badge-critical' };
|
|
return <span className={`badge ${map[severity]}`}>{severity}</span>;
|
|
}
|
|
|
|
const STATUSES: BugStatus[] = ['open', 'in_progress', 'resolved', 'closed'];
|
|
const STAFF_MEMBERS: { id: string; username: string; role: string }[] = [];
|
|
|
|
export default function IntranetBugs() {
|
|
const { user } = useAuth();
|
|
|
|
const [bugs, setBugs] = useState<BugReport[]>([]);
|
|
const [selected, setSelected] = useState<BugReport | null>(null);
|
|
const [statusFilter, setStatusFilter] = useState<BugStatus | 'all'>('all');
|
|
const [severityFilter, setSeverityFilter] = useState<BugSeverity | 'all'>('all');
|
|
const [assignedFilter, setAssignedFilter] = useState<string | 'all'>('all');
|
|
const [noteText, setNoteText] = useState('');
|
|
|
|
const openCount = bugs.filter((b) => b.status === 'open').length;
|
|
const criticalCount = bugs.filter((b) => b.severity === 'critical').length;
|
|
const myCount = bugs.filter((b) => b.assignedToId === user?.id).length;
|
|
|
|
const filtered = useMemo(() => {
|
|
return bugs.filter((b) => {
|
|
if (statusFilter !== 'all' && b.status !== statusFilter) return false;
|
|
if (severityFilter !== 'all' && b.severity !== severityFilter) return false;
|
|
if (assignedFilter !== 'all') {
|
|
if (assignedFilter === 'unassigned' && b.assignedToId) return false;
|
|
if (assignedFilter !== 'unassigned' && b.assignedToId !== assignedFilter) return false;
|
|
}
|
|
return true;
|
|
});
|
|
}, [bugs, statusFilter, severityFilter, assignedFilter]);
|
|
|
|
const updateBug = useCallback((id: string, changes: Partial<BugReport>) => {
|
|
setBugs((prev) => prev.map((b) => b.id === id ? { ...b, ...changes, updatedAt: new Date().toISOString() } : b));
|
|
setSelected((prev) => prev?.id === id ? { ...prev, ...changes, updatedAt: new Date().toISOString() } : prev);
|
|
}, []);
|
|
|
|
const handleAssign = useCallback((bugId: string, staffId: string) => {
|
|
const staff = STAFF_MEMBERS.find((s) => s.id === staffId);
|
|
updateBug(bugId, { assignedToId: staffId || undefined, assignedToName: staff?.username });
|
|
}, [updateBug]);
|
|
|
|
const handleStatusChange = useCallback((bugId: string, status: BugStatus) => {
|
|
updateBug(bugId, { status });
|
|
}, [updateBug]);
|
|
|
|
const handleAddNote = useCallback((bugId: string) => {
|
|
if (!noteText.trim() || !user) return;
|
|
const note: BugReportNote = {
|
|
id: `n${Date.now()}`,
|
|
bugReportId: bugId,
|
|
authorId: user.id,
|
|
authorName: user.username,
|
|
content: noteText.trim(),
|
|
createdAt: new Date().toISOString(),
|
|
};
|
|
setBugs((prev) => prev.map((b) => b.id === bugId ? { ...b, notes: [...(b.notes ?? []), note] } : b));
|
|
setSelected((prev) => prev?.id === bugId ? { ...prev, notes: [...(prev.notes ?? []), note] } : prev);
|
|
setNoteText('');
|
|
}, [noteText, user]);
|
|
|
|
return (
|
|
<div style={{ display: 'grid', gridTemplateColumns: selected ? '1fr 400px' : '1fr', gap: '1.5rem', alignItems: 'start' }}>
|
|
{/* Left panel */}
|
|
<div>
|
|
<div style={{ marginBottom: '1.5rem' }}>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.7rem', letterSpacing: '0.15em', marginBottom: '0.5rem' }}>
|
|
INTRANET / BUG REPORTS
|
|
</div>
|
|
<h1 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.8rem', marginBottom: '1rem' }}>BUG DASHBOARD</h1>
|
|
|
|
{/* Stats */}
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '0.5rem', marginBottom: '1.25rem' }}>
|
|
{[
|
|
{ label: 'Open', value: openCount, color: 'var(--color-green)' },
|
|
{ label: 'Critical', value: criticalCount, color: 'var(--color-red)' },
|
|
{ label: 'Mine', value: myCount, color: 'var(--color-amber)' },
|
|
].map(({ label, value, color }) => (
|
|
<div key={label} style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '0.75rem', textAlign: 'center' }}>
|
|
<div style={{ fontFamily: 'var(--font-heading)', color, fontSize: '1.8rem' }}>{value}</div>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', letterSpacing: '0.1em' }}>{label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
<select className="input-terminal" style={{ width: 'auto', fontSize: '0.75rem' }} value={statusFilter} onChange={(e) => setStatusFilter(e.target.value as BugStatus | 'all')}>
|
|
<option value="all">All Statuses</option>
|
|
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
</select>
|
|
<select className="input-terminal" style={{ width: 'auto', fontSize: '0.75rem' }} value={severityFilter} onChange={(e) => setSeverityFilter(e.target.value as BugSeverity | 'all')}>
|
|
<option value="all">All Severities</option>
|
|
{(['critical','high','medium','low'] as BugSeverity[]).map((s) => <option key={s} value={s}>{s}</option>)}
|
|
</select>
|
|
<select className="input-terminal" style={{ width: 'auto', fontSize: '0.75rem' }} value={assignedFilter} onChange={(e) => setAssignedFilter(e.target.value)}>
|
|
<option value="all">All Assigned</option>
|
|
<option value="unassigned">Unassigned</option>
|
|
{STAFF_MEMBERS.map((s) => <option key={s.id} value={s.id}>{s.username}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bug list */}
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
|
|
{filtered.length === 0 ? (
|
|
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '2rem', textAlign: 'center', color: 'var(--color-text-muted)', fontFamily: 'var(--font-mono)', fontSize: '0.8rem' }}>
|
|
No reports match filters.
|
|
</div>
|
|
) : (
|
|
filtered.map((bug) => (
|
|
<div
|
|
key={bug.id}
|
|
onClick={() => setSelected(bug === selected ? null : bug)}
|
|
style={{
|
|
background: selected?.id === bug.id ? 'rgba(37,99,235,0.08)' : 'var(--color-surface)',
|
|
border: `1px solid ${selected?.id === bug.id ? 'var(--color-yellow)' : 'var(--color-border)'}`,
|
|
padding: '0.85rem 1.1rem',
|
|
cursor: 'pointer',
|
|
transition: 'all 0.15s',
|
|
}}
|
|
role="button"
|
|
tabIndex={0}
|
|
onKeyDown={(e) => e.key === 'Enter' && setSelected(bug === selected ? null : bug)}
|
|
aria-label={`Select bug report ${bug.uniqueCode}`}
|
|
>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.3rem' }}>
|
|
<div style={{ display: 'flex', gap: '0.4rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>{bug.uniqueCode}</span>
|
|
<StatusBadge status={bug.status} />
|
|
<SeverityBadge severity={bug.severity} />
|
|
</div>
|
|
<span style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', flexShrink: 0 }}>
|
|
{formatDate(bug.createdAt)}
|
|
</span>
|
|
</div>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text)', fontSize: '0.83rem', marginBottom: '0.2rem', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{bug.title}
|
|
</div>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem' }}>
|
|
{bug.submittedByName} — Assigned: {bug.assignedToName ?? 'None'}
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right panel — detail */}
|
|
{selected && (
|
|
<div style={{ background: 'var(--color-surface)', border: '1px solid var(--color-border)', padding: '1.5rem', position: 'sticky', top: '1rem' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '1rem' }}>
|
|
<div>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', marginBottom: '0.25rem' }}>{selected.uniqueCode}</div>
|
|
<h2 style={{ fontFamily: 'var(--font-heading)', color: 'var(--color-text)', fontSize: '1.1rem' }}>{selected.title}</h2>
|
|
</div>
|
|
<button onClick={() => setSelected(null)} style={{ background: 'transparent', border: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', fontSize: '1.1rem' }} aria-label="Close">✕</button>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div style={{ display: 'grid', gap: '0.75rem', marginBottom: '1.25rem' }}>
|
|
<div>
|
|
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>STATUS</label>
|
|
<select
|
|
className="input-terminal"
|
|
style={{ fontSize: '0.75rem' }}
|
|
value={selected.status}
|
|
onChange={(e) => handleStatusChange(selected.id, e.target.value as BugStatus)}
|
|
>
|
|
{STATUSES.map((s) => <option key={s} value={s}>{s}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label style={{ display: 'block', fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.68rem', letterSpacing: '0.1em', marginBottom: '0.3rem' }}>ASSIGN TO</label>
|
|
<select
|
|
className="input-terminal"
|
|
style={{ fontSize: '0.75rem' }}
|
|
value={selected.assignedToId ?? ''}
|
|
onChange={(e) => handleAssign(selected.id, e.target.value)}
|
|
>
|
|
<option value="">Unassigned</option>
|
|
{STAFF_MEMBERS.map((s) => <option key={s.id} value={s.id}>{s.username} ({s.role})</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', letterSpacing: '0.1em', marginBottom: '0.4rem' }}>DESCRIPTION</div>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.78rem', lineHeight: 1.7, whiteSpace: 'pre-wrap', background: 'var(--color-bg-alt)', padding: '0.75rem', borderRadius: '4px' }}>
|
|
{selected.description}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.65rem', letterSpacing: '0.1em', marginBottom: '0.4rem' }}>STEPS TO REPRODUCE</div>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.78rem', lineHeight: 1.7, whiteSpace: 'pre-wrap', background: 'var(--color-bg-alt)', padding: '0.75rem', borderRadius: '4px' }}>
|
|
{selected.stepsToReproduce}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Internal notes */}
|
|
<div style={{ marginBottom: '1rem' }}>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.65rem', letterSpacing: '0.1em', marginBottom: '0.5rem' }}>INTERNAL NOTES (staff only)</div>
|
|
{(selected.notes ?? []).length === 0 ? (
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-muted)', fontSize: '0.75rem', padding: '0.5rem 0' }}>No notes yet.</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '0.75rem' }}>
|
|
{(selected.notes ?? []).map((note) => (
|
|
<div key={note.id} style={{ background: 'rgba(217,119,6,0.08)', border: '1px solid rgba(217,119,6,0.2)', padding: '0.6rem', borderRadius: '4px' }}>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-amber)', fontSize: '0.65rem', marginBottom: '0.2rem' }}>
|
|
{note.authorName} — {formatDateTime(note.createdAt)}
|
|
</div>
|
|
<div style={{ fontFamily: 'var(--font-mono)', color: 'var(--color-text-dim)', fontSize: '0.77rem', lineHeight: 1.6 }}>
|
|
{note.content}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<textarea
|
|
className="input-terminal"
|
|
rows={3}
|
|
placeholder="Add an internal note..."
|
|
value={noteText}
|
|
onChange={(e) => setNoteText(e.target.value)}
|
|
style={{ resize: 'vertical', fontSize: '0.8rem', marginBottom: '0.5rem' }}
|
|
/>
|
|
<button className="btn-terminal btn-amber" onClick={() => handleAddNote(selected.id)} style={{ padding: '0.35rem 0.9rem', fontSize: '0.75rem' }}>
|
|
Add Note
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|