Add OSHA Form 301 and Osha page components
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s

- 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:
jayjennings committed 2026-10-06 13:09:57 -07:00
1 parent 3e964d1176
commit 61c876edec
59 files changed
+9180 -183

No files matched your search

+144 -2
View File
@@ -2,7 +2,8 @@
import { hashPassword } from '../auth/password.ts'
import { loadConfig } from '../config.ts'
import { openDatabase } from './client.ts'
import { auditLog, areas, incidentCounters, incidents, organizations, sites, users } from './schema.ts'
import { assessRecordability } from '../../shared/osha.ts'
import { auditLog, areas, correctiveActions, hoursWorked, incidentCounters, incidents, injuredPersons, investigations, organizations, rootCauses, sites, users } from './schema.ts'
const DEMO_PASSWORD = 'safetylog-demo'
@@ -79,10 +80,151 @@ try {
createdAt: incident.createdAt,
})),
)
await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber: demo.length })
// A year of history so dashboards and the OSHA log have something to show
const lastNumber = await seedHistory(tx as unknown as typeof db, org.id, demo.length, areaIds, people.map((p) => p.id))
await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber })
})
console.log(`Seeded demo data. Sign in as admin@example.com, manager@example.com, supervisor@example.com or reporter@example.com with password "${DEMO_PASSWORD}".`)
}
} finally {
await close()
}
/** Deterministic pseudo-random numbers, so every seed produces the same demo. */
function random(seed: number) {
let state = seed
return () => {
state = (state * 1_664_525 + 1_013_904_223) % 2 ** 32
return state / 2 ** 32
}
}
async function seedHistory(
database: typeof db,
organizationId: string,
startNumber: number,
areaIds: Record<string, { siteId: string; areaId: string }>,
userIds: string[],
): Promise<number> {
const rand = random(42)
const pick = <T>(items: readonly T[]) => items[Math.floor(rand() * items.length)]
const day = 86_400_000
const now = Date.now()
const locations = Object.values(areaIds)
// Near misses and hazards outnumber injuries, as in a healthy reporting culture
const types = ['near_miss', 'near_miss', 'near_miss', 'hazard', 'hazard', 'hazard', 'injury', 'injury', 'property_damage', 'environmental', 'illness'] as const
// Busier around shift changes (06–08, 14–16) and mid-shift
const hours = [6, 7, 7, 9, 10, 11, 13, 14, 14, 15, 15, 16, 19, 22, 2]
const titles = {
near_miss: ['Forklift nearly struck pedestrian', 'Load slipped from pallet jack', 'Unsecured ladder almost fell', 'Dropped tool from mezzanine'],
hazard: ['Oil leak under press', 'Blocked emergency exit', 'Damaged guard on conveyor', 'Frayed extension cord', 'Missing machine guard'],
injury: ['Hand laceration from sheet metal', 'Back strain lifting boxes', 'Slip on wet floor', 'Pinched finger in die'],
illness: ['Dermatitis from cutting fluid'],
property_damage: ['Forklift hit racking', 'Damaged dock door'],
environmental: ['Hydraulic fluid spill', 'Coolant leak to drain'],
} as const
const categories = ['people', 'process', 'equipment', 'materials', 'environment', 'management'] as const
let number = startNumber
for (let i = 0; i < 150; i++) {
const type = pick(types)
const occurred = new Date(now - Math.floor(rand() * 360 + 3) * day)
occurred.setHours(pick(hours), Math.floor(rand() * 60), 0, 0)
const created = new Date(occurred.getTime() + (1 + rand() * 6) * 3_600_000)
const age = (now - occurred.getTime()) / day
const status = age > 60 ? (rand() < 0.85 ? 'closed' : 'actions') : age > 20 ? pick(['investigation', 'actions', 'verification', 'closed'] as const) : pick(['reported', 'triage', 'investigation'] as const)
const closedAt = status === 'closed' ? new Date(created.getTime() + (5 + rand() * 35) * day) : null
number++
const [incident] = await database
.insert(incidents)
.values({
organizationId,
number,
title: pick(titles[type]),
type,
severity: type === 'injury' ? pick(['medium', 'high', 'high'] as const) : pick(['low', 'medium', 'medium', 'high'] as const),
status,
occurredAt: occurred,
createdAt: created,
closedAt: closedAt && closedAt.getTime() < now ? closedAt : null,
...pick(locations),
reporterId: pick(userIds),
})
.returning()
const auditRows: (typeof auditLog.$inferInsert)[] = [
{ organizationId, actorId: incident.reporterId, entityType: 'incident', entityId: incident.id, action: 'create', changes: { number, title: incident.title }, createdAt: created },
]
if (status !== 'reported') {
// Most reports are triaged within a day; the team got quicker over the year
const delayHours = rand() < 0.55 + (1 - age / 365) * 0.35 ? 2 + rand() * 18 : 26 + rand() * 70
auditRows.push({ organizationId, actorId: pick(userIds), entityType: 'incident', entityId: incident.id, action: 'status', changes: { status: ['reported', 'triage'] }, createdAt: new Date(created.getTime() + delayHours * 3_600_000) })
}
await database.insert(auditLog).values(auditRows)
if (['investigation', 'actions', 'verification', 'closed'].includes(status) && type !== 'near_miss') {
const [investigation] = await database.insert(investigations).values({ incidentId: incident.id, method: 'five_whys', startedAt: created }).returning()
const causes = await database
.insert(rootCauses)
.values(Array.from({ length: 1 + Math.floor(rand() * 2) }, () => ({ investigationId: investigation.id, category: pick(categories), description: 'Identified during investigation' })))
.returning()
const due = new Date(created.getTime() + 21 * day)
const done = status === 'closed' || status === 'verification' || rand() < 0.4
const completed = new Date(created.getTime() + (8 + rand() * 30) * day)
await database.insert(correctiveActions).values({
incidentId: incident.id,
rootCauseId: causes[0].id,
title: 'Fix the cause and brief the team',
assigneeId: pick(userIds),
dueDate: due.toISOString().slice(0, 10),
status: done && completed.getTime() < now ? (status === 'closed' ? 'verified' : 'done') : 'open',
completedAt: done && completed.getTime() < now ? completed : null,
})
}
if ((type === 'injury' || type === 'illness') && rand() < 0.6) {
const answers = {
employmentType: 'employee' as const,
workRelated: true,
newCase: true,
died: false,
daysAwayFromWork: rand() < 0.3,
restrictedOrTransfer: rand() < 0.4,
medicalTreatment: rand() < 0.7,
lossOfConsciousness: false,
significantDiagnosis: false,
specialCase: 'none' as const,
}
const verdict = assessRecordability(answers)
await database.insert(injuredPersons).values({
incidentId: incident.id,
caseNumber: `INC-${String(number).padStart(4, '0')}`,
name: pick(['Alex Moreno', 'Jamie Patel', 'Chris Nguyen', 'Taylor Brooks', 'Morgan Diaz']),
jobTitle: pick(['Press operator', 'Material handler', 'Maintenance technician', 'Assembler']),
natureOfInjury: incident.title,
caseType: type === 'illness' ? 'skin_disorder' : 'injury',
...answers,
daysAway: answers.daysAwayFromWork ? 1 + Math.floor(rand() * 10) : 0,
daysRestricted: answers.restrictedOrTransfer ? 2 + Math.floor(rand() * 14) : 0,
recordable: verdict.status === 'recordable',
outcome: verdict.outcome,
})
}
}
// Monthly hours worked and headcount for every site, for the last 13 months
const siteIds = [...new Set(locations.map((l) => l.siteId))]
const today = new Date()
for (const siteId of siteIds) {
for (let m = 12; m >= 0; m--) {
const month = new Date(today.getFullYear(), today.getMonth() - m, 1)
const employees = 70 + Math.floor(rand() * 30)
await database.insert(hoursWorked).values({
siteId,
month: `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, '0')}-01`,
hours: String(employees * 168),
averageEmployees: employees,
})
}
}
return number
}