- 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.
174 lines
9.3 KiB
TypeScript
174 lines
9.3 KiB
TypeScript
// 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 }
|
||
}
|