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
+105
-33
@@ -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>
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user