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

+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>