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

+105 -33
View File
@@ -1,24 +1,63 @@
import { FileText, Filter, Plus, Search } from 'lucide-react'
import { useState } from 'react'
import { NavLink } from 'react-router-dom'
import { FileDown, Filter, Plus, Search } from 'lucide-react'
import { useEffect, useState } from 'react'
import { NavLink, useSearchParams } from 'react-router-dom'
import { incidentStatuses, incidentTypes, severities } from '../../shared/enums.ts'
import { IncidentTable } from '../components/IncidentTable'
import { Page } from '../components/Page'
import { PageIntro } from '../components/PageIntro'
import { Pagination } from '../components/Pagination'
import { Empty, ErrorMessage, Loading } from '../components/QueryState'
import { filterBox, panel, primaryButton, secondaryButton } from '../components/styles'
import { ALL_TYPES, filterIncidents, type TypeFilter } from '../lib/incidents'
import { incidentTypes, type Incident } from '../lib/types'
import { incidentTypeLabel, severityLabel, statusLabel } from '../lib/labels'
import { filtersToQuery, useIncidents, useSites, type IncidentFilters } from '../lib/queries'
export function Incidents({ incidents }: { incidents: Incident[] }) {
const [query, setQuery] = useState('')
const [type, setType] = useState<TypeFilter>(ALL_TYPES)
const filtered = filterIncidents(incidents, query, type)
const PAGE_SIZE = 25
const select = 'bg-transparent text-xs text-ink outline-none'
export function Incidents() {
const [params, setParams] = useSearchParams()
const sites = useSites()
const filters: IncidentFilters = {
q: params.get('q') ?? undefined,
type: params.get('type') ?? undefined,
severity: params.get('severity') ?? undefined,
status: params.get('status') ?? undefined,
siteId: params.get('siteId') ?? undefined,
}
const page = Number(params.get('page') ?? 1)
const incidents = useIncidents({ ...filters, page, pageSize: PAGE_SIZE })
// Search box updates the URL after a short pause
const [query, setQuery] = useState(filters.q ?? '')
useEffect(() => setQuery(params.get('q') ?? ''), [params])
useEffect(() => {
const handle = setTimeout(() => {
if ((params.get('q') ?? '') !== query.trim()) setFilter('q', query.trim())
}, 300)
return () => clearTimeout(handle)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query])
function setFilter(key: string, value: string) {
const next = new URLSearchParams(params)
if (value) next.set(key, value)
else next.delete(key)
next.delete('page')
setParams(next, { replace: true })
}
const setPage = (value: number) => {
const next = new URLSearchParams(params)
next.set('page', String(value))
setParams(next)
}
return (
<Page>
<PageIntro
eyebrow="Safety register"
title="Incidents"
copy="A complete record of reported events, hazards, and learnings."
copy="Every reported event, hazard and near miss, and where each one stands."
action={
<NavLink className={primaryButton} to="/report">
<Plus size={17} /> Report incident
@@ -26,36 +65,69 @@ export function Incidents({ incidents }: { incidents: Incident[] }) {
}
/>
<section className={`${panel} overflow-hidden`}>
<div className="flex flex-wrap items-center gap-2.5 border-b border-line px-[17px] py-3.5 sm:flex-nowrap sm:px-6 sm:py-[18px]">
<div className={`${filterBox} basis-full sm:max-w-[350px] sm:flex-1 sm:basis-auto`}>
<div className="flex flex-wrap items-center gap-2.5 border-b border-line px-[17px] py-3.5 sm:px-6 sm:py-[18px]">
<div className={`${filterBox} basis-full lg:max-w-[300px] lg:flex-1 lg:basis-auto`}>
<Search size={17} />
<input
className="w-full bg-transparent text-xs text-ink outline-none"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search incidents"
aria-label="Search incidents"
/>
<input className="w-full bg-transparent text-xs text-ink outline-none" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search title, location or INC number" aria-label="Search incidents" />
</div>
<div className={`${filterBox} sm:ml-auto`}>
<div className={filterBox}>
<Filter size={16} />
<select
className="bg-transparent text-xs text-ink outline-none"
value={type}
onChange={(event) => setType(event.target.value as TypeFilter)}
aria-label="Filter by type"
>
<option>{ALL_TYPES}</option>
{incidentTypes.map((option) => (
<option key={option}>{option}</option>
<select className={select} value={filters.type ?? ''} onChange={(event) => setFilter('type', event.target.value)} aria-label="Filter by type">
<option value="">All types</option>
{incidentTypes.map((type) => (
<option key={type} value={type}>
{incidentTypeLabel[type]}
</option>
))}
</select>
</div>
<button type="button" className={secondaryButton}>
<FileText size={16} /> Export
</button>
<div className={filterBox}>
<select className={select} value={filters.severity ?? ''} onChange={(event) => setFilter('severity', event.target.value)} aria-label="Filter by severity">
<option value="">All severities</option>
{severities.map((severity) => (
<option key={severity} value={severity}>
{severityLabel[severity]}
</option>
))}
</select>
</div>
<div className={filterBox}>
<select className={select} value={filters.status ?? ''} onChange={(event) => setFilter('status', event.target.value)} aria-label="Filter by status">
<option value="">All statuses</option>
{incidentStatuses.map((status) => (
<option key={status} value={status}>
{statusLabel[status]}
</option>
))}
</select>
</div>
{(sites.data?.length ?? 0) > 1 && (
<div className={filterBox}>
<select className={select} value={filters.siteId ?? ''} onChange={(event) => setFilter('siteId', event.target.value)} aria-label="Filter by site">
<option value="">All sites</option>
{sites.data?.map((site) => (
<option key={site.id} value={site.id}>
{site.name}
</option>
))}
</select>
</div>
)}
<a className={`${secondaryButton} lg:ml-auto`} href={`/api/incidents/export${filtersToQuery(filters)}`} download>
<FileDown size={16} /> Export CSV
</a>
</div>
<IncidentTable incidents={filtered} wide />
<ErrorMessage error={incidents.error} className="m-4" />
{incidents.isPending ? (
<Loading />
) : incidents.data?.items.length ? (
<>
<IncidentTable incidents={incidents.data.items} wide />
<Pagination page={page} pageSize={PAGE_SIZE} total={incidents.data.total} onPage={setPage} />
</>
) : (
<Empty>{Object.values(filters).some(Boolean) ? 'No incidents match these filters.' : 'No incidents reported yet.'}</Empty>
)}
</section>
</Page>
)