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

+156 -11
View File
@@ -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 }
}
+12
View File
@@ -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]
+122
View File
@@ -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
View File
@@ -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
View File
@@ -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>