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,200 @@
|
||||
import { and, asc, eq, gte, isNull, lt } from 'drizzle-orm'
|
||||
import type { Establishment, OshaCase, OshaLogRow, OshaYear } from '../../shared/api-types.ts'
|
||||
import { oshaCaseTypes } from '../../shared/enums.ts'
|
||||
import { incidentReference } from '../../shared/format.ts'
|
||||
import { capDays, incidenceRate, severeInjuryReport, summaryChecks, type SummaryTotals } from '../../shared/osha.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { areas, hoursWorked, incidents, injuredPersons, organizations, sites } from '../db/schema.ts'
|
||||
import { iso } from './dates.ts'
|
||||
import { fail } from './http.ts'
|
||||
|
||||
type Person = typeof injuredPersons.$inferSelect
|
||||
type IncidentInfo = { id: string; number: number; title: string; occurredAt: Date; createdAt: Date }
|
||||
|
||||
/** Jan 1 to Jan 1 in the server's time zone (set TZ to the plant's zone). */
|
||||
export function yearRange(year: number): [Date, Date] {
|
||||
return [new Date(year, 0, 1), new Date(year + 1, 0, 1)]
|
||||
}
|
||||
|
||||
/** Fields the ITA case upload and Form 301 require that are still empty. */
|
||||
export function missingFields(person: Person): string[] {
|
||||
if (!person.recordable) return []
|
||||
const required: [keyof Person, string][] = [
|
||||
['jobTitle', 'job title'],
|
||||
['dateOfBirth', 'date of birth'],
|
||||
['dateOfHire', 'date hired'],
|
||||
['activityBefore', 'what they were doing'],
|
||||
['whatHappened', 'what happened'],
|
||||
['natureOfInjury', 'the injury or illness'],
|
||||
['objectSubstance', 'object or substance'],
|
||||
]
|
||||
const missing = required.filter(([key]) => !person[key]).map(([, label]) => label)
|
||||
if (person.died && !person.dateOfDeath) missing.push('date of death')
|
||||
return missing
|
||||
}
|
||||
|
||||
export function toOshaCase(person: Person, incident: IncidentInfo): OshaCase {
|
||||
const severe = severeInjuryReport(person)
|
||||
return {
|
||||
id: person.id,
|
||||
incident: { id: incident.id, number: incident.number, reference: incidentReference(incident.number), title: incident.title, occurredAt: iso(incident.occurredAt) },
|
||||
caseNumber: person.caseNumber ?? incidentReference(incident.number),
|
||||
name: person.name,
|
||||
jobTitle: person.jobTitle,
|
||||
employmentType: person.employmentType,
|
||||
bodyPart: person.bodyPart,
|
||||
natureOfInjury: person.natureOfInjury,
|
||||
caseType: person.caseType,
|
||||
workRelated: person.workRelated,
|
||||
newCase: person.newCase,
|
||||
died: person.died,
|
||||
dateOfDeath: person.dateOfDeath,
|
||||
daysAwayFromWork: person.daysAwayFromWork,
|
||||
daysAway: person.daysAway,
|
||||
restrictedOrTransfer: person.restrictedOrTransfer,
|
||||
daysRestricted: person.daysRestricted,
|
||||
medicalTreatment: person.medicalTreatment,
|
||||
lossOfConsciousness: person.lossOfConsciousness,
|
||||
significantDiagnosis: person.significantDiagnosis,
|
||||
specialCase: person.specialCase,
|
||||
privacyCase: person.privacyCase,
|
||||
recordable: person.recordable,
|
||||
outcome: person.outcome,
|
||||
inpatientHospitalization: person.inpatientHospitalization,
|
||||
amputation: person.amputation,
|
||||
lossOfEye: person.lossOfEye,
|
||||
reportedToOshaAt: iso(person.reportedToOshaAt),
|
||||
// The clock starts when the employer learns of it; the report date is the best record of that.
|
||||
severeReport: severe && { ...severe, dueBy: new Date(incident.createdAt.getTime() + severe.withinHours * 3_600_000).toISOString() },
|
||||
employeeAddress: person.employeeAddress,
|
||||
dateOfBirth: person.dateOfBirth,
|
||||
dateOfHire: person.dateOfHire,
|
||||
sex: person.sex === 'M' || person.sex === 'F' ? person.sex : null,
|
||||
physicianName: person.physicianName,
|
||||
treatmentFacility: person.treatmentFacility,
|
||||
treatedInEmergencyRoom: person.treatedInEmergencyRoom,
|
||||
timeStartedWork: person.timeStartedWork,
|
||||
activityBefore: person.activityBefore,
|
||||
whatHappened: person.whatHappened,
|
||||
objectSubstance: person.objectSubstance,
|
||||
missingFields: missingFields(person),
|
||||
updatedAt: iso(person.updatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
/** Form 300 column F: injury, body part, and what caused it. */
|
||||
export function describeCase(person: Pick<Person, 'natureOfInjury' | 'bodyPart' | 'objectSubstance'>): string {
|
||||
const injury = [person.natureOfInjury, person.bodyPart && `(${person.bodyPart})`].filter(Boolean).join(' ')
|
||||
return [injury, person.objectSubstance].filter(Boolean).join('; ')
|
||||
}
|
||||
|
||||
export const establishmentOf = (site: typeof sites.$inferSelect): Establishment => ({
|
||||
streetAddress: site.streetAddress,
|
||||
city: site.city,
|
||||
state: site.state,
|
||||
zip: site.zip,
|
||||
naicsCode: site.naicsCode,
|
||||
industryDescription: site.industryDescription,
|
||||
ein: site.ein,
|
||||
establishmentType: site.establishmentType,
|
||||
})
|
||||
|
||||
export function missingEstablishmentFields(site: typeof sites.$inferSelect): string[] {
|
||||
const required: [keyof typeof site, string][] = [
|
||||
['streetAddress', 'street address'],
|
||||
['city', 'city'],
|
||||
['state', 'state'],
|
||||
['zip', 'ZIP code'],
|
||||
['naicsCode', 'NAICS code'],
|
||||
['ein', 'EIN'],
|
||||
]
|
||||
return required.filter(([key]) => !site[key]).map(([, label]) => label)
|
||||
}
|
||||
|
||||
/** Everything for one establishment's year: the 300 Log, 300A totals, hours, rates and ITA checks. */
|
||||
export async function buildOshaYear(db: Db, organizationId: string, siteId: string, year: number): Promise<OshaYear> {
|
||||
const [site] = await db.select().from(sites).where(and(eq(sites.id, siteId), eq(sites.organizationId, organizationId)))
|
||||
if (!site) throw fail(404, 'Site not found')
|
||||
const [organization] = await db.select({ name: organizations.name }).from(organizations).where(eq(organizations.id, organizationId))
|
||||
const [from, to] = yearRange(year)
|
||||
|
||||
const caseRows = await db
|
||||
.select({ person: injuredPersons, incident: incidents, areaName: areas.name })
|
||||
.from(injuredPersons)
|
||||
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
|
||||
.leftJoin(areas, eq(areas.id, incidents.areaId))
|
||||
.where(and(eq(incidents.siteId, siteId), eq(injuredPersons.recordable, true), gte(incidents.occurredAt, from), lt(incidents.occurredAt, to)))
|
||||
.orderBy(asc(incidents.occurredAt), asc(injuredPersons.caseNumber))
|
||||
|
||||
const rows: OshaLogRow[] = caseRows.map(({ person, incident, areaName }) => {
|
||||
const days = capDays(person.daysAwayFromWork ? person.daysAway : 0, person.restrictedOrTransfer ? person.daysRestricted : 0)
|
||||
return {
|
||||
caseId: person.id,
|
||||
caseNumber: person.caseNumber ?? incidentReference(incident.number),
|
||||
incidentNumber: incident.number,
|
||||
employeeName: person.privacyCase ? 'Privacy case' : person.name,
|
||||
jobTitle: person.jobTitle ?? '',
|
||||
dateOfInjury: iso(incident.occurredAt),
|
||||
location: [areaName, incident.locationDetail].filter(Boolean).join(', ') || site.name,
|
||||
description: describeCase(person),
|
||||
outcome: person.outcome ?? 'other',
|
||||
daysAway: days.daysAway,
|
||||
daysRestricted: days.daysRestricted,
|
||||
caseType: person.caseType,
|
||||
missingFields: missingFields(person),
|
||||
}
|
||||
})
|
||||
|
||||
const totals: SummaryTotals = {
|
||||
deaths: rows.filter((r) => r.outcome === 'death').length,
|
||||
daysAwayCases: rows.filter((r) => r.outcome === 'days_away').length,
|
||||
restrictedCases: rows.filter((r) => r.outcome === 'restricted').length,
|
||||
otherCases: rows.filter((r) => r.outcome === 'other').length,
|
||||
daysAway: rows.reduce((sum, r) => sum + r.daysAway, 0),
|
||||
daysRestricted: rows.reduce((sum, r) => sum + r.daysRestricted, 0),
|
||||
byType: Object.fromEntries(oshaCaseTypes.map((type) => [type, rows.filter((r) => r.caseType === type).length])) as SummaryTotals['byType'],
|
||||
}
|
||||
|
||||
const monthRows = await db
|
||||
.select()
|
||||
.from(hoursWorked)
|
||||
.where(and(eq(hoursWorked.siteId, siteId), gte(hoursWorked.month, `${year}-01-01`), lt(hoursWorked.month, `${year + 1}-01-01`)))
|
||||
const months = Array.from({ length: 12 }, (_, i) => {
|
||||
const row = monthRows.find((m) => Number(m.month.slice(5, 7)) === i + 1)
|
||||
return { month: i + 1, hours: row ? Number(row.hours) : null, averageEmployees: row?.averageEmployees ?? null }
|
||||
})
|
||||
const totalHoursWorked = Math.round(months.reduce((sum, m) => sum + (m.hours ?? 0), 0))
|
||||
const headcounts = months.map((m) => m.averageEmployees).filter((n): n is number => n !== null)
|
||||
// 1904.32 instructions: total of employees each pay period ÷ number of pay periods; here, months.
|
||||
const annualAverageEmployees = headcounts.length ? Math.round(headcounts.reduce((a, b) => a + b, 0) / headcounts.length) : 0
|
||||
const maxEmployees = headcounts.length ? Math.max(...headcounts) : 0
|
||||
const recordable = rows.length
|
||||
|
||||
return {
|
||||
site: { id: site.id, name: site.name, ...establishmentOf(site) },
|
||||
companyName: organization.name,
|
||||
year,
|
||||
rows,
|
||||
totals,
|
||||
months,
|
||||
totalHoursWorked,
|
||||
annualAverageEmployees,
|
||||
maxEmployees,
|
||||
rates: {
|
||||
trir: incidenceRate(recordable, totalHoursWorked),
|
||||
dart: incidenceRate(totals.daysAwayCases + totals.restrictedCases, totalHoursWorked),
|
||||
ltir: incidenceRate(totals.daysAwayCases, totalHoursWorked),
|
||||
},
|
||||
checks: summaryChecks(totals, annualAverageEmployees, totalHoursWorked),
|
||||
missingEstablishmentFields: missingEstablishmentFields(site),
|
||||
}
|
||||
}
|
||||
|
||||
/** Active sites for an organization (establishments that may need a log). */
|
||||
export function activeSites(db: Db, organizationId: string) {
|
||||
return db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.organizationId, organizationId), isNull(sites.archivedAt)))
|
||||
.orderBy(asc(sites.name))
|
||||
}
|
||||
Reference in new issue
Block a user