- 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.
228 lines
9.1 KiB
TypeScript
228 lines
9.1 KiB
TypeScript
// 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, 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)
|
|
const name = z.string().trim().min(1).max(200)
|
|
const optionalText = (max: number) => z.string().trim().max(max).nullish()
|
|
const isoDateTime = z.iso.datetime({ offset: true })
|
|
|
|
export const setupSchema = z.object({ organizationName: name, name, email, password })
|
|
|
|
export const loginSchema = z.object({ email, password: z.string().min(1).max(256) })
|
|
|
|
export const changePasswordSchema = z.object({ currentPassword: z.string().min(1).max(256), newPassword: password })
|
|
|
|
/** Without a password, the user is emailed an invitation to set one. */
|
|
export const createUserSchema = z.object({ name, email, role: z.enum(roles), password: password.optional() })
|
|
|
|
/** `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() })
|
|
|
|
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() })
|
|
|
|
export const createIncidentSchema = z.object({
|
|
title: name,
|
|
type: z.enum(incidentTypes),
|
|
severity: z.enum(severities),
|
|
occurredAt: isoDateTime,
|
|
siteId: z.uuid(),
|
|
areaId: z.uuid().nullish(),
|
|
locationDetail: optionalText(500),
|
|
description: optionalText(10_000),
|
|
immediateAction: optionalText(10_000),
|
|
anonymous: z.boolean().default(false),
|
|
/** Random id from the reporting device; resubmitting the same id returns the existing report. */
|
|
clientId: z.uuid().optional(),
|
|
})
|
|
|
|
/** Status changes go through transitions, not updates. */
|
|
export const updateIncidentSchema = createIncidentSchema.omit({ anonymous: true, clientId: true }).partial()
|
|
|
|
export const transitionSchema = z.object({ to: z.enum(incidentStatuses), note: optionalText(5_000) })
|
|
|
|
export const commentSchema = z.object({ body: z.string().trim().min(1).max(5_000) })
|
|
|
|
export const investigationSchema = z.object({
|
|
leadId: z.uuid().nullish(),
|
|
method: z.enum(rcaMethods).optional(),
|
|
summary: optionalText(20_000),
|
|
completed: z.boolean().optional(),
|
|
})
|
|
|
|
const rootCauseFields = {
|
|
category: z.enum(rootCauseCategories),
|
|
description: z.string().trim().min(1).max(2_000),
|
|
whys: z.array(z.string().trim().min(1).max(1_000)).max(10),
|
|
}
|
|
|
|
export const rootCauseSchema = z.object({ ...rootCauseFields, whys: rootCauseFields.whys.default([]) })
|
|
|
|
// Update schemas are written out rather than derived with .partial(): in Zod 4, .partial() keeps
|
|
// defaults, so a partial update would silently reset omitted fields.
|
|
export const updateRootCauseSchema = z.object(rootCauseFields).partial()
|
|
|
|
const isoDate = z.iso.date()
|
|
|
|
const actionFields = {
|
|
title: name,
|
|
description: optionalText(5_000),
|
|
kind: z.enum(actionKinds),
|
|
assigneeId: z.uuid().nullish(),
|
|
dueDate: isoDate.nullish(),
|
|
rootCauseId: z.uuid().nullish(),
|
|
}
|
|
|
|
export const createActionSchema = z.object({ ...actionFields, kind: actionFields.kind.default('corrective') })
|
|
|
|
export const updateActionSchema = z.object({ ...actionFields, status: z.enum(actionStatuses) }).partial()
|
|
|
|
export const actionListQuery = z.object({
|
|
scope: z.enum(['mine', 'all']).default('mine'),
|
|
filter: z.enum(['open', 'overdue', 'done', 'all']).default('open'),
|
|
})
|
|
|
|
export const createKioskSchema = z.object({ name, siteId: z.uuid().nullish() })
|
|
|
|
export const updateKioskSchema = z.object({ name: name.optional(), siteId: z.uuid().nullish(), revoked: z.boolean().optional() })
|
|
|
|
/** 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) })
|
|
|
|
export const resetPasswordSchema = z.object({ token: z.string().min(20).max(200), newPassword: password })
|
|
|
|
export const incidentListQuery = z.object({
|
|
page: z.coerce.number().int().min(1).default(1),
|
|
pageSize: z.coerce.number().int().min(1).max(100).default(25),
|
|
q: z.string().trim().max(200).optional(),
|
|
type: z.enum(incidentTypes).optional(),
|
|
severity: z.enum(severities).optional(),
|
|
status: z.enum(incidentStatuses).optional(),
|
|
siteId: z.uuid().optional(),
|
|
})
|
|
|
|
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>
|
|
export type UpdateActionInput = z.input<typeof updateActionSchema>
|
|
export type RootCauseInput = z.input<typeof rootCauseSchema>
|
|
export type UpdateRootCauseInput = z.input<typeof updateRootCauseSchema>
|
|
export type InvestigationInput = z.input<typeof investigationSchema>
|
|
export type IncidentListQuery = z.infer<typeof incidentListQuery>
|