Add OSHA Form 301 and Osha page components
- Implemented Form301 component for displaying OSHA Form 301 equivalent, including employee and case information. - Created Osha component to manage OSHA recordkeeping, including tabs for 300 Log, 300A Summary, Hours & rates, and Submit to OSHA. - Added functionality for printing and downloading CSV files for OSHA submissions. - Integrated loading and error handling states for data fetching.
This commit is contained in:
1 parent
3e964d1176
commit
61c876edec
59 files changed
+9180
-183
No files matched your search
@@ -0,0 +1,130 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { buckets } from '../lib/analytics.ts'
|
||||
import { localDate } from '../lib/dates.ts'
|
||||
import { createTestContext } from '../test/helpers.ts'
|
||||
|
||||
let ctx: Awaited<ReturnType<typeof createTestContext>>
|
||||
let manager: Awaited<ReturnType<typeof ctx.createUser>>
|
||||
let reporter: Awaited<ReturnType<typeof ctx.createUser>>
|
||||
let north: { id: string }
|
||||
let south: { id: string }
|
||||
|
||||
const daysAgo = (days: number, hour = 10) => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - days)
|
||||
d.setHours(hour, 0, 0, 0)
|
||||
return d
|
||||
}
|
||||
const range = (fromDaysAgo: number, toDaysAgo = 0) => `from=${localDate(daysAgo(fromDaysAgo))}&to=${localDate(daysAgo(toDaysAgo))}`
|
||||
|
||||
async function report(type: string, when: Date, siteId = north.id, by = manager) {
|
||||
const res = await by.post('/api/incidents', { title: `${type} report`, type, severity: 'low', occurredAt: when.toISOString(), siteId })
|
||||
return res.body.incident.number as number
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx = await createTestContext()
|
||||
north = await ctx.createSite('North')
|
||||
south = await ctx.createSite('South')
|
||||
manager = await ctx.createUser('safety_manager')
|
||||
reporter = await ctx.createUser('reporter')
|
||||
// Current 30-day period: 3 near misses/hazards, 1 injury, 1 spill
|
||||
await report('near_miss', daysAgo(2, 14))
|
||||
await report('near_miss', daysAgo(3, 14))
|
||||
await report('hazard', daysAgo(5, 9), south.id)
|
||||
const injury = await report('injury', daysAgo(6, 22))
|
||||
await report('environmental', daysAgo(10))
|
||||
// Previous period: 1 near miss, 2 injuries
|
||||
await report('near_miss', daysAgo(40))
|
||||
await report('injury', daysAgo(45))
|
||||
await report('injury', daysAgo(50))
|
||||
|
||||
// Triage one report straight away
|
||||
await manager.post(`/api/incidents/${injury}/transitions`, { to: 'triage' })
|
||||
// A recordable case with hours worked
|
||||
await manager.post(`/api/osha/incidents/${injury}/cases`, { name: 'Case', workRelated: true, newCase: true, daysAwayFromWork: true, daysAway: 2 })
|
||||
const now = new Date()
|
||||
await manager.put(`/api/osha/sites/${north.id}/hours`, { year: now.getFullYear(), months: [{ month: now.getMonth() + 1, hours: 10_000, averageEmployees: 60 }] })
|
||||
})
|
||||
afterAll(() => ctx.close())
|
||||
|
||||
describe('analytics', () => {
|
||||
it('compares the period with the one before', async () => {
|
||||
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
|
||||
expect(stats.kpis.reports).toEqual({ value: 5, previous: 3 })
|
||||
expect(stats.kpis.leadingReports).toEqual({ value: 3, previous: 1 })
|
||||
expect(stats.kpis.injuries).toEqual({ value: 1, previous: 2 })
|
||||
expect(stats.kpis.nearMissRatio).toEqual({ value: 3, previous: 0.5 })
|
||||
expect(stats.kpis.triagedWithin24h.value).toBeCloseTo(0.2)
|
||||
expect(stats.kpis.recordables).toEqual({ value: 1, previous: 0 })
|
||||
})
|
||||
|
||||
it('groups the trend, locations and the time-of-day heatmap', async () => {
|
||||
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
|
||||
expect(stats.bucket).toBe('week')
|
||||
const totals = stats.trend.reduce((sum: Record<string, number>, b: Record<string, number>) => ({ hazards: sum.hazards + b.hazards, injuries: sum.injuries + b.injuries, damage: sum.damage + b.damage }), { hazards: 0, injuries: 0, damage: 0 })
|
||||
expect(totals).toEqual({ hazards: 3, injuries: 1, damage: 1 })
|
||||
expect(stats.byLocation).toEqual([
|
||||
{ id: north.id, name: 'North', count: 4 },
|
||||
{ id: south.id, name: 'South', count: 1 },
|
||||
])
|
||||
expect(stats.heatmap).toHaveLength(7)
|
||||
expect(stats.heatmap.flat().reduce((a: number, b: number) => a + b, 0)).toBe(5)
|
||||
expect(stats.heatmap[(daysAgo(6).getDay() + 6) % 7][22]).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('narrows to a site (showing its areas) and a type', async () => {
|
||||
const stats = (await manager.get(`/api/stats/analytics?${range(29)}&siteId=${north.id}&type=near_miss`)).body
|
||||
expect(stats.kpis.reports.value).toBe(2)
|
||||
expect(stats.byLocation).toEqual([{ id: null, name: 'Area not specified', count: 2 }])
|
||||
})
|
||||
|
||||
it('computes rolling 12-month OSHA rates from hours worked', async () => {
|
||||
const stats = (await manager.get(`/api/stats/analytics?${range(29)}`)).body
|
||||
expect(stats.rates).toHaveLength(12)
|
||||
expect(stats.rates.at(-1)).toEqual({ month: localDate(new Date()).slice(0, 7), trir: 20, dart: 20 })
|
||||
// Summed over the years, so the test holds even when the injury falls in last year (early January)
|
||||
expect(stats.yearly.reduce((sum: number, y: { recordables: number }) => sum + y.recordables, 0)).toBe(1)
|
||||
expect(stats.yearly.at(-1)).toMatchObject({ year: new Date().getFullYear(), hours: 10_000 })
|
||||
})
|
||||
|
||||
it('scopes reporters to their own reports and hides rates', async () => {
|
||||
await report('hazard', daysAgo(1), north.id, reporter)
|
||||
const stats = (await reporter.get(`/api/stats/analytics?${range(29)}`)).body
|
||||
expect(stats.kpis.reports.value).toBe(1)
|
||||
expect(stats.rates).toBeNull()
|
||||
expect(stats.kpis.recordables).toBeNull()
|
||||
})
|
||||
|
||||
it('validates the range', async () => {
|
||||
expect((await manager.get(`/api/stats/analytics?from=2026-02-01&to=2026-01-01`)).status).toBe(400)
|
||||
expect((await manager.get(`/api/stats/analytics?from=2015-01-01&to=2026-01-01`)).status).toBe(400)
|
||||
})
|
||||
|
||||
it('uses weeks for short ranges and months for long ones', () => {
|
||||
expect(buckets('2026-01-01', '2026-03-31').bucket).toBe('week')
|
||||
const long = buckets('2025-01-15', '2025-12-31')
|
||||
expect(long.bucket).toBe('month')
|
||||
expect(long.starts).toHaveLength(12)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saved views', () => {
|
||||
it('keeps private views private and lets supervisors share', async () => {
|
||||
const supervisor = await ctx.createUser('supervisor')
|
||||
const mine = await reporter.post('/api/views', { page: 'analytics', name: 'My hazards', filters: { type: 'hazard', range: '90' } })
|
||||
expect(mine.status).toBe(201)
|
||||
expect((await reporter.post('/api/views', { page: 'analytics', name: 'Shared?', filters: {}, shared: true })).status).toBe(403)
|
||||
await supervisor.post('/api/views', { page: 'analytics', name: 'Plant review', filters: { range: '365' }, shared: true })
|
||||
|
||||
const reporterViews = (await reporter.get('/api/views?page=analytics')).body.views.map((v: { name: string; mine: boolean }) => [v.name, v.mine])
|
||||
expect(reporterViews).toEqual([
|
||||
['My hazards', true],
|
||||
['Plant review', false],
|
||||
])
|
||||
expect((await supervisor.get('/api/views?page=analytics')).body.views.map((v: { name: string }) => v.name)).toEqual(['Plant review'])
|
||||
|
||||
expect((await reporter.delete(`/api/views/${(await supervisor.get('/api/views?page=analytics')).body.views[0].id}`)).status).toBe(403)
|
||||
expect((await reporter.delete(`/api/views/${mine.body.view.id}`)).status).toBe(204)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user