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
+144
-2
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user