feat: implement permissions and actions management
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s

- 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:
jayjennings committed 2026-10-06 11:20:44 -07:00
1 parent 708da6b9f7
commit 3f6dff5f24
94 files changed
+7213 -643

No files matched your search

+23 -5
View File
@@ -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>
)
+24
View File
@@ -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>
)
}
+20
View File
@@ -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
View File
@@ -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>
)
+33 -39
View File
@@ -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>
+2 -4
View File
@@ -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>
)
}
+32
View File
@@ -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>
)
}
+25
View File
@@ -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
View File
@@ -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>
+159
View File
@@ -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>
)
}
+141
View File
@@ -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>
)
}
+14
View File
@@ -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>
)
}
+60
View File
@@ -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>
)
}
+14 -3
View File
@@ -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'