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
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user