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

@@ -0,0 +1,41 @@
CREATE TYPE "public"."osha_case_type" AS ENUM('injury', 'skin_disorder', 'respiratory', 'poisoning', 'hearing_loss', 'other_illness');--> statement-breakpoint
CREATE TYPE "public"."osha_outcome" AS ENUM('death', 'days_away', 'restricted', 'other');--> statement-breakpoint
CREATE TYPE "public"."osha_special_case" AS ENUM('none', 'needlestick', 'medical_removal', 'hearing_loss', 'tuberculosis');--> statement-breakpoint
ALTER TABLE "hours_worked" ADD COLUMN "average_employees" integer;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "case_number" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "work_related" boolean;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "new_case" boolean;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "died" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "date_of_death" date;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "days_away_from_work" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "restricted_or_transfer" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "medical_treatment" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "loss_of_consciousness" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "significant_diagnosis" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "special_case" "osha_special_case" DEFAULT 'none' NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "case_type" "osha_case_type" DEFAULT 'injury' NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "recordable" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "outcome" "osha_outcome";--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "inpatient_hospitalization" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "amputation" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "loss_of_eye" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "reported_to_osha_at" timestamp with time zone;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "employee_address" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "date_of_birth" date;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "date_of_hire" date;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "sex" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "physician_name" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "treatment_facility" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "treated_in_emergency_room" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "time_started_work" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "activity_before" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "what_happened" text;--> statement-breakpoint
ALTER TABLE "injured_persons" ADD COLUMN "object_substance" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "street_address" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "city" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "state" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "zip" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "naics_code" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "industry_description" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "ein" text;--> statement-breakpoint
ALTER TABLE "sites" ADD COLUMN "establishment_type" integer DEFAULT 1 NOT NULL;
+14
View File
@@ -0,0 +1,14 @@
CREATE TABLE "saved_views" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"organization_id" uuid NOT NULL,
"user_id" uuid NOT NULL,
"page" text NOT NULL,
"name" text NOT NULL,
"filters" jsonb NOT NULL,
"shared" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "saved_views" ADD CONSTRAINT "saved_views_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "saved_views" ADD CONSTRAINT "saved_views_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "saved_views_organization_page_idx" ON "saved_views" USING btree ("organization_id","page");
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+14
View File
@@ -22,6 +22,20 @@
"when": 1791310953907,
"tag": "0002_kiosks_and_offline",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1791312050891,
"tag": "0003_osha_recordkeeping",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1791315152348,
"tag": "0004_saved_views",
"breakpoints": true
}
]
}
+71
View File
@@ -14,6 +14,9 @@ export const actionKindEnum = pgEnum('action_kind', vocab.actionKinds)
export const actionStatusEnum = pgEnum('action_status', vocab.actionStatuses)
export const authTokenPurposeEnum = pgEnum('auth_token_purpose', ['magic_link', 'password_reset'])
export const reportChannelEnum = pgEnum('report_channel', ['web', 'kiosk'])
export const oshaCaseTypeEnum = pgEnum('osha_case_type', vocab.oshaCaseTypes)
export const oshaOutcomeEnum = pgEnum('osha_outcome', vocab.oshaOutcomes)
export const oshaSpecialCaseEnum = pgEnum('osha_special_case', vocab.oshaSpecialCases)
const id = () => uuid().primaryKey().defaultRandom()
const timestamptz = () => timestamp({ withTimezone: true })
@@ -91,6 +94,16 @@ export const sites = pgTable(
name: text().notNull(),
address: text(),
timezone: text().notNull().default('UTC'),
// OSHA establishment details (Form 300A and the ITA upload)
streetAddress: text(),
city: text(),
state: text(),
zip: text(),
naicsCode: text(),
industryDescription: text(),
ein: text(),
/** ITA establishment_type: 1 private, 2 state government, 3 local government. */
establishmentType: integer().notNull().default(1),
archivedAt: timestamptz(),
createdAt: createdAt(),
updatedAt: updatedAt(),
@@ -208,6 +221,42 @@ export const injuredPersons = pgTable(
daysRestricted: integer().notNull().default(0),
/** OSHA privacy case: name withheld from the 300 log. */
privacyCase: boolean().notNull().default(false),
// OSHA recordability (29 CFR 1904). Null answers mean "not assessed yet".
caseNumber: text(),
workRelated: boolean(),
newCase: boolean(),
died: boolean().notNull().default(false),
dateOfDeath: date({ mode: 'string' }),
daysAwayFromWork: boolean().notNull().default(false),
restrictedOrTransfer: boolean().notNull().default(false),
medicalTreatment: boolean().notNull().default(false),
lossOfConsciousness: boolean().notNull().default(false),
significantDiagnosis: boolean().notNull().default(false),
specialCase: oshaSpecialCaseEnum().notNull().default('none'),
caseType: oshaCaseTypeEnum().notNull().default('injury'),
/** Derived from the answers above with shared/osha.ts, stored for reporting. */
recordable: boolean().notNull().default(false),
outcome: oshaOutcomeEnum(),
// Severe injury reporting (1904.39)
inpatientHospitalization: boolean().notNull().default(false),
amputation: boolean().notNull().default(false),
lossOfEye: boolean().notNull().default(false),
reportedToOshaAt: timestamptz(),
// Form 301 details
employeeAddress: text(),
dateOfBirth: date({ mode: 'string' }),
dateOfHire: date({ mode: 'string' }),
sex: text(),
physicianName: text(),
treatmentFacility: text(),
treatedInEmergencyRoom: boolean().notNull().default(false),
timeStartedWork: text(),
activityBefore: text(),
whatHappened: text(),
objectSubstance: text(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
@@ -330,8 +379,30 @@ export const hoursWorked = pgTable(
/** First day of the month, e.g. `2026-10-01`. */
month: date({ mode: 'string' }).notNull(),
hours: numeric({ precision: 12, scale: 2 }).notNull(),
/** Average number of employees on the payroll that month (for the annual average). */
averageEmployees: integer(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(t) => [uniqueIndex('hours_worked_site_month_unique').on(t.siteId, t.month)],
)
/** Named filter sets for the Analytics and Incidents pages; private, or shared with the organization. */
export const savedViews = pgTable(
'saved_views',
{
id: id(),
organizationId: uuid()
.notNull()
.references(() => organizations.id),
userId: uuid()
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
page: text().notNull(),
name: text().notNull(),
filters: jsonb().$type<Record<string, string>>().notNull(),
shared: boolean().notNull().default(false),
createdAt: createdAt(),
},
(t) => [index('saved_views_organization_page_idx').on(t.organizationId, t.page)],
)
+144 -2
View File
@@ -2,7 +2,8 @@
import { hashPassword } from '../auth/password.ts'
import { loadConfig } from '../config.ts'
import { openDatabase } from './client.ts'
import { auditLog, areas, incidentCounters, incidents, organizations, sites, users } from './schema.ts'
import { assessRecordability } from '../../shared/osha.ts'
import { auditLog, areas, correctiveActions, hoursWorked, incidentCounters, incidents, injuredPersons, investigations, organizations, rootCauses, sites, users } from './schema.ts'
const DEMO_PASSWORD = 'safetylog-demo'
@@ -79,10 +80,151 @@ try {
createdAt: incident.createdAt,
})),
)
await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber: demo.length })
// A year of history so dashboards and the OSHA log have something to show
const lastNumber = await seedHistory(tx as unknown as typeof db, org.id, demo.length, areaIds, people.map((p) => p.id))
await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber })
})
console.log(`Seeded demo data. Sign in as admin@example.com, manager@example.com, supervisor@example.com or reporter@example.com with password "${DEMO_PASSWORD}".`)
}
} finally {
await close()
}
/** Deterministic pseudo-random numbers, so every seed produces the same demo. */
function random(seed: number) {
let state = seed
return () => {
state = (state * 1_664_525 + 1_013_904_223) % 2 ** 32
return state / 2 ** 32
}
}
async function seedHistory(
database: typeof db,
organizationId: string,
startNumber: number,
areaIds: Record<string, { siteId: string; areaId: string }>,
userIds: string[],
): Promise<number> {
const rand = random(42)
const pick = <T>(items: readonly T[]) => items[Math.floor(rand() * items.length)]
const day = 86_400_000
const now = Date.now()
const locations = Object.values(areaIds)
// Near misses and hazards outnumber injuries, as in a healthy reporting culture
const types = ['near_miss', 'near_miss', 'near_miss', 'hazard', 'hazard', 'hazard', 'injury', 'injury', 'property_damage', 'environmental', 'illness'] as const
// Busier around shift changes (06–08, 14–16) and mid-shift
const hours = [6, 7, 7, 9, 10, 11, 13, 14, 14, 15, 15, 16, 19, 22, 2]
const titles = {
near_miss: ['Forklift nearly struck pedestrian', 'Load slipped from pallet jack', 'Unsecured ladder almost fell', 'Dropped tool from mezzanine'],
hazard: ['Oil leak under press', 'Blocked emergency exit', 'Damaged guard on conveyor', 'Frayed extension cord', 'Missing machine guard'],
injury: ['Hand laceration from sheet metal', 'Back strain lifting boxes', 'Slip on wet floor', 'Pinched finger in die'],
illness: ['Dermatitis from cutting fluid'],
property_damage: ['Forklift hit racking', 'Damaged dock door'],
environmental: ['Hydraulic fluid spill', 'Coolant leak to drain'],
} as const
const categories = ['people', 'process', 'equipment', 'materials', 'environment', 'management'] as const
let number = startNumber
for (let i = 0; i < 150; i++) {
const type = pick(types)
const occurred = new Date(now - Math.floor(rand() * 360 + 3) * day)
occurred.setHours(pick(hours), Math.floor(rand() * 60), 0, 0)
const created = new Date(occurred.getTime() + (1 + rand() * 6) * 3_600_000)
const age = (now - occurred.getTime()) / day
const status = age > 60 ? (rand() < 0.85 ? 'closed' : 'actions') : age > 20 ? pick(['investigation', 'actions', 'verification', 'closed'] as const) : pick(['reported', 'triage', 'investigation'] as const)
const closedAt = status === 'closed' ? new Date(created.getTime() + (5 + rand() * 35) * day) : null
number++
const [incident] = await database
.insert(incidents)
.values({
organizationId,
number,
title: pick(titles[type]),
type,
severity: type === 'injury' ? pick(['medium', 'high', 'high'] as const) : pick(['low', 'medium', 'medium', 'high'] as const),
status,
occurredAt: occurred,
createdAt: created,
closedAt: closedAt && closedAt.getTime() < now ? closedAt : null,
...pick(locations),
reporterId: pick(userIds),
})
.returning()
const auditRows: (typeof auditLog.$inferInsert)[] = [
{ organizationId, actorId: incident.reporterId, entityType: 'incident', entityId: incident.id, action: 'create', changes: { number, title: incident.title }, createdAt: created },
]
if (status !== 'reported') {
// Most reports are triaged within a day; the team got quicker over the year
const delayHours = rand() < 0.55 + (1 - age / 365) * 0.35 ? 2 + rand() * 18 : 26 + rand() * 70
auditRows.push({ organizationId, actorId: pick(userIds), entityType: 'incident', entityId: incident.id, action: 'status', changes: { status: ['reported', 'triage'] }, createdAt: new Date(created.getTime() + delayHours * 3_600_000) })
}
await database.insert(auditLog).values(auditRows)
if (['investigation', 'actions', 'verification', 'closed'].includes(status) && type !== 'near_miss') {
const [investigation] = await database.insert(investigations).values({ incidentId: incident.id, method: 'five_whys', startedAt: created }).returning()
const causes = await database
.insert(rootCauses)
.values(Array.from({ length: 1 + Math.floor(rand() * 2) }, () => ({ investigationId: investigation.id, category: pick(categories), description: 'Identified during investigation' })))
.returning()
const due = new Date(created.getTime() + 21 * day)
const done = status === 'closed' || status === 'verification' || rand() < 0.4
const completed = new Date(created.getTime() + (8 + rand() * 30) * day)
await database.insert(correctiveActions).values({
incidentId: incident.id,
rootCauseId: causes[0].id,
title: 'Fix the cause and brief the team',
assigneeId: pick(userIds),
dueDate: due.toISOString().slice(0, 10),
status: done && completed.getTime() < now ? (status === 'closed' ? 'verified' : 'done') : 'open',
completedAt: done && completed.getTime() < now ? completed : null,
})
}
if ((type === 'injury' || type === 'illness') && rand() < 0.6) {
const answers = {
employmentType: 'employee' as const,
workRelated: true,
newCase: true,
died: false,
daysAwayFromWork: rand() < 0.3,
restrictedOrTransfer: rand() < 0.4,
medicalTreatment: rand() < 0.7,
lossOfConsciousness: false,
significantDiagnosis: false,
specialCase: 'none' as const,
}
const verdict = assessRecordability(answers)
await database.insert(injuredPersons).values({
incidentId: incident.id,
caseNumber: `INC-${String(number).padStart(4, '0')}`,
name: pick(['Alex Moreno', 'Jamie Patel', 'Chris Nguyen', 'Taylor Brooks', 'Morgan Diaz']),
jobTitle: pick(['Press operator', 'Material handler', 'Maintenance technician', 'Assembler']),
natureOfInjury: incident.title,
caseType: type === 'illness' ? 'skin_disorder' : 'injury',
...answers,
daysAway: answers.daysAwayFromWork ? 1 + Math.floor(rand() * 10) : 0,
daysRestricted: answers.restrictedOrTransfer ? 2 + Math.floor(rand() * 14) : 0,
recordable: verdict.status === 'recordable',
outcome: verdict.outcome,
})
}
}
// Monthly hours worked and headcount for every site, for the last 13 months
const siteIds = [...new Set(locations.map((l) => l.siteId))]
const today = new Date()
for (const siteId of siteIds) {
for (let m = 12; m >= 0; m--) {
const month = new Date(today.getFullYear(), today.getMonth() - m, 1)
const employees = 70 + Math.floor(rand() * 30)
await database.insert(hoursWorked).values({
siteId,
month: `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, '0')}-01`,
hours: String(employees * 168),
averageEmployees: employees,
})
}
}
return number
}