Files
jayjennings 61c876edec
CI / Lint, typecheck, test, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
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.
2026-10-06 13:09:57 -07:00

174 lines
9.3 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// OSHA recordkeeping rules (29 CFR Part 1904), shared by the API and the web app.
// Section references are to 29 CFR 1904. This helps apply the rules consistently; it is not legal advice.
import type { OshaCaseType, OshaOutcome, OshaSpecialCase } from './enums.ts'
export type EmploymentType = 'employee' | 'temporary' | 'contractor' | 'visitor'
export interface RecordabilityInput {
employmentType: EmploymentType
/** null while not yet answered */
workRelated: boolean | null
newCase: boolean | null
died: boolean
daysAwayFromWork: boolean
restrictedOrTransfer: boolean
medicalTreatment: boolean
lossOfConsciousness: boolean
significantDiagnosis: boolean
specialCase: OshaSpecialCase
}
export interface Recordability {
status: 'recordable' | 'not_recordable' | 'incomplete'
/** Form 300 column G–J; null unless recordable. */
outcome: OshaOutcome | null
/** Plain-language reasons, with the section that applies. */
reasons: string[]
}
const specialReasons: Record<Exclude<OshaSpecialCase, 'none'>, string> = {
needlestick: 'Needlestick or sharps injury contaminated with another person’s blood or other potentially infectious material (1904.8)',
medical_removal: 'Medical removal under an OSHA standard (1904.9)',
hearing_loss: 'Standard threshold shift in hearing with overall hearing level of 25 dB or more (1904.10)',
tuberculosis: 'Tuberculosis infection after workplace exposure to active TB (1904.11)',
}
export function assessRecordability(input: RecordabilityInput): Recordability {
if (input.employmentType === 'contractor' || input.employmentType === 'visitor') {
return {
status: 'not_recordable',
outcome: null,
reasons: ['Only your employees, and temporary or contract workers you supervise day to day, go on your log (1904.31). Record supervised contractors as “temporary”.'],
}
}
if (input.workRelated === null || input.newCase === null) return { status: 'incomplete', outcome: null, reasons: ['Answer whether the case is work-related and new.'] }
if (!input.workRelated) return { status: 'not_recordable', outcome: null, reasons: ['Not work-related (1904.5).'] }
if (!input.newCase) return { status: 'not_recordable', outcome: null, reasons: ['Not a new case: update the original case on the log instead (1904.6).'] }
const reasons: string[] = []
if (input.died) reasons.push('Death (1904.7(b)(2))')
if (input.daysAwayFromWork) reasons.push('Days away from work (1904.7(b)(3))')
if (input.restrictedOrTransfer) reasons.push('Restricted work or job transfer (1904.7(b)(4))')
if (input.medicalTreatment) reasons.push('Medical treatment beyond first aid (1904.7(b)(5))')
if (input.lossOfConsciousness) reasons.push('Loss of consciousness (1904.7(b)(6))')
if (input.significantDiagnosis) reasons.push('Significant injury or illness diagnosed by a physician or other licensed health care professional (1904.7(b)(7))')
if (input.specialCase !== 'none') reasons.push(specialReasons[input.specialCase])
if (reasons.length === 0) {
return { status: 'not_recordable', outcome: null, reasons: ['No recording criteria apply: first aid only, with no time lost, restriction or other criteria (1904.7).'] }
}
// Classified by the most serious outcome (Form 300 columns G–J)
const outcome: OshaOutcome = input.died ? 'death' : input.daysAwayFromWork ? 'days_away' : input.restrictedOrTransfer ? 'restricted' : 'other'
return { status: 'recordable', outcome, reasons }
}
export interface SevereInjuryInput {
died: boolean
inpatientHospitalization: boolean
amputation: boolean
lossOfEye: boolean
}
/** Whether 1904.39 requires reporting directly to OSHA, and how fast. */
export function severeInjuryReport(input: SevereInjuryInput): { withinHours: 8 | 24; reasons: string[] } | null {
if (input.died) return { withinHours: 8, reasons: ['work-related fatality'] }
const reasons = [
input.inpatientHospitalization && 'in-patient hospitalization',
input.amputation && 'amputation',
input.lossOfEye && 'loss of an eye',
].filter((reason): reason is string => !!reason)
return reasons.length ? { withinHours: 24, reasons } : null
}
export const OSHA_REPORTING_CONTACT = 'Call 1-800-321-OSHA (6742), report online at osha.gov/report, or contact your nearest OSHA area office.'
/** 1904.7(b)(3)(vii): you may stop counting once days away plus restricted days reach 180. */
export const DAY_COUNT_CAP = 180
export function capDays(daysAway: number, daysRestricted: number): { daysAway: number; daysRestricted: number } {
const away = Math.min(daysAway, DAY_COUNT_CAP)
return { daysAway: away, daysRestricted: Math.min(daysRestricted, DAY_COUNT_CAP - away) }
}
/** The complete first-aid list of 1904.7(b)(5)(ii). Anything else is medical treatment. */
export const FIRST_AID = [
'Non-prescription medication at non-prescription strength',
'Tetanus immunizations',
'Cleaning, flushing or soaking wounds on the surface of the skin',
'Wound coverings such as bandages, Band-Aids™, gauze pads, butterfly bandages or Steri-Strips™ (not sutures, staples or other wound closing devices)',
'Hot or cold therapy',
'Non-rigid means of support, such as elastic bandages, wraps or non-rigid back belts',
'Temporary immobilization devices while transporting an accident victim (splints, slings, neck collars, back boards)',
'Drilling a fingernail or toenail to relieve pressure, or draining fluid from a blister',
'Eye patches',
'Removing foreign bodies from the eye using only irrigation or a cotton swab',
'Removing splinters or foreign material from areas other than the eye by irrigation, tweezers, cotton swabs or other simple means',
'Finger guards',
'Massages',
'Drinking fluids for relief of heat stress',
] as const
/** 1904.29(b)(7): the employee's name must not go on the 300 Log. */
export const PRIVACY_CASES = [
'Injury or illness to an intimate body part or the reproductive system',
'Injury or illness resulting from a sexual assault',
'Mental illness',
'HIV infection, hepatitis or tuberculosis',
'Needlestick or sharps injury contaminated with another person’s blood or other potentially infectious material',
'Any other illness, if the employee asks for their name to be left off',
] as const
/** Injuries and illnesses per 100 full-time workers a year: cases × 200,000 ÷ hours worked. */
export function incidenceRate(cases: number, hoursWorked: number): number | null {
if (!(hoursWorked > 0)) return null
return Math.round(((cases * 200_000) / hoursWorked) * 100) / 100
}
/** ITA `size` code from the most employees at any point in the year. */
export function itaSizeCode(maxEmployees: number): 1 | 21 | 22 | 3 {
if (maxEmployees < 20) return 1
if (maxEmployees < 100) return 21
if (maxEmployees < 250) return 22
return 3
}
export const outcomeCode: Record<OshaOutcome, 1 | 2 | 3 | 4> = { death: 1, days_away: 2, restricted: 3, other: 4 }
export const caseTypeCode: Record<OshaCaseType, 1 | 2 | 3 | 4 | 5 | 6> = { injury: 1, skin_disorder: 2, respiratory: 3, poisoning: 4, hearing_loss: 5, other_illness: 6 }
/** Form 300A totals (fields G–M). */
export interface SummaryTotals {
deaths: number
daysAwayCases: number
restrictedCases: number
otherCases: number
daysAway: number
daysRestricted: number
byType: Record<OshaCaseType, number>
}
/**
* The checks OSHA's Injury Tracking Application applies to a 300A upload, so problems are caught
* before submitting. Errors would be rejected; warnings are "should" rules.
*/
export function summaryChecks(totals: SummaryTotals, annualAverageEmployees: number, totalHoursWorked: number) {
const errors: string[] = []
const warnings: string[] = []
const cases = totals.deaths + totals.daysAwayCases + totals.restrictedCases + totals.otherCases
const types = Object.values(totals.byType).reduce((sum, n) => sum + n, 0)
if (types !== cases) errors.push(`Injury and illness types (M1–M6) add up to ${types} but there are ${cases} cases (G–J).`)
if (totals.daysAwayCases > 0 && totals.daysAway === 0) errors.push('There are days-away cases (H) but no days away (K).')
if (totals.daysAway > 0 && totals.daysAwayCases === 0) errors.push('There are days away (K) but no days-away cases (H).')
if (totals.daysAway < totals.daysAwayCases) errors.push('Days away (K) must be at least the number of days-away cases (H).')
if (totals.restrictedCases > 0 && totals.daysRestricted === 0) errors.push('There are restriction/transfer cases (I) but no restricted days (L).')
if (totals.daysRestricted > 0 && totals.daysAwayCases + totals.restrictedCases === 0) errors.push('There are restricted days (L) but no days-away or restriction cases (H or I).')
if (!(annualAverageEmployees > 0)) errors.push('Enter the average number of employees for each month.')
if (!(totalHoursWorked > 0)) errors.push('Enter hours worked for each month.')
if (annualAverageEmployees > 0 && totalHoursWorked > 0) {
const perEmployee = totalHoursWorked / annualAverageEmployees
if (perEmployee >= 8760) errors.push('Hours worked per employee is more than the hours in a year; check hours and headcount.')
if (perEmployee <= 500) warnings.push('Fewer than 500 hours worked per employee; check hours and headcount.')
}
if (annualAverageEmployees > 0 && cases >= annualAverageEmployees) warnings.push('More cases than the average number of employees; check the headcount.')
return { errors, warnings }
}