feat: implement permissions and actions management
- Add permissions logic for status options in corrective actions. - Create tests for permissions functionality. - Implement queries for user authentication and actions management. - Develop Actions page to display and manage corrective actions. - Create authentication pages for login, setup, and password reset. - Add IncidentDetail page to view incident details and associated actions. - Implement Settings page for user management and password changes.
This commit is contained in:
1 parent
708da6b9f7
commit
3f6dff5f24
94 files changed
+7213
-643
No files matched your search
@@ -1,16 +1,34 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom'
|
||||
import { useActions, useMe, useSetupStatus } from '../lib/queries'
|
||||
import { Header } from './Header'
|
||||
import { ErrorMessage, Loading } from './QueryState'
|
||||
import { Sidebar } from './Sidebar'
|
||||
|
||||
export function AppLayout({ openIncidentCount }: { openIncidentCount: number }) {
|
||||
/** Signed-in shell. Sends visitors to setup (first run) or sign-in. */
|
||||
export function AppLayout() {
|
||||
const me = useMe()
|
||||
const setup = useSetupStatus()
|
||||
const location = useLocation()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const myOpenActions = useActions('mine', 'open', !!me.data)
|
||||
|
||||
if (me.isPending) return <Loading />
|
||||
if (me.isError) return <ErrorMessage error={me.error} className="m-6" />
|
||||
if (!me.data) {
|
||||
if (setup.data?.needsSetup) return <Navigate to="/setup" replace />
|
||||
if (setup.isPending) return <Loading />
|
||||
return <Navigate to="/login" replace state={{ from: location.pathname + location.search }} />
|
||||
}
|
||||
|
||||
const open = myOpenActions.data ?? []
|
||||
const overdue = open.filter((action) => action.overdue).length
|
||||
return (
|
||||
<div className="flex min-h-screen bg-canvas">
|
||||
<Sidebar open={menuOpen} onClose={() => setMenuOpen(false)} openIncidentCount={openIncidentCount} />
|
||||
<Sidebar me={me.data} open={menuOpen} onClose={() => setMenuOpen(false)} badges={{ '/actions': open.length }} />
|
||||
<main className="min-w-0 flex-1">
|
||||
<Header onOpenMenu={() => setMenuOpen(true)} />
|
||||
<Outlet />
|
||||
<Header me={me.data} onOpenMenu={() => setMenuOpen(true)} overdueActions={overdue} />
|
||||
<Outlet context={me.data} />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ShieldCheck } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { panel } from './styles'
|
||||
|
||||
/** Centered card for sign-in, setup and email-link pages. */
|
||||
export function AuthLayout({ title, copy, children }: { title: string; copy?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid min-h-screen place-items-center bg-canvas px-4 py-10">
|
||||
<div className="w-full max-w-[420px]">
|
||||
<div className="mb-6 flex items-center justify-center gap-2.5">
|
||||
<div className="grid size-[34px] place-items-center rounded-[9px] bg-coral text-white">
|
||||
<ShieldCheck size={20} />
|
||||
</div>
|
||||
<strong className="font-display text-xl font-bold tracking-[-0.5px] text-forest-950">Open SafetyLog EHS</strong>
|
||||
</div>
|
||||
<div className={`${panel} p-7`}>
|
||||
<h1 className="font-display text-2xl font-semibold tracking-[-0.5px] text-ink">{title}</h1>
|
||||
{copy && <p className="mt-1.5 text-xs leading-[1.6] text-muted">{copy}</p>}
|
||||
<div className="mt-6">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ActionStatus } from '../../shared/api-types.ts'
|
||||
import type { IncidentStatus, Severity } from '../../shared/enums.ts'
|
||||
import { actionStatusClass, actionStatusLabel, severityDotClass, severityLabel, statusClass, statusLabel } from '../lib/labels'
|
||||
|
||||
const chip = 'inline-block rounded px-2 py-[5px] text-3xs whitespace-nowrap'
|
||||
|
||||
export const StatusBadge = ({ status }: { status: IncidentStatus }) => <span className={`${chip} ${statusClass[status]}`}>{statusLabel[status]}</span>
|
||||
|
||||
export const ActionStatusBadge = ({ status }: { status: ActionStatus }) => <span className={`${chip} ${actionStatusClass[status]}`}>{actionStatusLabel[status]}</span>
|
||||
|
||||
export function SeverityLabel({ severity }: { severity: Severity }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-[5px]">
|
||||
<i className={`size-1.5 rounded-full ${severityDotClass[severity]}`} />
|
||||
{severityLabel[severity]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const OverdueBadge = () => <span className={`${chip} bg-coral-soft font-semibold text-coral-strong`}>Overdue</span>
|
||||
+39
-14
@@ -1,30 +1,55 @@
|
||||
import { Bell, Menu, Search } from 'lucide-react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { pageTitles } from '../lib/navigation'
|
||||
import { currentUser } from '../lib/seed'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { initials } from '../lib/initials'
|
||||
import { pageTitle } from '../lib/navigation'
|
||||
import type { Me } from '../lib/queries'
|
||||
import { iconButton } from './styles'
|
||||
|
||||
export function Header({ onOpenMenu }: { onOpenMenu: () => void }) {
|
||||
export function Header({ me, onOpenMenu, overdueActions }: { me: Me; onOpenMenu: () => void; overdueActions: number }) {
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
const search = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
navigate(`/incidents${query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ''}`)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
return (
|
||||
<header className="flex h-[62px] items-center justify-between border-b border-line bg-white/60 px-[17px] sm:h-[72px] sm:px-6 lg:px-[42px]">
|
||||
<button type="button" className={`${iconButton} mr-2.5 sm:hidden`} onClick={onOpenMenu} aria-label="Open menu">
|
||||
<header className="flex h-[62px] items-center justify-between gap-3 border-b border-line bg-white/60 px-[17px] sm:h-[72px] sm:px-6 lg:px-[42px] print:hidden">
|
||||
<button type="button" className={`${iconButton} sm:hidden`} onClick={onOpenMenu} aria-label="Open menu">
|
||||
<Menu size={21} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2.5 text-xs text-[#94a09b]">
|
||||
<div className="flex min-w-0 items-center gap-2.5 text-xs text-[#94a09b]">
|
||||
<span className="max-sm:hidden">Safety workspace</span>
|
||||
<span className="max-sm:hidden">/</span>
|
||||
<strong className="text-ink">{pageTitles[pathname] ?? 'Not found'}</strong>
|
||||
<strong className="truncate text-ink">{pageTitle(pathname)}</strong>
|
||||
</div>
|
||||
<div className="flex items-center gap-[15px]">
|
||||
<button type="button" className={iconButton} aria-label="Search">
|
||||
<search className="hidden md:block">
|
||||
<form onSubmit={search} className="flex items-center gap-2 rounded-[5px] border border-line bg-white px-2.5 text-[#85938e]">
|
||||
<Search size={15} />
|
||||
<input
|
||||
className="h-[34px] w-[180px] bg-transparent text-xs text-ink outline-none"
|
||||
placeholder="Search incidents"
|
||||
aria-label="Search incidents"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
</form>
|
||||
</search>
|
||||
<Link to="/incidents" className={`${iconButton} md:hidden`} aria-label="Search incidents">
|
||||
<Search size={19} />
|
||||
</button>
|
||||
<button type="button" className={`${iconButton} relative`} aria-label="Notifications">
|
||||
</Link>
|
||||
<Link to="/actions?filter=overdue" className={`${iconButton} relative`} aria-label={overdueActions ? `${overdueActions} overdue actions` : 'My actions'}>
|
||||
<Bell size={19} />
|
||||
<i className="absolute top-1 right-[3px] size-1.5 rounded-full border border-canvas bg-coral" />
|
||||
</button>
|
||||
<div className="grid size-8 place-items-center rounded-full bg-[#d2e0d8] font-mono text-2xs text-teal">{currentUser.initials}</div>
|
||||
{overdueActions > 0 && <i className="absolute top-1 right-[3px] size-1.5 rounded-full border border-canvas bg-coral" />}
|
||||
</Link>
|
||||
<Link to="/settings" className="grid size-8 place-items-center rounded-full bg-[#d2e0d8] font-mono text-2xs text-teal" aria-label="Your settings">
|
||||
{initials(me.user.name)}
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import { AlertTriangle, MapPin } from 'lucide-react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import type { Incident } from '../../shared/api-types.ts'
|
||||
import { formatDate } from '../lib/dates'
|
||||
import { severityDotClass, statusClass, typeTone } from '../lib/incidents'
|
||||
import { incidentTypeLabel, incidentTypeTone } from '../lib/labels'
|
||||
import { toneClasses } from '../lib/tones'
|
||||
import type { Incident } from '../lib/types'
|
||||
import { iconButton } from './styles'
|
||||
import { SeverityLabel, StatusBadge } from './Badges'
|
||||
|
||||
const th = 'border-b border-line px-2.5 py-3 font-mono text-3xs font-medium tracking-[0.06em] text-faint uppercase first:pl-6 last:pr-[18px]'
|
||||
const td = 'border-b border-line-soft px-2.5 py-3.5 text-2xs whitespace-nowrap text-body first:pl-6 last:pr-[18px]'
|
||||
const th = 'border-b border-line px-2.5 py-3 font-mono text-3xs font-medium tracking-[0.06em] text-faint uppercase first:pl-6 last:pr-6'
|
||||
const td = 'border-b border-line-soft px-2.5 py-3.5 text-2xs whitespace-nowrap text-body first:pl-6 last:pr-6'
|
||||
|
||||
interface IncidentTableProps {
|
||||
incidents: Incident[]
|
||||
limit?: number
|
||||
wide?: boolean
|
||||
}
|
||||
|
||||
export function IncidentTable({ incidents, limit, wide = false }: IncidentTableProps) {
|
||||
const rows = limit ? incidents.slice(0, limit) : incidents
|
||||
export function IncidentTable({ incidents, wide = false }: { incidents: Incident[]; wide?: boolean }) {
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<div className="overflow-auto">
|
||||
<table className={`w-full border-collapse text-left ${wide ? 'min-w-[900px]' : 'min-w-[720px]'}`}>
|
||||
@@ -26,51 +21,50 @@ export function IncidentTable({ incidents, limit, wide = false }: IncidentTableP
|
||||
<th className={th}>Severity</th>
|
||||
<th className={th}>Location</th>
|
||||
<th className={th}>Status</th>
|
||||
<th className={th}>Date</th>
|
||||
<th className={th}>
|
||||
<span className="sr-only">Actions</span>
|
||||
</th>
|
||||
<th className={th}>Occurred</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((incident) => (
|
||||
<tr key={incident.id} className="[&:last-child>td]:border-b-0">
|
||||
{incidents.map((incident) => (
|
||||
<tr
|
||||
key={incident.id}
|
||||
className="cursor-pointer hover:bg-canvas [&:last-child>td]:border-b-0"
|
||||
onClick={() => navigate(`/incidents/${incident.number}`)}
|
||||
>
|
||||
<td className={td}>
|
||||
<div className="flex min-w-[250px] items-center gap-2.5">
|
||||
<span className={`grid size-7 place-items-center rounded-[7px] ${toneClasses[typeTone[incident.type]]}`}>
|
||||
<span className={`grid size-7 flex-none place-items-center rounded-[7px] ${toneClasses[incidentTypeTone[incident.type]]}`}>
|
||||
<AlertTriangle size={14} />
|
||||
</span>
|
||||
<div>
|
||||
<strong className="block text-xs font-semibold text-ink">{incident.title}</strong>
|
||||
<div className="min-w-0">
|
||||
<Link
|
||||
to={`/incidents/${incident.number}`}
|
||||
className="block max-w-[320px] truncate text-xs font-semibold text-ink hover:underline"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{incident.title}
|
||||
</Link>
|
||||
<small className="mt-1 block text-3xs text-faint">
|
||||
{incident.id} · {incident.reporter}
|
||||
{incident.reference} · {incident.anonymous ? 'Anonymous' : (incident.reporter?.name ?? 'Unknown')}
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className={td}>{incident.type}</td>
|
||||
<td className={td}>{incidentTypeLabel[incident.type]}</td>
|
||||
<td className={td}>
|
||||
<span className="inline-flex items-center gap-[5px]">
|
||||
<i className={`size-1.5 rounded-full ${severityDotClass[incident.severity]}`} />
|
||||
{incident.severity}
|
||||
</span>
|
||||
<SeverityLabel severity={incident.severity} />
|
||||
</td>
|
||||
<td className={td}>
|
||||
<div className="flex max-w-[120px] flex-wrap items-center gap-1">
|
||||
<MapPin size={14} />
|
||||
{incident.location}
|
||||
<small className="mt-1 ml-[18px] block w-full text-3xs text-faint">{incident.building}</small>
|
||||
<div className="flex max-w-[160px] flex-wrap items-center gap-1">
|
||||
<MapPin size={14} className="flex-none" />
|
||||
<span className="truncate">{incident.area?.name ?? incident.site.name}</span>
|
||||
<small className="mt-1 ml-[18px] block w-full truncate text-3xs text-faint">{incident.locationDetail || incident.site.name}</small>
|
||||
</div>
|
||||
</td>
|
||||
<td className={td}>
|
||||
<span className={`rounded px-2 py-[5px] text-3xs ${statusClass(incident.status)}`}>{incident.status}</span>
|
||||
</td>
|
||||
<td className={`${td} text-[#87938f]`}>{formatDate(incident.date)}</td>
|
||||
<td className={td}>
|
||||
<button type="button" className={iconButton} aria-label={`More actions for ${incident.id}`}>
|
||||
<span className="tracking-[2px] text-[#91aaa0]">•••</span>
|
||||
</button>
|
||||
<StatusBadge status={incident.status} />
|
||||
</td>
|
||||
<td className={`${td} text-[#87938f]`}>{formatDate(incident.occurredAt)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { toneClasses, type Tone } from '../lib/tones'
|
||||
interface MetricCardProps {
|
||||
label: string
|
||||
value: string
|
||||
delta: string
|
||||
delta?: string
|
||||
icon: LucideIcon
|
||||
tone: Tone
|
||||
}
|
||||
@@ -17,9 +17,7 @@ export function MetricCard({ label, value, delta, icon: Icon, tone }: MetricCard
|
||||
</div>
|
||||
<span className="mt-2.5 mb-1.5 block text-xs text-muted sm:mt-[15px]">{label}</span>
|
||||
<strong className="block font-display text-[25px] font-semibold tracking-[-1px] text-ink sm:text-[30px]">{value}</strong>
|
||||
<small className="mt-[9px] block text-[9px] text-[#7d8b86] sm:text-2xs">
|
||||
<b className="text-positive">↗</b> {delta}
|
||||
</small>
|
||||
{delta && <small className="mt-[9px] block text-[9px] text-[#7d8b86] sm:text-2xs">{delta}</small>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { iconButton } from './styles'
|
||||
|
||||
interface PaginationProps {
|
||||
page: number
|
||||
pageSize: number
|
||||
total: number
|
||||
onPage: (page: number) => void
|
||||
}
|
||||
|
||||
export function Pagination({ page, pageSize, total, onPage }: PaginationProps) {
|
||||
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||
const first = total === 0 ? 0 : (page - 1) * pageSize + 1
|
||||
return (
|
||||
<div className="flex items-center justify-between border-t border-line px-6 py-3 text-2xs text-muted print:hidden">
|
||||
<span>
|
||||
{first}–{Math.min(page * pageSize, total)} of {total}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" className={`${iconButton} disabled:opacity-30`} disabled={page <= 1} onClick={() => onPage(page - 1)} aria-label="Previous page">
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span>
|
||||
Page {page} of {pages}
|
||||
</span>
|
||||
<button type="button" className={`${iconButton} disabled:opacity-30`} disabled={page >= pages} onClick={() => onPage(page + 1)} aria-label="Next page">
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { errorMessage } from '../lib/api'
|
||||
|
||||
export function Loading({ label = 'Loading…' }: { label?: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 p-10 text-xs text-muted" role="status">
|
||||
<Loader2 size={16} className="animate-spin" /> {label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorMessage({ error, className = '' }: { error: unknown; className?: string }) {
|
||||
if (!error) return null
|
||||
return (
|
||||
<div role="alert" className={`flex items-start gap-2 rounded-[5px] border border-coral-soft bg-[#fff6f3] px-3 py-2.5 text-xs text-coral-strong ${className}`}>
|
||||
<AlertTriangle size={15} className="mt-px flex-none" />
|
||||
<span>{errorMessage(error)}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Empty({ children }: { children: ReactNode }) {
|
||||
return <div className="px-6 py-10 text-center text-xs text-muted">{children}</div>
|
||||
}
|
||||
+32
-26
@@ -1,7 +1,9 @@
|
||||
import { ChevronDown, FileText, Info, Plus, Settings, ShieldCheck, X } from 'lucide-react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import { Info, LogOut, Plus, Settings, ShieldCheck, X } from 'lucide-react'
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { initials } from '../lib/initials'
|
||||
import { roleLabel } from '../lib/labels'
|
||||
import { primaryNav } from '../lib/navigation'
|
||||
import { currentUser, workspace } from '../lib/seed'
|
||||
import { useLogout, type Me } from '../lib/queries'
|
||||
import { Eyebrow } from './Eyebrow'
|
||||
import { iconButton } from './styles'
|
||||
|
||||
@@ -10,22 +12,25 @@ const navLink = ({ isActive }: { isActive: boolean }) =>
|
||||
const navSection = 'mb-6 border-b border-forest-700 pb-[23px]'
|
||||
|
||||
interface SidebarProps {
|
||||
me: Me
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
openIncidentCount: number
|
||||
badges: Partial<Record<string, number>>
|
||||
}
|
||||
|
||||
export function Sidebar({ open, onClose, openIncidentCount }: SidebarProps) {
|
||||
export function Sidebar({ me, open, onClose, badges }: SidebarProps) {
|
||||
const logout = useLogout()
|
||||
const navigate = useNavigate()
|
||||
return (
|
||||
<aside
|
||||
className={`fixed inset-y-0 z-10 flex w-[260px] flex-none flex-col overflow-y-auto bg-forest-950 px-[15px] pt-7 pb-[18px] text-forest-100 shadow-[8px_0_30px_#163c3740] transition-[left] duration-200 sm:static sm:min-h-screen sm:w-[230px] sm:shadow-none lg:w-[246px] ${open ? 'left-0' : '-left-[270px]'}`}
|
||||
className={`fixed inset-y-0 z-10 flex w-[260px] flex-none flex-col overflow-y-auto bg-forest-950 px-[15px] pt-7 pb-[18px] text-forest-100 shadow-[8px_0_30px_#163c3740] transition-[left] duration-200 sm:static sm:min-h-screen sm:w-[230px] sm:shadow-none lg:w-[246px] print:hidden ${open ? 'left-0' : '-left-[270px]'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2.5 px-3 pb-[31px] text-white">
|
||||
<div className="grid size-[34px] flex-none place-items-center rounded-[9px] bg-coral text-white">
|
||||
<ShieldCheck size={20} />
|
||||
</div>
|
||||
<div>
|
||||
<strong className="block font-display text-[19px] leading-tight sm:text-[17px] lg:text-[19px] font-bold tracking-[-0.5px] whitespace-nowrap">Open SafetyLog</strong>
|
||||
<strong className="block font-display text-[19px] leading-tight font-bold tracking-[-0.5px] whitespace-nowrap sm:text-[17px] lg:text-[19px]">Open SafetyLog</strong>
|
||||
<span className="mt-0.5 block text-3xs text-forest-300">EHS · Incident intelligence</span>
|
||||
</div>
|
||||
<button type="button" className={`${iconButton} absolute top-2 right-2 sm:hidden`} onClick={onClose} aria-label="Close menu">
|
||||
@@ -34,22 +39,19 @@ export function Sidebar({ open, onClose, openIncidentCount }: SidebarProps) {
|
||||
</div>
|
||||
|
||||
<div className="mb-[27px] flex items-center gap-2.5 rounded-[7px] border border-[#316158] p-[11px]">
|
||||
<div className="grid size-[29px] place-items-center rounded-full bg-[#f1c865] font-mono text-2xs font-bold text-[#143d37]">{workspace.initials}</div>
|
||||
<div>
|
||||
<div className="grid size-[29px] flex-none place-items-center rounded-full bg-[#f1c865] font-mono text-2xs font-bold text-[#143d37]">{initials(me.organization.name)}</div>
|
||||
<div className="min-w-0">
|
||||
<Eyebrow>Workspace</Eyebrow>
|
||||
<strong className="block text-[13px] text-white">{workspace.name}</strong>
|
||||
<strong className="block truncate text-[13px] text-white">{me.organization.name}</strong>
|
||||
</div>
|
||||
<ChevronDown size={16} className="ml-auto" />
|
||||
</div>
|
||||
|
||||
<nav className={navSection}>
|
||||
{primaryNav.map(({ label, to, icon: Icon }) => (
|
||||
<NavLink key={to} to={to} end onClick={onClose} className={navLink}>
|
||||
<NavLink key={to} to={to} end={to === '/'} onClick={onClose} className={navLink}>
|
||||
<Icon size={18} />
|
||||
<span>{label}</span>
|
||||
{to === '/incidents' && openIncidentCount > 0 && (
|
||||
<em className="ml-auto rounded-[10px] bg-coral px-[7px] py-0.5 font-mono text-2xs text-white not-italic">{openIncidentCount}</em>
|
||||
)}
|
||||
{!!badges[to] && <em className="ml-auto rounded-[10px] bg-coral px-[7px] py-0.5 font-mono text-2xs text-white not-italic">{badges[to]}</em>}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
@@ -60,10 +62,6 @@ export function Sidebar({ open, onClose, openIncidentCount }: SidebarProps) {
|
||||
<Plus size={18} />
|
||||
<span>Report an incident</span>
|
||||
</NavLink>
|
||||
<a href="#resources" className={navLink({ isActive: false })}>
|
||||
<FileText size={18} />
|
||||
<span>Resources</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto">
|
||||
@@ -71,17 +69,25 @@ export function Sidebar({ open, onClose, openIncidentCount }: SidebarProps) {
|
||||
<Info size={18} />
|
||||
<span>About</span>
|
||||
</NavLink>
|
||||
<a href="#settings" className={navLink({ isActive: false })}>
|
||||
<NavLink to="/settings" onClick={onClose} className={navLink}>
|
||||
<Settings size={18} />
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</NavLink>
|
||||
<div className="mt-4 flex items-center gap-2.5 border-t border-forest-700 px-2 pt-5">
|
||||
<div className="grid size-[31px] place-items-center rounded-full bg-[#5c8274] font-mono text-2xs text-white">{currentUser.initials}</div>
|
||||
<div>
|
||||
<strong className="block text-xs text-white">{currentUser.name}</strong>
|
||||
<span className="mt-0.5 block text-3xs text-forest-300">{currentUser.role}</span>
|
||||
<div className="grid size-[31px] flex-none place-items-center rounded-full bg-[#5c8274] font-mono text-2xs text-white">{initials(me.user.name)}</div>
|
||||
<div className="min-w-0">
|
||||
<strong className="block truncate text-xs text-white">{me.user.name}</strong>
|
||||
<span className="mt-0.5 block text-3xs text-forest-300">{roleLabel[me.user.role]}</span>
|
||||
</div>
|
||||
<span className="ml-auto tracking-[2px] text-[#91aaa0]">•••</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`${iconButton} ml-auto text-[#91aaa0] hover:text-white`}
|
||||
aria-label="Sign out"
|
||||
title="Sign out"
|
||||
onClick={() => logout.mutate(undefined, { onSuccess: () => navigate('/login') })}
|
||||
>
|
||||
<LogOut size={17} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Plus } from 'lucide-react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import type { ActionKind, ActionStatus, CorrectiveAction, IncidentDetail } from '../../../shared/api-types.ts'
|
||||
import { actionKinds } from '../../../shared/enums.ts'
|
||||
import { formatDay, toIsoDate } from '../../lib/dates'
|
||||
import { actionStatusLabel } from '../../lib/labels'
|
||||
import { statusOptions } from '../../lib/permissions'
|
||||
import { useAssignableUsers, useIncidentMutations, type Me } from '../../lib/queries'
|
||||
import { ActionStatusBadge, OverdueBadge } from '../Badges'
|
||||
import { Field } from '../FormSection'
|
||||
import { ErrorMessage } from '../QueryState'
|
||||
import { field, primaryButton, secondaryButton, smallButton } from '../styles'
|
||||
import { Section } from './Section'
|
||||
|
||||
export function ActionStatusControl({ action, onChange, disabled }: { action: CorrectiveAction; onChange: (status: ActionStatus) => void; disabled?: boolean }) {
|
||||
const me = useOutletContext<Me>().user
|
||||
const options = statusOptions(action, me)
|
||||
if (options.length === 0) return <ActionStatusBadge status={action.status} />
|
||||
return (
|
||||
<select
|
||||
className="rounded-[5px] border border-line bg-white px-2 py-1 text-2xs text-ink outline-none print:hidden"
|
||||
value={action.status}
|
||||
disabled={disabled}
|
||||
aria-label={`Status of ${action.title}`}
|
||||
onChange={(event) => onChange(event.target.value as ActionStatus)}
|
||||
>
|
||||
{!options.includes(action.status) && <option value={action.status}>{actionStatusLabel[action.status]}</option>}
|
||||
{options.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{actionStatusLabel[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
}
|
||||
|
||||
export function ActionsSection({ incident }: { incident: IncidentDetail }) {
|
||||
const { updateAction } = useIncidentMutations(incident.number)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const canAdd = incident.can.manageActions && incident.status !== 'closed'
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Corrective actions"
|
||||
action={
|
||||
canAdd &&
|
||||
!adding && (
|
||||
<button type="button" className={smallButton} onClick={() => setAdding(true)}>
|
||||
<Plus size={13} /> Add action
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{adding && <ActionForm incident={incident} onDone={() => setAdding(false)} />}
|
||||
{incident.actions.length === 0 ? (
|
||||
!adding && <p className="text-xs text-faint">No corrective actions yet.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-line-soft">
|
||||
{incident.actions.map((action) => {
|
||||
const cause = incident.rootCauses.find((c) => c.id === action.rootCauseId)
|
||||
return (
|
||||
<li key={action.id} className="flex flex-wrap items-start gap-x-4 gap-y-2 py-3 first:pt-0 last:pb-0">
|
||||
<div className="min-w-[220px] flex-1">
|
||||
<p className="text-xs font-semibold text-ink">
|
||||
{action.title}
|
||||
{action.kind === 'preventive' && <span className="ml-2 font-mono text-3xs font-normal text-faint uppercase">Preventive</span>}
|
||||
</p>
|
||||
{action.description && <p className="mt-1 text-2xs leading-[1.5] whitespace-pre-wrap text-body">{action.description}</p>}
|
||||
<p className="mt-1 text-3xs text-muted">
|
||||
{action.assignee ? `Assigned to ${action.assignee.name}` : 'Unassigned'}
|
||||
{action.dueDate && ` · Due ${formatDay(action.dueDate)}`}
|
||||
{cause && ` · Addresses “${cause.description}”`}
|
||||
{action.verifiedBy && ` · Verified by ${action.verifiedBy.name}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{action.overdue && <OverdueBadge />}
|
||||
<ActionStatusControl action={action} disabled={updateAction.isPending} onChange={(status) => updateAction.mutate({ id: action.id, status })} />
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<ErrorMessage error={updateAction.error} className="mt-3" />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionForm({ incident, onDone }: { incident: IncidentDetail; onDone: () => void }) {
|
||||
const { addAction } = useIncidentMutations(incident.number)
|
||||
const users = useAssignableUsers()
|
||||
const [form, setForm] = useState({ title: '', description: '', kind: 'corrective' as ActionKind, assigneeId: '', dueDate: '', rootCauseId: '' })
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
addAction.mutate(
|
||||
{ ...form, assigneeId: form.assigneeId || null, dueDate: form.dueDate || null, rootCauseId: form.rootCauseId || null, description: form.description || null },
|
||||
{ onSuccess: onDone },
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="mb-4 rounded-md border border-line bg-canvas p-4">
|
||||
<Field label="Action">
|
||||
<input className={field} required value={form.title} onChange={(event) => setForm({ ...form, title: event.target.value })} placeholder="e.g. Add guard refit step to maintenance checklist" />
|
||||
</Field>
|
||||
<Field label="Details" optional>
|
||||
<textarea className={`${field} resize-y`} rows={2} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} />
|
||||
</Field>
|
||||
<div className="grid sm:grid-cols-3 sm:gap-4">
|
||||
<Field label="Assign to">
|
||||
<select className={field} value={form.assigneeId} onChange={(event) => setForm({ ...form, assigneeId: event.target.value })}>
|
||||
<option value="">Unassigned</option>
|
||||
{users.data?.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Due date" optional>
|
||||
<input className={field} type="date" min={toIsoDate(new Date())} value={form.dueDate} onChange={(event) => setForm({ ...form, dueDate: event.target.value })} />
|
||||
</Field>
|
||||
<Field label="Type">
|
||||
<select className={field} value={form.kind} onChange={(event) => setForm({ ...form, kind: event.target.value as ActionKind })}>
|
||||
{actionKinds.map((kind) => (
|
||||
<option key={kind} value={kind}>
|
||||
{kind === 'corrective' ? 'Corrective (fix this)' : 'Preventive (stop it elsewhere)'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
{incident.rootCauses.length > 0 && (
|
||||
<Field label="Addresses root cause" optional>
|
||||
<select className={field} value={form.rootCauseId} onChange={(event) => setForm({ ...form, rootCauseId: event.target.value })}>
|
||||
<option value="">None</option>
|
||||
{incident.rootCauses.map((cause) => (
|
||||
<option key={cause.id} value={cause.id}>
|
||||
{cause.description}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
)}
|
||||
<ErrorMessage error={addAction.error} className="mb-3" />
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className={secondaryButton} onClick={onDone}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className={primaryButton} disabled={addAction.isPending}>
|
||||
Add action
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { IncidentDetail } from '../../../shared/api-types.ts'
|
||||
import { formatDateTime } from '../../lib/dates'
|
||||
import { initials } from '../../lib/initials'
|
||||
import { describeTimelineEntry } from '../../lib/labels'
|
||||
import { useComments, useIncidentMutations, useTimeline } from '../../lib/queries'
|
||||
import { ErrorMessage, Loading } from '../QueryState'
|
||||
import { fieldBase, primaryButton } from '../styles'
|
||||
import { Section } from './Section'
|
||||
|
||||
type Item = { kind: 'comment'; id: string; at: string; who: string; body: string } | { kind: 'event'; id: string; at: string; who: string; text: string }
|
||||
|
||||
/** Comments and the audit trail, interleaved by time. */
|
||||
export function ActivitySection({ incident }: { incident: IncidentDetail }) {
|
||||
const timeline = useTimeline(incident.number)
|
||||
const comments = useComments(incident.number)
|
||||
const { comment } = useIncidentMutations(incident.number)
|
||||
const [body, setBody] = useState('')
|
||||
|
||||
const items: Item[] = [
|
||||
...(comments.data ?? []).map((c): Item => ({ kind: 'comment', id: `c-${c.id}`, at: c.createdAt, who: c.author?.name ?? 'Former user', body: c.body })),
|
||||
...(timeline.data ?? []).map(
|
||||
(entry): Item => ({ kind: 'event', id: `e-${entry.id}`, at: entry.at, who: entry.actor?.name ?? (entry.action === 'create' ? 'Anonymous' : 'Someone'), text: describeTimelineEntry(entry) }),
|
||||
),
|
||||
].sort((a, b) => a.at.localeCompare(b.at))
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
if (body.trim()) comment.mutate(body.trim(), { onSuccess: () => setBody('') })
|
||||
}
|
||||
|
||||
return (
|
||||
<Section title="Activity">
|
||||
{timeline.isPending || comments.isPending ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<ol className="space-y-3">
|
||||
{items.map((item) =>
|
||||
item.kind === 'comment' ? (
|
||||
<li key={item.id} className="flex gap-2.5">
|
||||
<span className="grid size-7 flex-none place-items-center rounded-full bg-[#d2e0d8] font-mono text-3xs text-teal">{initials(item.who)}</span>
|
||||
<div className="min-w-0 flex-1 rounded-md border border-line bg-field px-3 py-2">
|
||||
<p className="text-3xs text-muted">
|
||||
<strong className="font-semibold text-ink">{item.who}</strong> · {formatDateTime(item.at)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs leading-[1.5] whitespace-pre-wrap text-body">{item.body}</p>
|
||||
</div>
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.id} className="flex items-baseline gap-2.5 pl-2.5 text-2xs text-muted">
|
||||
<i className="size-1.5 flex-none translate-y-[-1px] rounded-full bg-line" />
|
||||
<span>
|
||||
<strong className="font-semibold text-body">{item.who}</strong> {item.text}
|
||||
<span className="text-faint"> · {formatDateTime(item.at)}</span>
|
||||
</span>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ol>
|
||||
)}
|
||||
<form onSubmit={submit} className="mt-5 flex flex-col gap-2 border-t border-line pt-4 print:hidden">
|
||||
<textarea className={`${fieldBase} resize-y`} rows={2} value={body} onChange={(event) => setBody(event.target.value)} placeholder="Add a comment or update" aria-label="Comment" />
|
||||
<ErrorMessage error={comment.error} />
|
||||
<button type="submit" className={`${primaryButton} self-end`} disabled={!body.trim() || comment.isPending}>
|
||||
Comment
|
||||
</button>
|
||||
</form>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { FileText, ImagePlus, Trash2 } from 'lucide-react'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useOutletContext } from 'react-router-dom'
|
||||
import type { IncidentDetail } from '../../../shared/api-types.ts'
|
||||
import { hasRole } from '../../../shared/enums.ts'
|
||||
import { formatBytes } from '../../lib/dates'
|
||||
import { useIncidentMutations, type Me } from '../../lib/queries'
|
||||
import { ErrorMessage } from '../QueryState'
|
||||
import { iconButton, smallButton } from '../styles'
|
||||
import { Section } from './Section'
|
||||
|
||||
export function AttachmentsSection({ incident }: { incident: IncidentDetail }) {
|
||||
const me = useOutletContext<Me>().user
|
||||
const { upload, removeAttachment } = useIncidentMutations(incident.number)
|
||||
|
||||
const onFiles = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files ?? [])
|
||||
event.target.value = ''
|
||||
for (const file of files) await upload.mutateAsync(file).catch(() => undefined)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Photos and files"
|
||||
action={
|
||||
<label className={`${smallButton} cursor-pointer ${upload.isPending ? 'opacity-50' : ''}`}>
|
||||
<ImagePlus size={13} /> {upload.isPending ? 'Uploading…' : 'Add'}
|
||||
<input type="file" accept="image/*,application/pdf" multiple className="sr-only" disabled={upload.isPending} onChange={onFiles} />
|
||||
</label>
|
||||
}
|
||||
>
|
||||
<ErrorMessage error={upload.error ?? removeAttachment.error} className="mb-3" />
|
||||
{incident.attachments.length === 0 ? (
|
||||
<p className="text-xs text-faint">No photos or files.</p>
|
||||
) : (
|
||||
<ul className="grid grid-cols-2 gap-3">
|
||||
{incident.attachments.map((file) => {
|
||||
const canRemove = file.uploadedBy?.id === me.id || hasRole(me.role, 'supervisor')
|
||||
const isImage = file.contentType.startsWith('image/') && file.contentType !== 'image/heic'
|
||||
return (
|
||||
<li key={file.id} className="group relative overflow-hidden rounded-md border border-line bg-canvas">
|
||||
<a href={file.url} target="_blank" rel="noreferrer" className="block">
|
||||
{isImage ? (
|
||||
<img src={file.url} alt={file.fileName} loading="lazy" className="aspect-[4/3] w-full object-cover" />
|
||||
) : (
|
||||
<div className="grid aspect-[4/3] place-items-center text-muted">
|
||||
<FileText size={28} />
|
||||
</div>
|
||||
)}
|
||||
<div className="truncate px-2 py-1.5 text-3xs text-body" title={file.fileName}>
|
||||
{file.fileName} <span className="text-faint">· {formatBytes(file.sizeBytes)}</span>
|
||||
</div>
|
||||
</a>
|
||||
{canRemove && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${iconButton} absolute top-1 right-1 rounded bg-white/90 opacity-0 group-hover:opacity-100 focus:opacity-100 print:hidden`}
|
||||
aria-label={`Remove ${file.fileName}`}
|
||||
onClick={() => confirm(`Remove ${file.fileName}?`) && removeAttachment.mutate(file.id)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Pencil } from 'lucide-react'
|
||||
import { useState, type ChangeEvent, type FormEvent } from 'react'
|
||||
import type { IncidentDetail } from '../../../shared/api-types.ts'
|
||||
import { incidentTypes, severities, type IncidentType, type Severity } from '../../../shared/enums.ts'
|
||||
import { toDateTimeLocal } from '../../lib/dates'
|
||||
import { incidentTypeLabel, severityLabel } from '../../lib/labels'
|
||||
import { useIncidentMutations, useSites } from '../../lib/queries'
|
||||
import { Field } from '../FormSection'
|
||||
import { ErrorMessage } from '../QueryState'
|
||||
import { field, primaryButton, secondaryButton, smallButton } from '../styles'
|
||||
import { Section } from './Section'
|
||||
|
||||
function Text({ label, value }: { label: string; value: string | null }) {
|
||||
return (
|
||||
<div className="mb-4 last:mb-0">
|
||||
<h3 className="mb-1 text-2xs font-semibold text-[#586762]">{label}</h3>
|
||||
<p className={`text-xs leading-[1.6] whitespace-pre-wrap ${value ? 'text-body' : 'text-faint'}`}>{value || 'Not recorded'}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DetailsSection({ incident }: { incident: IncidentDetail }) {
|
||||
const [editing, setEditing] = useState(false)
|
||||
return (
|
||||
<Section
|
||||
title="What happened"
|
||||
action={
|
||||
incident.can.edit &&
|
||||
!editing && (
|
||||
<button type="button" className={smallButton} onClick={() => setEditing(true)}>
|
||||
<Pencil size={13} /> Edit
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{editing ? (
|
||||
<EditForm incident={incident} onDone={() => setEditing(false)} />
|
||||
) : (
|
||||
<>
|
||||
<Text label="Description" value={incident.description} />
|
||||
<Text label="Immediate response" value={incident.immediateAction} />
|
||||
<Text label="Exact location" value={incident.locationDetail} />
|
||||
</>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function EditForm({ incident, onDone }: { incident: IncidentDetail; onDone: () => void }) {
|
||||
const { update } = useIncidentMutations(incident.number)
|
||||
const sites = useSites()
|
||||
const [form, setForm] = useState({
|
||||
title: incident.title,
|
||||
type: incident.type as IncidentType,
|
||||
severity: incident.severity as Severity,
|
||||
occurredAt: toDateTimeLocal(new Date(incident.occurredAt)),
|
||||
siteId: incident.site.id,
|
||||
areaId: incident.area?.id ?? '',
|
||||
locationDetail: incident.locationDetail ?? '',
|
||||
description: incident.description ?? '',
|
||||
immediateAction: incident.immediateAction ?? '',
|
||||
})
|
||||
const set = (key: keyof typeof form) => (event: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
|
||||
setForm({ ...form, [key]: event.target.value, ...(key === 'siteId' ? { areaId: '' } : {}) })
|
||||
const areas = sites.data?.find((site) => site.id === form.siteId)?.areas ?? []
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
update.mutate({ ...form, areaId: form.areaId || null, occurredAt: new Date(form.occurredAt).toISOString() }, { onSuccess: onDone })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit}>
|
||||
<Field label="Title">
|
||||
<input className={field} required value={form.title} onChange={set('title')} />
|
||||
</Field>
|
||||
<div className="grid sm:grid-cols-3 sm:gap-4">
|
||||
<Field label="Type">
|
||||
<select className={field} value={form.type} onChange={set('type')}>
|
||||
{incidentTypes.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{incidentTypeLabel[type]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Severity">
|
||||
<select className={field} value={form.severity} onChange={set('severity')}>
|
||||
{severities.map((severity) => (
|
||||
<option key={severity} value={severity}>
|
||||
{severityLabel[severity]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="When">
|
||||
<input className={field} type="datetime-local" required value={form.occurredAt} onChange={set('occurredAt')} />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid sm:grid-cols-2 sm:gap-4">
|
||||
<Field label="Site">
|
||||
<select className={field} value={form.siteId} onChange={set('siteId')}>
|
||||
{sites.data?.map((site) => (
|
||||
<option key={site.id} value={site.id}>
|
||||
{site.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Building or area" optional>
|
||||
<select className={field} value={form.areaId} onChange={set('areaId')}>
|
||||
<option value="">Not specified</option>
|
||||
{areas.map((area) => (
|
||||
<option key={area.id} value={area.id}>
|
||||
{area.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Exact location" optional>
|
||||
<input className={field} value={form.locationDetail} onChange={set('locationDetail')} />
|
||||
</Field>
|
||||
<Field label="Description" optional>
|
||||
<textarea className={`${field} resize-y`} rows={4} value={form.description} onChange={set('description')} />
|
||||
</Field>
|
||||
<Field label="Immediate response" optional>
|
||||
<textarea className={`${field} resize-y`} rows={3} value={form.immediateAction} onChange={set('immediateAction')} />
|
||||
</Field>
|
||||
<ErrorMessage error={update.error} className="mb-3" />
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className={secondaryButton} onClick={onDone}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className={primaryButton} disabled={update.isPending}>
|
||||
Save changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { Plus, Trash2, X } from 'lucide-react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import type { IncidentDetail, RcaMethod, RootCause, RootCauseCategory } from '../../../shared/api-types.ts'
|
||||
import { rcaMethods, rootCauseCategories } from '../../../shared/enums.ts'
|
||||
import { formatDate } from '../../lib/dates'
|
||||
import { rcaMethodLabel, rootCauseCategoryLabel } from '../../lib/labels'
|
||||
import { useAssignableUsers, useIncidentMutations } from '../../lib/queries'
|
||||
import { Field } from '../FormSection'
|
||||
import { ErrorMessage } from '../QueryState'
|
||||
import { field, fieldBase, iconButton, primaryButton, secondaryButton, smallButton, textButton } from '../styles'
|
||||
import { Section } from './Section'
|
||||
|
||||
export function InvestigationSection({ incident }: { incident: IncidentDetail }) {
|
||||
const { investigation, rootCauses } = incident
|
||||
const canManage = incident.can.manageInvestigation
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const { removeRootCause } = useIncidentMutations(incident.number)
|
||||
|
||||
if (!investigation && !canManage) return null
|
||||
|
||||
return (
|
||||
<Section
|
||||
title="Investigation"
|
||||
action={
|
||||
canManage &&
|
||||
!editing && (
|
||||
<button type="button" className={smallButton} onClick={() => setEditing(true)}>
|
||||
{investigation ? 'Edit' : 'Record findings'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{editing ? (
|
||||
<InvestigationForm incident={incident} onDone={() => setEditing(false)} />
|
||||
) : investigation ? (
|
||||
<dl className="mb-5 grid gap-x-6 gap-y-3 text-xs sm:grid-cols-3">
|
||||
<div>
|
||||
<dt className="text-2xs font-semibold text-[#586762]">Lead</dt>
|
||||
<dd className="mt-0.5 text-body">{investigation.lead?.name ?? 'Unassigned'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-2xs font-semibold text-[#586762]">Method</dt>
|
||||
<dd className="mt-0.5 text-body">{rcaMethodLabel[investigation.method]}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-2xs font-semibold text-[#586762]">Status</dt>
|
||||
<dd className="mt-0.5 text-body">{investigation.completedAt ? `Completed ${formatDate(investigation.completedAt)}` : `Started ${formatDate(investigation.startedAt)}`}</dd>
|
||||
</div>
|
||||
{investigation.summary && (
|
||||
<div className="sm:col-span-3">
|
||||
<dt className="text-2xs font-semibold text-[#586762]">Findings</dt>
|
||||
<dd className="mt-0.5 leading-[1.6] whitespace-pre-wrap text-body">{investigation.summary}</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
) : (
|
||||
<p className="mb-5 text-xs text-muted">No findings recorded yet.</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold text-ink">Root causes</h3>
|
||||
{canManage && !adding && (
|
||||
<button type="button" className={`${textButton} print:hidden`} onClick={() => setAdding(true)}>
|
||||
<Plus size={13} /> Add root cause
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{adding && <RootCauseForm incident={incident} onDone={() => setAdding(false)} />}
|
||||
{rootCauses.length === 0 ? (
|
||||
!adding && <p className="mt-2 text-xs text-faint">None recorded yet.</p>
|
||||
) : investigation?.method === 'fishbone' ? (
|
||||
<Fishbone causes={rootCauses} onRemove={canManage ? (id) => removeRootCause.mutate(id) : undefined} />
|
||||
) : (
|
||||
<ul className="mt-3 space-y-3">
|
||||
{rootCauses.map((cause) => (
|
||||
<RootCauseItem key={cause.id} cause={cause} onRemove={canManage ? () => removeRootCause.mutate(cause.id) : undefined} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<ErrorMessage error={removeRootCause.error} className="mt-3" />
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
function RootCauseItem({ cause, onRemove, compact = false }: { cause: RootCause; onRemove?: () => void; compact?: boolean }) {
|
||||
return (
|
||||
<li className="rounded-md border border-line bg-field p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
{!compact && <span className="font-mono text-3xs tracking-[0.06em] text-faint uppercase">{rootCauseCategoryLabel[cause.category]}</span>}
|
||||
<p className="text-xs font-semibold text-ink">{cause.description}</p>
|
||||
</div>
|
||||
{onRemove && (
|
||||
<button type="button" className={`${iconButton} -m-1 print:hidden`} aria-label={`Remove root cause ${cause.description}`} onClick={onRemove}>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{cause.whys.length > 0 && (
|
||||
<ol className="mt-2 space-y-1 border-l-2 border-coral-soft pl-3">
|
||||
{cause.whys.map((why, i) => (
|
||||
<li key={i} className="text-2xs text-body">
|
||||
<span className="mr-1.5 font-mono text-coral">Why {i + 1}</span>
|
||||
{why}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
/** Causes laid out by the six fishbone (6M) categories. */
|
||||
function Fishbone({ causes, onRemove }: { causes: RootCause[]; onRemove?: (id: string) => void }) {
|
||||
return (
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{rootCauseCategories.map((category) => {
|
||||
const items = causes.filter((cause) => cause.category === category)
|
||||
return (
|
||||
<div key={category} className={`rounded-md border p-3 ${items.length ? 'border-coral-soft bg-[#fffaf8]' : 'border-dashed border-line'}`}>
|
||||
<h4 className="mb-2 font-mono text-3xs tracking-[0.06em] text-muted uppercase">{rootCauseCategoryLabel[category]}</h4>
|
||||
{items.length ? (
|
||||
<ul className="space-y-2">
|
||||
{items.map((cause) => (
|
||||
<RootCauseItem key={cause.id} cause={cause} compact onRemove={onRemove ? () => onRemove(cause.id) : undefined} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-3xs text-faint">Nothing identified</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InvestigationForm({ incident, onDone }: { incident: IncidentDetail; onDone: () => void }) {
|
||||
const { investigation } = useIncidentMutations(incident.number)
|
||||
const users = useAssignableUsers()
|
||||
const current = incident.investigation
|
||||
const [form, setForm] = useState({
|
||||
leadId: current?.lead?.id ?? '',
|
||||
method: (current?.method ?? 'five_whys') as RcaMethod,
|
||||
summary: current?.summary ?? '',
|
||||
completed: !!current?.completedAt,
|
||||
})
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
investigation.mutate({ ...form, leadId: form.leadId || null }, { onSuccess: onDone })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="mb-5">
|
||||
<div className="grid sm:grid-cols-2 sm:gap-4">
|
||||
<Field label="Lead investigator">
|
||||
<select className={field} value={form.leadId} onChange={(event) => setForm({ ...form, leadId: event.target.value })}>
|
||||
<option value="">Unassigned</option>
|
||||
{users.data?.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Root-cause method">
|
||||
<select className={field} value={form.method} onChange={(event) => setForm({ ...form, method: event.target.value as RcaMethod })}>
|
||||
{rcaMethods.map((method) => (
|
||||
<option key={method} value={method}>
|
||||
{rcaMethodLabel[method]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Findings" optional>
|
||||
<textarea className={`${field} resize-y`} rows={4} value={form.summary} onChange={(event) => setForm({ ...form, summary: event.target.value })} placeholder="What the investigation found" />
|
||||
</Field>
|
||||
<label className="mb-4 flex items-center gap-2 text-2xs text-[#586762]">
|
||||
<input type="checkbox" className="accent-coral" checked={form.completed} onChange={(event) => setForm({ ...form, completed: event.target.checked })} />
|
||||
Investigation complete
|
||||
</label>
|
||||
<ErrorMessage error={investigation.error} className="mb-3" />
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className={secondaryButton} onClick={onDone}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className={primaryButton} disabled={investigation.isPending}>
|
||||
Save investigation
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function RootCauseForm({ incident, onDone }: { incident: IncidentDetail; onDone: () => void }) {
|
||||
const { addRootCause } = useIncidentMutations(incident.number)
|
||||
const [category, setCategory] = useState<RootCauseCategory>('process')
|
||||
const [description, setDescription] = useState('')
|
||||
const [whys, setWhys] = useState<string[]>([''])
|
||||
|
||||
const submit = (event: FormEvent) => {
|
||||
event.preventDefault()
|
||||
addRootCause.mutate({ category, description, whys: whys.map((why) => why.trim()).filter(Boolean) }, { onSuccess: onDone })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="mt-3 rounded-md border border-line bg-canvas p-4">
|
||||
<div className="grid sm:grid-cols-[200px_1fr] sm:gap-4">
|
||||
<Field label="Category">
|
||||
<select className={field} value={category} onChange={(event) => setCategory(event.target.value as RootCauseCategory)}>
|
||||
{rootCauseCategories.map((value) => (
|
||||
<option key={value} value={value}>
|
||||
{rootCauseCategoryLabel[value]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Root cause">
|
||||
<input className={field} required value={description} onChange={(event) => setDescription(event.target.value)} placeholder="e.g. No step to refit guards after maintenance" />
|
||||
</Field>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<span className="text-2xs font-semibold text-[#586762]">5 Whys</span>
|
||||
<span className="ml-2 text-3xs text-faint">Ask “why?” until you reach something you can fix</span>
|
||||
{whys.map((why, index) => (
|
||||
<div key={index} className="mt-2 flex items-center gap-2">
|
||||
<span className="w-12 flex-none font-mono text-3xs text-coral">Why {index + 1}</span>
|
||||
<input
|
||||
className={fieldBase}
|
||||
value={why}
|
||||
aria-label={`Why ${index + 1}`}
|
||||
onChange={(event) => setWhys(whys.map((value, i) => (i === index ? event.target.value : value)))}
|
||||
/>
|
||||
{whys.length > 1 && (
|
||||
<button type="button" className={iconButton} aria-label={`Remove why ${index + 1}`} onClick={() => setWhys(whys.filter((_, i) => i !== index))}>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{whys.length < 5 && (
|
||||
<button type="button" className={`${textButton} mt-2`} onClick={() => setWhys([...whys, ''])}>
|
||||
<Plus size={13} /> Ask why again
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ErrorMessage error={addRootCause.error} className="mb-3" />
|
||||
<div className="flex justify-end gap-2">
|
||||
<button type="button" className={secondaryButton} onClick={onDone}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className={primaryButton} disabled={addRootCause.isPending}>
|
||||
Add root cause
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { panel, sectionHeading } from '../styles'
|
||||
|
||||
export function Section({ title, action, children, className = '' }: { title: string; action?: ReactNode; children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<section className={`${panel} break-inside-avoid ${className}`}>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-line px-5 py-4 sm:px-6">
|
||||
<h2 className={sectionHeading}>{title}</h2>
|
||||
{action && <div className="print:hidden">{action}</div>}
|
||||
</div>
|
||||
<div className="px-5 py-5 sm:px-6">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import type { IncidentDetail } from '../../../shared/api-types.ts'
|
||||
import { incidentStatuses } from '../../../shared/enums.ts'
|
||||
import { statusLabel } from '../../lib/labels'
|
||||
import { useIncidentMutations } from '../../lib/queries'
|
||||
import { Field } from '../FormSection'
|
||||
import { ErrorMessage } from '../QueryState'
|
||||
import { field, primaryButton, secondaryButton } from '../styles'
|
||||
import { Section } from './Section'
|
||||
|
||||
export function WorkflowPanel({ incident }: { incident: IncidentDetail }) {
|
||||
const { transition } = useIncidentMutations(incident.number)
|
||||
const [note, setNote] = useState('')
|
||||
const current = incidentStatuses.indexOf(incident.status)
|
||||
const forward = incident.transitions.filter((t) => incidentStatuses.indexOf(t.to) > current || incident.status === 'closed')
|
||||
const back = incident.transitions.filter((t) => !forward.includes(t))
|
||||
|
||||
return (
|
||||
<Section title="Workflow">
|
||||
<ol className="mb-5 space-y-1.5">
|
||||
{incidentStatuses.map((status, index) => {
|
||||
const done = index < current || incident.status === 'closed'
|
||||
const active = index === current
|
||||
return (
|
||||
<li key={status} className={`flex items-center gap-2.5 text-xs ${active ? 'font-semibold text-ink' : done ? 'text-body' : 'text-faint'}`}>
|
||||
<span
|
||||
className={`grid size-5 flex-none place-items-center rounded-full text-3xs ${active ? 'bg-coral text-white' : done ? 'bg-mint-soft text-mint-strong' : 'border border-line text-faint'}`}
|
||||
>
|
||||
{done && !active ? <Check size={12} /> : index + 1}
|
||||
</span>
|
||||
{statusLabel[status]}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
{incident.transitions.length > 0 && (
|
||||
<div className="space-y-2.5 border-t border-line pt-4 print:hidden">
|
||||
<Field label="Note" optional>
|
||||
<textarea className={`${field} resize-y`} rows={2} value={note} onChange={(event) => setNote(event.target.value)} placeholder="Added to the activity log with the change" />
|
||||
</Field>
|
||||
{[...forward, ...back].map((option) => (
|
||||
<div key={option.to}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${forward.includes(option) ? primaryButton : secondaryButton} w-full`}
|
||||
disabled={!!option.blockedReason || transition.isPending}
|
||||
onClick={() => transition.mutate({ to: option.to, note: note.trim() || undefined }, { onSuccess: () => setNote('') })}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
{option.blockedReason && <p className="mt-1 text-3xs text-muted">{option.blockedReason}</p>}
|
||||
</div>
|
||||
))}
|
||||
<ErrorMessage error={transition.error} />
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -2,14 +2,25 @@
|
||||
|
||||
export const panel = 'min-w-0 rounded-lg border border-line bg-white shadow-[0_5px_20px_#1b252305]'
|
||||
|
||||
const buttonBase = 'inline-flex cursor-pointer items-center justify-center gap-2 rounded-[5px] px-3.5 py-[11px] text-xs font-bold no-underline'
|
||||
const buttonBase =
|
||||
'inline-flex cursor-pointer items-center justify-center gap-2 rounded-[5px] px-3.5 py-[11px] text-xs font-bold no-underline disabled:cursor-not-allowed disabled:opacity-50'
|
||||
export const primaryButton = `${buttonBase} bg-coral text-white shadow-[0_4px_12px_#ee725833]`
|
||||
export const secondaryButton = `${buttonBase} border border-line bg-white text-ink`
|
||||
export const lightButton = `${buttonBase} bg-[#edf6ef] text-teal`
|
||||
|
||||
export const iconButton = 'grid cursor-pointer place-items-center bg-transparent p-1.5 text-[#72817d]'
|
||||
|
||||
export const field =
|
||||
'mt-[7px] block w-full rounded-[5px] border border-line bg-field px-3 py-[11px] text-xs text-ink outline-none focus:border-[#83b3a5] focus:shadow-[0_0_0_3px_#83b3a522]'
|
||||
/** Form control without spacing, for inline use. */
|
||||
export const fieldBase =
|
||||
'block w-full rounded-[5px] border border-line bg-field px-3 py-[11px] text-xs text-ink outline-none focus:border-[#83b3a5] focus:shadow-[0_0_0_3px_#83b3a522]'
|
||||
|
||||
/** Form control placed under its label. */
|
||||
export const field = `mt-[7px] ${fieldBase}`
|
||||
|
||||
export const filterBox = 'flex h-[38px] items-center gap-2 rounded-[5px] border border-line px-[11px] text-[#85938e]'
|
||||
|
||||
export const smallButton = 'inline-flex cursor-pointer items-center gap-1.5 rounded-[5px] border border-line bg-white px-2.5 py-1.5 text-2xs font-semibold text-ink hover:bg-canvas disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
export const textButton = 'inline-flex cursor-pointer items-center gap-1 text-2xs font-semibold text-coral hover:underline disabled:opacity-50'
|
||||
|
||||
export const sectionHeading = 'font-display text-[17px] font-semibold text-ink'
|
||||
Reference in new issue
Block a user