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
+156
-11
@@ -1,9 +1,14 @@
|
||||
// Response shapes returned by the API. Dates are ISO 8601 strings.
|
||||
import type { SummaryTotals } from './osha.ts'
|
||||
import type {
|
||||
actionKinds,
|
||||
actionStatuses,
|
||||
AreaKind,
|
||||
employmentTypes,
|
||||
IncidentStatus,
|
||||
OshaCaseType,
|
||||
OshaOutcome,
|
||||
OshaSpecialCase,
|
||||
IncidentType,
|
||||
rcaMethods,
|
||||
Role,
|
||||
@@ -45,7 +50,18 @@ export interface Area {
|
||||
archivedAt: string | null
|
||||
}
|
||||
|
||||
export interface Site {
|
||||
export interface Establishment {
|
||||
streetAddress: string | null
|
||||
city: string | null
|
||||
state: string | null
|
||||
zip: string | null
|
||||
naicsCode: string | null
|
||||
industryDescription: string | null
|
||||
ein: string | null
|
||||
establishmentType: number
|
||||
}
|
||||
|
||||
export interface Site extends Partial<Establishment> {
|
||||
id: string
|
||||
name: string
|
||||
address: string | null
|
||||
@@ -153,8 +169,10 @@ export interface IncidentDetail extends Incident {
|
||||
actions: CorrectiveAction[]
|
||||
attachments: Attachment[]
|
||||
transitions: TransitionOption[]
|
||||
/** Number of injured or ill people recorded; details are only for safety managers. */
|
||||
injuredCount: number
|
||||
/** What the signed-in user may do on this incident. */
|
||||
can: { edit: boolean; manageInvestigation: boolean; manageActions: boolean }
|
||||
can: { edit: boolean; manageInvestigation: boolean; manageActions: boolean; manageOsha: boolean }
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
@@ -193,13 +211,140 @@ export interface OverviewStats {
|
||||
bySite: { siteId: string; name: string; count: number }[]
|
||||
}
|
||||
|
||||
export interface AnalyticsStats {
|
||||
periodDays: number
|
||||
total: number
|
||||
byType: { type: IncidentType; count: number }[]
|
||||
bySeverity: { severity: Severity; count: number }[]
|
||||
topRootCause: { category: RootCauseCategory; count: number; share: number } | null
|
||||
/** Share of completed actions finished on or before their due date. */
|
||||
onTimeActionRate: number | null
|
||||
monthlyCompletedActions: { month: string; onTime: number; late: number }[]
|
||||
/** A number for the selected period and the same-length period before it. */
|
||||
export interface Kpi {
|
||||
value: number | null
|
||||
previous: number | null
|
||||
}
|
||||
|
||||
/** Incident types grouped for trend charts. */
|
||||
export type TypeGroup = 'hazards' | 'injuries' | 'damage'
|
||||
|
||||
export interface AnalyticsStats {
|
||||
from: string
|
||||
to: string
|
||||
previousFrom: string
|
||||
bucket: 'week' | 'month'
|
||||
kpis: {
|
||||
reports: Kpi
|
||||
/** Leading: hazards and near misses reported. */
|
||||
leadingReports: Kpi
|
||||
injuries: Kpi
|
||||
/** Leading: hazards and near misses per injury or illness. */
|
||||
nearMissRatio: Kpi
|
||||
/** Leading: share of reports moved out of "reported" within 24 hours. */
|
||||
triagedWithin24h: Kpi
|
||||
/** Leading: share of actions completed by their due date. */
|
||||
actionsOnTime: Kpi
|
||||
averageDaysToClose: Kpi
|
||||
/** Lagging: OSHA-recordable cases (supervisors and above). */
|
||||
recordables: Kpi | null
|
||||
overdueActions: number
|
||||
}
|
||||
trend: ({ start: string } & Record<TypeGroup, number>)[]
|
||||
byLocation: { id: string | null; name: string; count: number }[]
|
||||
byRootCause: { category: RootCauseCategory; count: number }[]
|
||||
/** heatmap[weekday][hour], weekday 0 = Monday, in the server's time zone. */
|
||||
heatmap: number[][]
|
||||
actionsByMonth: { month: string; onTime: number; late: number }[]
|
||||
/** Rolling 12-month OSHA rates at each month end; null for reporters. */
|
||||
rates: { month: string; trir: number | null; dart: number | null }[] | null
|
||||
yearly: { year: number; recordables: number; dartCases: number; hours: number; trir: number | null; dart: number | null }[] | null
|
||||
}
|
||||
|
||||
export interface SavedView {
|
||||
id: string
|
||||
page: 'analytics' | 'incidents'
|
||||
name: string
|
||||
filters: Record<string, string>
|
||||
shared: boolean
|
||||
mine: boolean
|
||||
}
|
||||
|
||||
// ---- OSHA recordkeeping ----
|
||||
|
||||
export interface OshaCase {
|
||||
id: string
|
||||
incident: { id: string; number: number; reference: string; title: string; occurredAt: string }
|
||||
caseNumber: string
|
||||
name: string
|
||||
jobTitle: string | null
|
||||
employmentType: (typeof employmentTypes)[number]
|
||||
bodyPart: string | null
|
||||
natureOfInjury: string | null
|
||||
caseType: OshaCaseType
|
||||
workRelated: boolean | null
|
||||
newCase: boolean | null
|
||||
died: boolean
|
||||
dateOfDeath: string | null
|
||||
daysAwayFromWork: boolean
|
||||
daysAway: number
|
||||
restrictedOrTransfer: boolean
|
||||
daysRestricted: number
|
||||
medicalTreatment: boolean
|
||||
lossOfConsciousness: boolean
|
||||
significantDiagnosis: boolean
|
||||
specialCase: OshaSpecialCase
|
||||
privacyCase: boolean
|
||||
recordable: boolean
|
||||
outcome: OshaOutcome | null
|
||||
inpatientHospitalization: boolean
|
||||
amputation: boolean
|
||||
lossOfEye: boolean
|
||||
reportedToOshaAt: string | null
|
||||
/** Set when 1904.39 requires a direct report to OSHA. */
|
||||
severeReport: { withinHours: 8 | 24; reasons: string[]; dueBy: string } | null
|
||||
employeeAddress: string | null
|
||||
dateOfBirth: string | null
|
||||
dateOfHire: string | null
|
||||
sex: 'M' | 'F' | null
|
||||
physicianName: string | null
|
||||
treatmentFacility: string | null
|
||||
treatedInEmergencyRoom: boolean
|
||||
timeStartedWork: string | null
|
||||
activityBefore: string | null
|
||||
whatHappened: string | null
|
||||
objectSubstance: string | null
|
||||
/** Form 301 / ITA fields still empty for a recordable case. */
|
||||
missingFields: string[]
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** One line of the Form 300 Log. */
|
||||
export interface OshaLogRow {
|
||||
caseId: string
|
||||
caseNumber: string
|
||||
incidentNumber: number
|
||||
/** "Privacy case" for privacy concern cases. */
|
||||
employeeName: string
|
||||
jobTitle: string
|
||||
dateOfInjury: string
|
||||
location: string
|
||||
description: string
|
||||
outcome: OshaOutcome
|
||||
daysAway: number
|
||||
daysRestricted: number
|
||||
caseType: OshaCaseType
|
||||
missingFields: string[]
|
||||
}
|
||||
|
||||
export interface OshaYear {
|
||||
site: { id: string; name: string } & Establishment
|
||||
companyName: string
|
||||
year: number
|
||||
rows: OshaLogRow[]
|
||||
totals: SummaryTotals
|
||||
months: { month: number; hours: number | null; averageEmployees: number | null }[]
|
||||
totalHoursWorked: number
|
||||
annualAverageEmployees: number
|
||||
maxEmployees: number
|
||||
rates: { trir: number | null; dart: number | null; ltir: number | null }
|
||||
checks: { errors: string[]; warnings: string[] }
|
||||
missingEstablishmentFields: string[]
|
||||
}
|
||||
|
||||
export interface Form301 {
|
||||
case: OshaCase
|
||||
establishment: { name: string; companyName: string } & Establishment
|
||||
incident: { reference: string; occurredAt: string; title: string; description: string | null; site: string; area: string | null; locationDetail: string | null }
|
||||
}
|
||||
@@ -33,3 +33,15 @@ export const rootCauseCategories = ['people', 'process', 'equipment', 'materials
|
||||
export const actionKinds = ['corrective', 'preventive'] as const
|
||||
|
||||
export const actionStatuses = ['open', 'in_progress', 'done', 'verified', 'cancelled'] as const
|
||||
|
||||
/** Form 300 column M: injury or illness type. */
|
||||
export const oshaCaseTypes = ['injury', 'skin_disorder', 'respiratory', 'poisoning', 'hearing_loss', 'other_illness'] as const
|
||||
export type OshaCaseType = (typeof oshaCaseTypes)[number]
|
||||
|
||||
/** Form 300 columns G–J, most serious first. */
|
||||
export const oshaOutcomes = ['death', 'days_away', 'restricted', 'other'] as const
|
||||
export type OshaOutcome = (typeof oshaOutcomes)[number]
|
||||
|
||||
/** Cases recordable under 1904.8–1904.11 regardless of the general criteria. */
|
||||
export const oshaSpecialCases = ['none', 'needlestick', 'medical_removal', 'hearing_loss', 'tuberculosis'] as const
|
||||
export type OshaSpecialCase = (typeof oshaSpecialCases)[number]
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { assessRecordability, capDays, FIRST_AID, incidenceRate, itaSizeCode, severeInjuryReport, summaryChecks, type RecordabilityInput, type SummaryTotals } from './osha.ts'
|
||||
|
||||
const base: RecordabilityInput = {
|
||||
employmentType: 'employee',
|
||||
workRelated: true,
|
||||
newCase: true,
|
||||
died: false,
|
||||
daysAwayFromWork: false,
|
||||
restrictedOrTransfer: false,
|
||||
medicalTreatment: false,
|
||||
lossOfConsciousness: false,
|
||||
significantDiagnosis: false,
|
||||
specialCase: 'none',
|
||||
}
|
||||
const assess = (overrides: Partial<RecordabilityInput>) => assessRecordability({ ...base, ...overrides })
|
||||
|
||||
describe('assessRecordability', () => {
|
||||
it('first aid only is not recordable', () => {
|
||||
expect(assess({})).toMatchObject({ status: 'not_recordable', outcome: null })
|
||||
})
|
||||
|
||||
it('waits for the work-related and new-case answers', () => {
|
||||
expect(assess({ workRelated: null, medicalTreatment: true }).status).toBe('incomplete')
|
||||
expect(assess({ newCase: null, medicalTreatment: true }).status).toBe('incomplete')
|
||||
})
|
||||
|
||||
it('excludes non-work-related cases and recurrences', () => {
|
||||
expect(assess({ workRelated: false, died: true }).status).toBe('not_recordable')
|
||||
expect(assess({ newCase: false, medicalTreatment: true }).reasons[0]).toMatch(/1904\.6/)
|
||||
})
|
||||
|
||||
it('keeps contractors and visitors off your log', () => {
|
||||
expect(assess({ employmentType: 'contractor', daysAwayFromWork: true }).reasons[0]).toMatch(/1904\.31/)
|
||||
expect(assess({ employmentType: 'visitor', died: true }).status).toBe('not_recordable')
|
||||
expect(assess({ employmentType: 'temporary', medicalTreatment: true }).status).toBe('recordable')
|
||||
})
|
||||
|
||||
it('classifies by the most serious outcome', () => {
|
||||
expect(assess({ medicalTreatment: true }).outcome).toBe('other')
|
||||
expect(assess({ medicalTreatment: true, restrictedOrTransfer: true }).outcome).toBe('restricted')
|
||||
expect(assess({ restrictedOrTransfer: true, daysAwayFromWork: true }).outcome).toBe('days_away')
|
||||
expect(assess({ daysAwayFromWork: true, died: true }).outcome).toBe('death')
|
||||
})
|
||||
|
||||
it('records each general criterion on its own', () => {
|
||||
for (const key of ['lossOfConsciousness', 'significantDiagnosis', 'medicalTreatment'] as const) {
|
||||
expect(assess({ [key]: true })).toMatchObject({ status: 'recordable', outcome: 'other' })
|
||||
}
|
||||
})
|
||||
|
||||
it('records special cases even without general criteria', () => {
|
||||
for (const specialCase of ['needlestick', 'medical_removal', 'hearing_loss', 'tuberculosis'] as const) {
|
||||
expect(assess({ specialCase }).status).toBe('recordable')
|
||||
}
|
||||
expect(assess({ specialCase: 'needlestick' }).reasons[0]).toMatch(/1904\.8/)
|
||||
})
|
||||
|
||||
it('lists every reason that applies', () => {
|
||||
expect(assess({ medicalTreatment: true, lossOfConsciousness: true }).reasons).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('severeInjuryReport', () => {
|
||||
it('requires fatalities within 8 hours and other severe injuries within 24', () => {
|
||||
expect(severeInjuryReport({ died: true, inpatientHospitalization: true, amputation: false, lossOfEye: false })).toEqual({ withinHours: 8, reasons: ['work-related fatality'] })
|
||||
expect(severeInjuryReport({ died: false, inpatientHospitalization: false, amputation: true, lossOfEye: true })).toEqual({ withinHours: 24, reasons: ['amputation', 'loss of an eye'] })
|
||||
expect(severeInjuryReport({ died: false, inpatientHospitalization: false, amputation: false, lossOfEye: false })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('capDays', () => {
|
||||
it('stops counting at 180 days in total', () => {
|
||||
expect(capDays(10, 5)).toEqual({ daysAway: 10, daysRestricted: 5 })
|
||||
expect(capDays(150, 60)).toEqual({ daysAway: 150, daysRestricted: 30 })
|
||||
expect(capDays(200, 10)).toEqual({ daysAway: 180, daysRestricted: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('rates and codes', () => {
|
||||
it('computes incidence rates per 200,000 hours', () => {
|
||||
expect(incidenceRate(3, 200_000)).toBe(3)
|
||||
expect(incidenceRate(2, 150_000)).toBe(2.67)
|
||||
expect(incidenceRate(1, 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('maps headcount to ITA size codes', () => {
|
||||
expect([19, 20, 99, 100, 249, 250].map(itaSizeCode)).toEqual([1, 21, 21, 22, 22, 3])
|
||||
})
|
||||
|
||||
it('has the complete 14-item first-aid list', () => {
|
||||
expect(FIRST_AID).toHaveLength(14)
|
||||
})
|
||||
})
|
||||
|
||||
describe('summaryChecks (OSHA ITA validations)', () => {
|
||||
const totals = (overrides: Partial<SummaryTotals> = {}): SummaryTotals => ({
|
||||
deaths: 0,
|
||||
daysAwayCases: 1,
|
||||
restrictedCases: 1,
|
||||
otherCases: 1,
|
||||
daysAway: 5,
|
||||
daysRestricted: 3,
|
||||
byType: { injury: 3, skin_disorder: 0, respiratory: 0, poisoning: 0, hearing_loss: 0, other_illness: 0 },
|
||||
...overrides,
|
||||
})
|
||||
|
||||
it('passes a consistent summary', () => {
|
||||
expect(summaryChecks(totals(), 50, 100_000)).toEqual({ errors: [], warnings: [] })
|
||||
})
|
||||
|
||||
it('catches the cross-field rules OSHA enforces', () => {
|
||||
expect(summaryChecks(totals({ byType: { ...totals().byType, injury: 2 } }), 50, 100_000).errors[0]).toMatch(/add up to 2/)
|
||||
expect(summaryChecks(totals({ daysAway: 0 }), 50, 100_000).errors).toContain('There are days-away cases (H) but no days away (K).')
|
||||
expect(summaryChecks(totals({ restrictedCases: 0, daysAwayCases: 0, otherCases: 3, daysAway: 0 }), 50, 100_000).errors).toContain(
|
||||
'There are restricted days (L) but no days-away or restriction cases (H or I).',
|
||||
)
|
||||
expect(summaryChecks(totals(), 0, 0).errors).toHaveLength(2)
|
||||
expect(summaryChecks(totals(), 10, 100_000).errors[0]).toMatch(/more than the hours in a year/)
|
||||
expect(summaryChecks(totals(), 50, 20_000).warnings[0]).toMatch(/Fewer than 500/)
|
||||
})
|
||||
})
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
// 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 }
|
||||
}
|
||||
+105
-2
@@ -1,6 +1,6 @@
|
||||
// Request validation schemas, shared so the web app can validate forms with the same rules as the API.
|
||||
import { z } from 'zod'
|
||||
import { actionKinds, actionStatuses, areaKinds, incidentStatuses, incidentTypes, rcaMethods, roles, rootCauseCategories, severities } from './enums.ts'
|
||||
import { actionKinds, actionStatuses, areaKinds, employmentTypes, incidentStatuses, incidentTypes, oshaCaseTypes, oshaSpecialCases, rcaMethods, roles, rootCauseCategories, severities } from './enums.ts'
|
||||
|
||||
const email = z.string().trim().toLowerCase().max(254).pipe(z.email())
|
||||
const password = z.string().min(10, 'Use at least 10 characters').max(256)
|
||||
@@ -20,10 +20,97 @@ export const createUserSchema = z.object({ name, email, role: z.enum(roles), pas
|
||||
/** `password` lets an admin set a new password when email isn't available. */
|
||||
export const updateUserSchema = z.object({ name: name.optional(), role: z.enum(roles).optional(), active: z.boolean().optional(), password: password.optional() })
|
||||
|
||||
export const createSiteSchema = z.object({ name, address: optionalText(500), timezone: z.string().trim().max(64).optional() })
|
||||
const digits = (count: number[], label: string) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((value) => value.replace(/[\s-]/g, ''))
|
||||
.pipe(z.string().refine((value) => value === '' || (count.includes(value.length) && /^\d+$/.test(value)), `${label} must be ${count.join(' or ')} digits`))
|
||||
.transform((value) => value || null)
|
||||
.nullish()
|
||||
|
||||
/** OSHA establishment details, as required by Form 300A and the ITA upload. */
|
||||
const establishmentFields = {
|
||||
streetAddress: optionalText(100),
|
||||
city: optionalText(100),
|
||||
state: z
|
||||
.string()
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
.refine((value) => value === '' || /^[A-Z]{2}$/.test(value), 'Use the two-letter state code')
|
||||
.transform((value) => value || null)
|
||||
.nullish(),
|
||||
zip: digits([5, 9], 'ZIP code'),
|
||||
naicsCode: digits([6], 'NAICS code'),
|
||||
industryDescription: optionalText(300),
|
||||
ein: digits([9], 'EIN'),
|
||||
establishmentType: z.union([z.literal(1), z.literal(2), z.literal(3)]).optional(),
|
||||
}
|
||||
|
||||
export const createSiteSchema = z.object({ name, address: optionalText(500), timezone: z.string().trim().max(64).optional(), ...establishmentFields })
|
||||
|
||||
export const updateSiteSchema = createSiteSchema.partial().extend({ archived: z.boolean().optional() })
|
||||
|
||||
export const hoursWorkedSchema = z.object({
|
||||
year: z.number().int().min(2000).max(2100),
|
||||
months: z
|
||||
.array(
|
||||
z.object({
|
||||
month: z.number().int().min(1).max(12),
|
||||
hours: z.number().min(0).max(100_000_000).nullable(),
|
||||
averageEmployees: z.number().int().min(0).max(1_000_000).nullable(),
|
||||
}),
|
||||
)
|
||||
.max(12),
|
||||
})
|
||||
|
||||
const dayCount = z.number().int().min(0).max(180)
|
||||
const isoDateOnly = z.iso.date()
|
||||
|
||||
/** An injured or ill person on an incident, with the OSHA recordability answers and Form 301 details. */
|
||||
const oshaCaseFields = {
|
||||
name,
|
||||
jobTitle: optionalText(255),
|
||||
employmentType: z.enum(employmentTypes),
|
||||
bodyPart: optionalText(255),
|
||||
natureOfInjury: optionalText(500),
|
||||
caseType: z.enum(oshaCaseTypes),
|
||||
workRelated: z.boolean().nullable(),
|
||||
newCase: z.boolean().nullable(),
|
||||
died: z.boolean(),
|
||||
dateOfDeath: isoDateOnly.nullish(),
|
||||
daysAwayFromWork: z.boolean(),
|
||||
daysAway: dayCount,
|
||||
restrictedOrTransfer: z.boolean(),
|
||||
daysRestricted: dayCount,
|
||||
medicalTreatment: z.boolean(),
|
||||
lossOfConsciousness: z.boolean(),
|
||||
significantDiagnosis: z.boolean(),
|
||||
specialCase: z.enum(oshaSpecialCases),
|
||||
privacyCase: z.boolean(),
|
||||
inpatientHospitalization: z.boolean(),
|
||||
amputation: z.boolean(),
|
||||
lossOfEye: z.boolean(),
|
||||
reportedToOshaAt: isoDateTime.nullish(),
|
||||
employeeAddress: optionalText(500),
|
||||
dateOfBirth: isoDateOnly.nullish(),
|
||||
dateOfHire: isoDateOnly.nullish(),
|
||||
sex: z.enum(['M', 'F']).nullish(),
|
||||
physicianName: optionalText(200),
|
||||
treatmentFacility: optionalText(500),
|
||||
treatedInEmergencyRoom: z.boolean(),
|
||||
timeStartedWork: z
|
||||
.string()
|
||||
.regex(/^([01]\d|2[0-3]):[0-5]\d$/, 'Use 24-hour time, e.g. 07:30')
|
||||
.nullish(),
|
||||
activityBefore: optionalText(2_000),
|
||||
whatHappened: optionalText(5_000),
|
||||
objectSubstance: optionalText(500),
|
||||
}
|
||||
|
||||
export const createOshaCaseSchema = z.object(oshaCaseFields).partial().required({ name: true })
|
||||
export const updateOshaCaseSchema = z.object(oshaCaseFields).partial()
|
||||
|
||||
export const createAreaSchema = z.object({ name, kind: z.enum(areaKinds).default('area'), parentId: z.uuid().nullish() })
|
||||
|
||||
export const updateAreaSchema = z.object({ name: name.optional(), kind: z.enum(areaKinds).optional(), archived: z.boolean().optional() })
|
||||
@@ -96,6 +183,20 @@ export const updateKioskSchema = z.object({ name: name.optional(), siteId: z.uui
|
||||
/** Report filed through a reporting link (kiosk tablet or QR code), without an account. */
|
||||
export const kioskReportSchema = createIncidentSchema.omit({ anonymous: true }).extend({ reporterName: optionalText(200) })
|
||||
|
||||
export const analyticsQuery = z.object({
|
||||
from: z.iso.date(),
|
||||
to: z.iso.date(),
|
||||
siteId: z.uuid().optional(),
|
||||
type: z.enum(incidentTypes).optional(),
|
||||
})
|
||||
|
||||
export const savedViewSchema = z.object({
|
||||
page: z.enum(['analytics', 'incidents']),
|
||||
name,
|
||||
filters: z.record(z.string().max(50), z.string().max(200)).refine((value) => Object.keys(value).length <= 20, 'Too many filters'),
|
||||
shared: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export const emailOnlySchema = z.object({ email })
|
||||
|
||||
export const tokenSchema = z.object({ token: z.string().min(20).max(200) })
|
||||
@@ -114,6 +215,8 @@ export const incidentListQuery = z.object({
|
||||
|
||||
export type SetupInput = z.infer<typeof setupSchema>
|
||||
export type CreateIncidentInput = z.input<typeof createIncidentSchema>
|
||||
export type OshaCaseInput = z.input<typeof updateOshaCaseSchema>
|
||||
export type SiteInput = z.input<typeof updateSiteSchema>
|
||||
export type KioskReportInput = z.input<typeof kioskReportSchema>
|
||||
export type UpdateIncidentInput = z.infer<typeof updateIncidentSchema>
|
||||
export type CreateActionInput = z.input<typeof createActionSchema>
|
||||
|
||||
Reference in new issue
Block a user