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,252 @@
|
||||
import { and, eq, gte, inArray, isNotNull, lt, min } from 'drizzle-orm'
|
||||
import type { AnalyticsStats, Kpi, TypeGroup } from '../../shared/api-types.ts'
|
||||
import { hasRole, type IncidentType } from '../../shared/enums.ts'
|
||||
import { incidenceRate } from '../../shared/osha.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { areas, auditLog, correctiveActions, hoursWorked, incidents, injuredPersons, investigations, rootCauses, sites } from '../db/schema.ts'
|
||||
import { addDays, localDate } from './dates.ts'
|
||||
import { visibleTo } from './incidents.ts'
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
|
||||
export const typeGroup: Record<IncidentType, TypeGroup> = {
|
||||
near_miss: 'hazards',
|
||||
hazard: 'hazards',
|
||||
injury: 'injuries',
|
||||
illness: 'injuries',
|
||||
property_damage: 'damage',
|
||||
environmental: 'damage',
|
||||
}
|
||||
|
||||
export interface AnalyticsFilters {
|
||||
/** Inclusive start date, `YYYY-MM-DD` (server time zone). */
|
||||
from: string
|
||||
/** Inclusive end date. */
|
||||
to: string
|
||||
siteId?: string
|
||||
type?: IncidentType
|
||||
}
|
||||
|
||||
const startOf = (isoDate: string) => new Date(`${isoDate}T00:00:00`)
|
||||
const monthKey = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||
const ratio = (part: number, whole: number) => (whole > 0 ? part / whole : null)
|
||||
const round1 = (value: number | null) => (value === null ? null : Math.round(value * 10) / 10)
|
||||
|
||||
/** Monday of the week containing `date`, local time. */
|
||||
function weekStart(date: Date): Date {
|
||||
const start = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
|
||||
return start
|
||||
}
|
||||
|
||||
/** Buckets covering [from, to]: weeks for ranges up to ~3 months, otherwise months. */
|
||||
export function buckets(from: string, to: string): { bucket: 'week' | 'month'; starts: Date[] } {
|
||||
const start = startOf(from)
|
||||
const end = startOf(to)
|
||||
const days = (end.getTime() - start.getTime()) / DAY_MS + 1
|
||||
const starts: Date[] = []
|
||||
if (days <= 92) {
|
||||
for (let d = weekStart(start); d <= end; d = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 7)) starts.push(d)
|
||||
return { bucket: 'week', starts }
|
||||
}
|
||||
for (let d = new Date(start.getFullYear(), start.getMonth(), 1); d <= end; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) starts.push(d)
|
||||
return { bucket: 'month', starts }
|
||||
}
|
||||
|
||||
function bucketIndex(starts: Date[], date: Date): number {
|
||||
for (let i = starts.length - 1; i >= 0; i--) if (date >= starts[i]) return i
|
||||
return -1
|
||||
}
|
||||
|
||||
export async function computeAnalytics(db: Db, user: SessionUser, filters: AnalyticsFilters): Promise<AnalyticsStats> {
|
||||
const from = startOf(filters.from)
|
||||
const to = startOf(addDays(filters.to, 1)) // exclusive
|
||||
const length = to.getTime() - from.getTime()
|
||||
const previousFrom = new Date(from.getTime() - length)
|
||||
const visible = visibleTo(db, user)
|
||||
const scope = and(visible, filters.siteId ? eq(incidents.siteId, filters.siteId) : undefined, filters.type ? eq(incidents.type, filters.type) : undefined)
|
||||
const occurredIn = (start: Date, end: Date) => and(scope, gte(incidents.occurredAt, start), lt(incidents.occurredAt, end))
|
||||
|
||||
const columns = { id: incidents.id, type: incidents.type, occurredAt: incidents.occurredAt, createdAt: incidents.createdAt, siteId: incidents.siteId, areaId: incidents.areaId }
|
||||
const current = await db.select(columns).from(incidents).where(occurredIn(from, to))
|
||||
const previous = await db.select(columns).from(incidents).where(occurredIn(previousFrom, from))
|
||||
|
||||
const countGroups = (rows: typeof current) => {
|
||||
const groups = { hazards: 0, injuries: 0, damage: 0 }
|
||||
for (const row of rows) groups[typeGroup[row.type]]++
|
||||
return groups
|
||||
}
|
||||
const now = countGroups(current)
|
||||
const before = countGroups(previous)
|
||||
|
||||
// Responsiveness: time from report to the first status change
|
||||
async function triagedShare(rows: typeof current): Promise<number | null> {
|
||||
if (rows.length === 0) return null
|
||||
const firstMoves = await db
|
||||
.select({ id: auditLog.entityId, at: min(auditLog.createdAt) })
|
||||
.from(auditLog)
|
||||
.where(and(eq(auditLog.entityType, 'incident'), eq(auditLog.action, 'status'), inArray(auditLog.entityId, rows.map((r) => r.id))))
|
||||
.groupBy(auditLog.entityId)
|
||||
const moved = new Map(firstMoves.map((m) => [m.id, m.at]))
|
||||
const quick = rows.filter((r) => {
|
||||
const at = moved.get(r.id)
|
||||
return at && at.getTime() - r.createdAt.getTime() <= DAY_MS
|
||||
})
|
||||
return quick.length / rows.length
|
||||
}
|
||||
|
||||
async function averageDaysToClose(start: Date, end: Date) {
|
||||
const closed = await db
|
||||
.select({ createdAt: incidents.createdAt, closedAt: incidents.closedAt })
|
||||
.from(incidents)
|
||||
.where(and(scope, isNotNull(incidents.closedAt), gte(incidents.closedAt, start), lt(incidents.closedAt, end)))
|
||||
if (closed.length === 0) return null
|
||||
return round1(closed.reduce((sum, r) => sum + (r.closedAt!.getTime() - r.createdAt.getTime()), 0) / closed.length / DAY_MS)
|
||||
}
|
||||
|
||||
async function completedActions(start: Date, end: Date) {
|
||||
return db
|
||||
.select({ completedAt: correctiveActions.completedAt, dueDate: correctiveActions.dueDate })
|
||||
.from(correctiveActions)
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.where(and(scope, isNotNull(correctiveActions.completedAt), isNotNull(correctiveActions.dueDate), gte(correctiveActions.completedAt, start), lt(correctiveActions.completedAt, end)))
|
||||
}
|
||||
const onTime = (row: { completedAt: Date | null; dueDate: string | null }) => localDate(row.completedAt!) <= row.dueDate!
|
||||
const actionsNow = await completedActions(from, to)
|
||||
const actionsBefore = await completedActions(previousFrom, from)
|
||||
|
||||
const today = localDate()
|
||||
const overdueRows = await db
|
||||
.select({ id: correctiveActions.id })
|
||||
.from(correctiveActions)
|
||||
.innerJoin(incidents, eq(incidents.id, correctiveActions.incidentId))
|
||||
.where(and(scope, inArray(correctiveActions.status, ['open', 'in_progress']), lt(correctiveActions.dueDate, today)))
|
||||
|
||||
// Lagging: OSHA-recordable cases. Counts only, so supervisors may see them.
|
||||
const canSeeRates = hasRole(user.role, 'supervisor')
|
||||
async function recordableRows(start: Date, end: Date) {
|
||||
return db
|
||||
.select({ occurredAt: incidents.occurredAt, outcome: injuredPersons.outcome })
|
||||
.from(injuredPersons)
|
||||
.innerJoin(incidents, eq(incidents.id, injuredPersons.incidentId))
|
||||
.where(and(eq(incidents.organizationId, user.organizationId), filters.siteId ? eq(incidents.siteId, filters.siteId) : undefined, eq(injuredPersons.recordable, true), gte(incidents.occurredAt, start), lt(incidents.occurredAt, end)))
|
||||
}
|
||||
|
||||
const kpi = (value: number | null, previousValue: number | null): Kpi => ({ value, previous: previousValue })
|
||||
const ratioOf = (groups: typeof now) => (groups.injuries ? Math.round(((groups.hazards / groups.injuries) * 10)) / 10 : null)
|
||||
|
||||
// Trend
|
||||
const { bucket, starts } = buckets(filters.from, filters.to)
|
||||
const trend = starts.map((start) => ({ start: localDate(start), hazards: 0, injuries: 0, damage: 0 }))
|
||||
for (const row of current) {
|
||||
const i = bucketIndex(starts, row.occurredAt)
|
||||
if (i >= 0) trend[i][typeGroup[row.type]]++
|
||||
}
|
||||
|
||||
// Heatmap: weekday × hour of occurrence
|
||||
const heatmap = Array.from({ length: 7 }, () => Array<number>(24).fill(0))
|
||||
for (const row of current) heatmap[(row.occurredAt.getDay() + 6) % 7][row.occurredAt.getHours()]++
|
||||
|
||||
// Location: sites, or areas within the chosen site
|
||||
let byLocation: AnalyticsStats['byLocation']
|
||||
if (filters.siteId) {
|
||||
const areaRows = await db.select({ id: areas.id, name: areas.name }).from(areas).where(eq(areas.siteId, filters.siteId))
|
||||
const names = new Map(areaRows.map((a) => [a.id, a.name]))
|
||||
const counts = new Map<string | null, number>()
|
||||
for (const row of current) counts.set(row.areaId, (counts.get(row.areaId) ?? 0) + 1)
|
||||
byLocation = [...counts].map(([id, count]) => ({ id, name: id ? (names.get(id) ?? 'Unknown area') : 'Area not specified', count }))
|
||||
} else {
|
||||
const siteRows = await db.select({ id: sites.id, name: sites.name }).from(sites).where(eq(sites.organizationId, user.organizationId))
|
||||
const names = new Map(siteRows.map((s) => [s.id, s.name]))
|
||||
const counts = new Map<string, number>()
|
||||
for (const row of current) counts.set(row.siteId, (counts.get(row.siteId) ?? 0) + 1)
|
||||
byLocation = [...counts].map(([id, count]) => ({ id, name: names.get(id) ?? 'Unknown site', count }))
|
||||
}
|
||||
byLocation.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
|
||||
|
||||
const causeRows = current.length
|
||||
? await db
|
||||
.select({ category: rootCauses.category })
|
||||
.from(rootCauses)
|
||||
.innerJoin(investigations, eq(investigations.id, rootCauses.investigationId))
|
||||
.where(inArray(investigations.incidentId, current.map((r) => r.id)))
|
||||
: []
|
||||
const causeCounts = new Map<string, number>()
|
||||
for (const row of causeRows) causeCounts.set(row.category, (causeCounts.get(row.category) ?? 0) + 1)
|
||||
const byRootCause = [...causeCounts].map(([category, count]) => ({ category: category as AnalyticsStats['byRootCause'][number]['category'], count })).sort((a, b) => b.count - a.count)
|
||||
|
||||
// Actions completed per month, on time vs late
|
||||
const actionsByMonth: AnalyticsStats['actionsByMonth'] = []
|
||||
for (let d = new Date(from.getFullYear(), from.getMonth(), 1); d < to; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) actionsByMonth.push({ month: monthKey(d), onTime: 0, late: 0 })
|
||||
for (const row of actionsNow) {
|
||||
const entry = actionsByMonth.find((m) => m.month === monthKey(row.completedAt!))
|
||||
if (entry) entry[onTime(row) ? 'onTime' : 'late']++
|
||||
}
|
||||
|
||||
// Rolling 12-month OSHA rates at each of the last 12 month ends
|
||||
let rates: AnalyticsStats['rates'] = null
|
||||
let yearly: AnalyticsStats['yearly'] = null
|
||||
let recordables: Kpi | null = null
|
||||
if (canSeeRates) {
|
||||
recordables = kpi((await recordableRows(from, to)).length, (await recordableRows(previousFrom, from)).length)
|
||||
const endMonth = new Date(startOf(filters.to).getFullYear(), startOf(filters.to).getMonth(), 1)
|
||||
const windowStart = new Date(endMonth.getFullYear() - 5, 0, 1)
|
||||
const cases = await recordableRows(windowStart, new Date(endMonth.getFullYear(), endMonth.getMonth() + 1, 1))
|
||||
const hours = await db
|
||||
.select({ month: hoursWorked.month, hours: hoursWorked.hours })
|
||||
.from(hoursWorked)
|
||||
.innerJoin(sites, eq(sites.id, hoursWorked.siteId))
|
||||
.where(and(eq(sites.organizationId, user.organizationId), filters.siteId ? eq(hoursWorked.siteId, filters.siteId) : undefined, gte(hoursWorked.month, localDate(windowStart))))
|
||||
const hoursByMonth = new Map<string, number>()
|
||||
for (const h of hours) hoursByMonth.set(h.month.slice(0, 7), (hoursByMonth.get(h.month.slice(0, 7)) ?? 0) + Number(h.hours))
|
||||
const casesIn = (start: Date, end: Date) => cases.filter((c) => c.occurredAt >= start && c.occurredAt < end)
|
||||
const hoursIn = (start: Date, end: Date) => {
|
||||
let total = 0
|
||||
for (let d = new Date(start); d < end; d = new Date(d.getFullYear(), d.getMonth() + 1, 1)) total += hoursByMonth.get(monthKey(d)) ?? 0
|
||||
return total
|
||||
}
|
||||
const isDart = (c: { outcome: string | null }) => c.outcome === 'days_away' || c.outcome === 'restricted'
|
||||
|
||||
rates = Array.from({ length: 12 }, (_, i) => {
|
||||
const monthStart = new Date(endMonth.getFullYear(), endMonth.getMonth() - 11 + i, 1)
|
||||
const windowEnd = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 1)
|
||||
const windowBegin = new Date(windowEnd.getFullYear() - 1, windowEnd.getMonth(), 1)
|
||||
const inWindow = casesIn(windowBegin, windowEnd)
|
||||
const h = hoursIn(windowBegin, windowEnd)
|
||||
return { month: monthKey(monthStart), trir: incidenceRate(inWindow.length, h), dart: incidenceRate(inWindow.filter(isDart).length, h) }
|
||||
})
|
||||
yearly = Array.from({ length: 5 }, (_, i) => {
|
||||
const year = endMonth.getFullYear() - 4 + i
|
||||
const inYear = casesIn(new Date(year, 0, 1), new Date(year + 1, 0, 1))
|
||||
const h = Math.round(hoursIn(new Date(year, 0, 1), new Date(year + 1, 0, 1)))
|
||||
const dartCases = inYear.filter(isDart).length
|
||||
return { year, recordables: inYear.length, dartCases, hours: h, trir: incidenceRate(inYear.length, h), dart: incidenceRate(dartCases, h) }
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
from: filters.from,
|
||||
to: filters.to,
|
||||
previousFrom: localDate(previousFrom),
|
||||
bucket,
|
||||
kpis: {
|
||||
reports: kpi(current.length, previous.length),
|
||||
leadingReports: kpi(now.hazards, before.hazards),
|
||||
injuries: kpi(now.injuries, before.injuries),
|
||||
nearMissRatio: kpi(ratioOf(now), ratioOf(before)),
|
||||
triagedWithin24h: kpi(await triagedShare(current), await triagedShare(previous)),
|
||||
actionsOnTime: kpi(ratio(actionsNow.filter(onTime).length, actionsNow.length), ratio(actionsBefore.filter(onTime).length, actionsBefore.length)),
|
||||
averageDaysToClose: kpi(await averageDaysToClose(from, to), await averageDaysToClose(previousFrom, from)),
|
||||
recordables,
|
||||
overdueActions: overdueRows.length,
|
||||
},
|
||||
trend,
|
||||
byLocation,
|
||||
byRootCause,
|
||||
heatmap,
|
||||
actionsByMonth,
|
||||
rates,
|
||||
yearly,
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import { toCsv } from './csv.ts'
|
||||
|
||||
describe('toCsv', () => {
|
||||
it('quotes commas, quotes and newlines', () => {
|
||||
expect(toCsv(['a', 'b'], [['x,y', 'say "hi"\nok']])).toBe('a,b\r\n"x,y","say ""hi""\nok"\r\n')
|
||||
expect(toCsv(['a', 'b'], [['x,y', 'say "hi"\nok']])).toBe('\uFEFFa,b\r\n"x,y","say ""hi""\nok"\r\n')
|
||||
})
|
||||
|
||||
it('neutralises formula prefixes and blanks nulls', () => {
|
||||
expect(toCsv(['a'], [['=1+1'], ['-2'], [null], [3]])).toBe("a\r\n'=1+1\r\n'-2\r\n\r\n3\r\n")
|
||||
expect(toCsv(['a'], [['=1+1'], ['-2'], [null], [3]])).toBe("\uFEFFa\r\n'=1+1\r\n'-2\r\n\r\n3\r\n")
|
||||
})
|
||||
})
|
||||
+5
-4
@@ -1,14 +1,15 @@
|
||||
type Cell = string | number | null | undefined
|
||||
|
||||
/**
|
||||
* RFC 4180 CSV with a BOM so Excel opens UTF-8 correctly. Cells that a spreadsheet would treat as a
|
||||
* formula are prefixed with an apostrophe (CSV injection).
|
||||
* RFC 4180 CSV. By default starts with a BOM so Excel opens UTF-8 correctly (`bom: false` for machine
|
||||
* uploads such as OSHA's ITA). Cells a spreadsheet would treat as a formula are prefixed with an
|
||||
* apostrophe (CSV injection).
|
||||
*/
|
||||
export function toCsv(header: string[], rows: Cell[][]): string {
|
||||
export function toCsv(header: string[], rows: Cell[][], { bom = true }: { bom?: boolean } = {}): string {
|
||||
const cell = (value: Cell) => {
|
||||
let text = value === null || value === undefined ? '' : String(value)
|
||||
if (/^[=+\-@\t\r]/.test(text)) text = `'${text}`
|
||||
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text
|
||||
}
|
||||
return '' + [header, ...rows].map((row) => row.map(cell).join(',')).join('\r\n') + '\r\n'
|
||||
return (bom ? '\uFEFF' : '') + [header, ...rows].map((row) => row.map(cell).join(',')).join('\r\n') + '\r\n'
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, eq, exists, or, type SQL } from 'drizzle-orm'
|
||||
import { and, asc, count, eq, exists, or, type SQL } from 'drizzle-orm'
|
||||
import { alias } from 'drizzle-orm/pg-core'
|
||||
import type { Attachment, CorrectiveAction, Incident, IncidentDetail, Investigation, RootCause } from '../../shared/api-types.ts'
|
||||
import { hasRole } from '../../shared/enums.ts'
|
||||
@@ -6,7 +6,7 @@ import { incidentReference } from '../../shared/format.ts'
|
||||
import { availableTransitions } from '../../shared/workflow.ts'
|
||||
import type { SessionUser } from '../auth/sessions.ts'
|
||||
import type { Db } from '../db/client.ts'
|
||||
import { areas, attachments, correctiveActions, incidents, investigations, kiosks, rootCauses, sites, users } from '../db/schema.ts'
|
||||
import { areas, attachments, correctiveActions, incidents, injuredPersons, investigations, kiosks, rootCauses, sites, users } from '../db/schema.ts'
|
||||
import { audit } from './audit.ts'
|
||||
import { iso, localDate } from './dates.ts'
|
||||
import { fail } from './http.ts'
|
||||
@@ -159,6 +159,7 @@ export async function loadDetail(db: Db, user: SessionUser, number: number): Pro
|
||||
: null
|
||||
const actions = actionRows.map((action) => toAction(action))
|
||||
const manage = hasRole(user.role, 'supervisor')
|
||||
const [{ injured }] = await db.select({ injured: count() }).from(injuredPersons).where(eq(injuredPersons.incidentId, incidentId))
|
||||
|
||||
return {
|
||||
...toIncident(row),
|
||||
@@ -171,7 +172,8 @@ export async function loadDetail(db: Db, user: SessionUser, number: number): Pro
|
||||
rootCauseCount: causes.length,
|
||||
actionStatuses: actions.map((action) => action.status),
|
||||
}),
|
||||
can: { edit: manage, manageInvestigation: manage, manageActions: manage },
|
||||
injuredCount: injured,
|
||||
can: { edit: manage, manageInvestigation: manage, manageActions: manage, manageOsha: hasRole(user.role, 'safety_manager') },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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