465 lines
14 KiB
TypeScript

import {
DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD,
DIRECTOR_DASHBOARD_FRAME_PREVIEW_LIMIT,
DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD,
DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH,
} from '@/shared/constants/directorDashboard';
import {
countStaffAttendanceStatus,
staffAttendanceRate,
} from '@/business/staff-attendance/selectors';
import type { FrameEntryViewModel } from '@/business/frame/types';
import type { StaffAttendanceRecordViewModel } from '@/business/staff-attendance/types';
import type {
PolicyAcknowledgmentReportDto,
PolicyAcknowledgmentReportSummaryDto,
} from '@/shared/types/policyDocuments';
import type {
DirectorAcknowledgmentDocumentRow,
DirectorFramePreview,
DirectorOverviewCard,
DirectorQuizResultDetail,
DirectorQuizResultRow,
DirectorRiskArea,
} from '@/business/director-dashboard/types';
import type {
SafetyQuizCompletionSummary,
SafetyQuizComplianceRow,
} from '@/business/safety-quiz/types';
import type {
PersonalityCompletionDto,
PersonalityQuizResultDto,
} from '@/shared/types/personality';
import type { ZoneCheckinCompletionDto } from '@/shared/types/zoneCheckins';
export function calculateQuizCompletionRate(
quizSummary: SafetyQuizCompletionSummary,
): number {
return quizSummary.completionRate;
}
export function buildDirectorOverviewCards(
attendanceRecords: readonly StaffAttendanceRecordViewModel[],
quizSummary: SafetyQuizCompletionSummary,
frameEntries: readonly FrameEntryViewModel[],
acknowledgmentSummary?: PolicyAcknowledgmentReportSummaryDto | null,
): readonly DirectorOverviewCard[] {
const attendanceRate = staffAttendanceRate(attendanceRecords);
const quizCompletionRate = calculateQuizCompletionRate(quizSummary);
const acknowledgmentRate = acknowledgmentSummary?.completionRate ?? 0;
return [
{
label: 'Staff Attendance',
value: `${attendanceRate}%`,
change: `${attendanceRecords.length} records`,
trend: 'up',
iconId: 'clock',
tone: 'orange',
module: 'attendance',
},
{
label: 'Behavior Management Quiz Completion',
value: `${quizSummary.completedCount}/${quizSummary.totalStaff}`,
change: `${quizCompletionRate}%`,
trend: quizCompletionRate > DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD ? 'up' : 'down',
iconId: 'shield',
tone: 'blue',
module: 'qbs',
action: 'openQuizResults',
},
{
label: 'F.R.A.M.E. Entries',
value: frameEntries.length.toString(),
change: 'Total entries',
trend: 'up',
iconId: 'eye',
tone: 'amber',
module: 'frame',
},
{
label: 'Staff Members',
value: quizSummary.totalStaff.toString(),
change: 'Active',
trend: 'up',
iconId: 'users',
tone: 'purple',
module: 'user-admin',
},
{
label: 'Acknowledgments',
value: `${acknowledgmentRate}%`,
change: `${acknowledgmentSummary?.missingCount ?? 0} missing`,
trend: acknowledgmentRate > DIRECTOR_DASHBOARD_COMPLETION_TREND_THRESHOLD ? 'up' : 'down',
iconId: 'clipboard',
tone: 'emerald',
module: 'director',
action: 'openAcknowledgments',
},
];
}
export function buildDirectorRiskAreas(
attendanceRecords: readonly StaffAttendanceRecordViewModel[],
quizSummary: SafetyQuizCompletionSummary,
emotionalIntelligenceCompletion?: PersonalityCompletionDto | null,
zoneCheckinCompletion?: ZoneCheckinCompletionDto | null,
acknowledgmentDocuments: readonly DirectorAcknowledgmentDocumentRow[] = [],
): readonly DirectorRiskArea[] {
const incompleteStaffCount = quizSummary.pendingCount;
const absenceCount = countStaffAttendanceStatus(attendanceRecords, 'absent');
const lateCount = countStaffAttendanceStatus(attendanceRecords, 'late');
const staffAttendanceExceptionCount = absenceCount + lateCount;
const selfAssessmentPendingCount = emotionalIntelligenceCompletion?.summary
? emotionalIntelligenceCompletion.summary.totalStaff
- emotionalIntelligenceCompletion.summary.selfAssessmentCompletedCount
: 0;
const personalityPendingCount = emotionalIntelligenceCompletion?.summary
? emotionalIntelligenceCompletion.summary.totalStaff
- emotionalIntelligenceCompletion.summary.personalityCompletedCount
: 0;
const zonePendingCount = zoneCheckinCompletion?.summary.pendingCount ?? 0;
const nonGreenZoneNote = getNonGreenZoneNote(zoneCheckinCompletion);
const risks: DirectorRiskArea[] = [];
if (incompleteStaffCount > 0) {
risks.push({
issue: `${incompleteStaffCount} staff haven't completed Behavior Management quiz`,
severity: incompleteStaffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD ? 'high' : 'medium',
module: 'qbs',
action: 'openQuizResults',
});
}
if (selfAssessmentPendingCount > 0) {
risks.push({
issue: `${selfAssessmentPendingCount} staff haven't completed EI self-assessment`,
severity: 'low',
module: 'ei',
action: 'openQuizResults',
});
}
if (personalityPendingCount > 0) {
risks.push({
issue: `${personalityPendingCount} staff haven't completed personality type quiz`,
severity: 'low',
module: 'ei',
action: 'openQuizResults',
});
}
if (zonePendingCount > 0) {
risks.push({
issue: `${zonePendingCount} staff haven't completed daily zone check-in`,
severity: 'medium',
module: 'zones',
action: 'openQuizResults',
});
}
if (nonGreenZoneNote) {
risks.push({
issue: nonGreenZoneNote,
severity: 'medium',
module: 'zones',
});
}
const acknowledgmentRisk = buildAcknowledgmentRisk(acknowledgmentDocuments);
if (acknowledgmentRisk) {
risks.push({
issue: acknowledgmentRisk.issue,
severity: acknowledgmentRisk.severity,
module: 'director',
action: 'openAcknowledgments',
});
}
if (staffAttendanceExceptionCount > 0) {
risks.push({
issue: `${staffAttendanceExceptionCount} ${pluralize('staff attendance exception', staffAttendanceExceptionCount)} this period (${lateCount} late, ${absenceCount} absent)`,
severity: 'high',
module: 'attendance',
});
}
return risks;
}
function buildAcknowledgmentRisk(
acknowledgmentDocuments: readonly DirectorAcknowledgmentDocumentRow[],
): Pick<DirectorRiskArea, 'issue' | 'severity'> | null {
const unresolvedDocuments = acknowledgmentDocuments.filter((document) =>
document.missingCount > 0,
);
if (unresolvedDocuments.length === 0) {
return null;
}
const staffIds = new Set<string>();
for (const document of unresolvedDocuments) {
for (const staff of document.missingStaff) {
staffIds.add(staff.userId);
}
}
const staffCount = staffIds.size || Math.max(
...unresolvedDocuments.map((document) => document.missingCount),
);
const documentCount = unresolvedDocuments.length;
return {
issue: `${staffCount} ${pluralize('staff', staffCount)} haven't acknowledged ${documentCount} ${pluralize('document', documentCount)}`,
severity: staffCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD
|| documentCount > DIRECTOR_DASHBOARD_HIGH_INCOMPLETE_STAFF_THRESHOLD
? 'high'
: 'medium',
};
}
export function buildDirectorAcknowledgmentDocuments(
report?: PolicyAcknowledgmentReportDto | null,
): readonly DirectorAcknowledgmentDocumentRow[] {
if (!report) {
return [];
}
return report.documents.map((document) => {
const missingStaff = report.staff
.filter((staff) =>
staff.documents.some((status) =>
status.policyDocumentId === document.id
&& status.version === document.version
&& status.missing,
),
)
.map((staff) => ({
userId: staff.userId,
name: staff.name,
avatar: staff.avatar,
tenantName: staff.tenantName,
tenantLogo: staff.tenantLogo,
role: formatRoleLabel(staff.role),
email: staff.email,
}));
return {
id: document.id,
title: document.title,
category: document.category,
categoryLabel: getPolicyCategoryLabel(document.category),
version: document.version,
totalStaff: document.totalStaff,
acknowledgedCount: document.acknowledgedCount,
missingCount: document.missingCount,
completionRate: document.completionRate,
missingStaff,
};
});
}
function getNonGreenZoneNote(
zoneCheckinCompletion?: ZoneCheckinCompletionDto | null,
): string | null {
const nonGreenRows = zoneCheckinCompletion?.rows
.filter((row) => row.status === 'complete' && row.zone !== null && row.zone !== 'green')
?? [];
if (nonGreenRows.length === 0) {
return null;
}
const staffNotes = nonGreenRows
.map((row) => `${row.name} (${row.result})`)
.join(', ');
return `Non-green regulation zones: ${staffNotes}`;
}
function getPolicyCategoryLabel(category: string): string {
if (category === 'safety_protocol') {
return 'Safety Protocols';
}
if (category === 'handbook_policy') {
return 'Handbook & Policies';
}
return category.replace(/_/g, ' ');
}
function formatRoleLabel(role: string | null): string {
return role ? role.replace(/_/g, ' ') : 'Staff';
}
function pluralize(noun: string, count: number): string {
if (noun === 'staff') {
return 'staff';
}
return count === 1 ? noun : `${noun}s`;
}
export function buildDirectorQuizResults(
safetyRows: readonly SafetyQuizComplianceRow[],
emotionalIntelligenceCompletion?: PersonalityCompletionDto | null,
zoneCheckinCompletion?: ZoneCheckinCompletionDto | null,
tenantLabel = 'Current scope',
): readonly DirectorQuizResultRow[] {
const rowsByUserId = new Map<string, {
staffName: string;
avatar: string | null;
tenantName: string | null;
tenantLogo: string | null;
role: string;
details: DirectorQuizResultDetail[];
}>();
const ensureRow = (
userId: string,
staffName: string,
avatar: string | null,
tenantName: string | null,
tenantLogo: string | null,
role: string | null,
): {
staffName: string;
avatar: string | null;
tenantName: string | null;
tenantLogo: string | null;
role: string;
details: DirectorQuizResultDetail[];
} => {
const existing = rowsByUserId.get(userId);
if (existing) {
if (!existing.avatar && avatar) {
existing.avatar = avatar;
}
if (!existing.tenantName && tenantName) {
existing.tenantName = tenantName;
}
if (!existing.tenantLogo && tenantLogo) {
existing.tenantLogo = tenantLogo;
}
return existing;
}
const next = {
staffName,
avatar,
tenantName,
tenantLogo,
role: role ?? 'Staff',
details: [],
};
rowsByUserId.set(userId, next);
return next;
};
for (const row of safetyRows) {
ensureRow(row.userId, row.name, row.avatar, row.tenantName, row.tenantLogo, row.role).details.push({
id: 'behavior-management',
quiz: 'Behavior Management',
result: row.score,
date: row.date,
status: row.status,
});
}
for (const row of emotionalIntelligenceCompletion?.rows ?? []) {
const target = ensureRow(row.userId, row.name, row.avatar, row.tenantName, row.tenantLogo, row.role);
target.details.push(
{
id: 'ei-self-assessment',
quiz: 'EI Self-Assessment',
result: formatPersonalityQuizResult(row.selfAssessment, 'Pending'),
date: formatPersonalityQuizDate(row.selfAssessment),
status: row.selfAssessment ? 'complete' : 'pending',
},
{
id: 'personality-type',
quiz: 'Personality Type Quiz',
result: formatPersonalityQuizResult(row.personality, 'Pending'),
date: formatPersonalityQuizDate(row.personality),
status: row.personality ? 'complete' : 'pending',
},
);
}
for (const row of zoneCheckinCompletion?.rows ?? []) {
ensureRow(row.userId, row.name, row.avatar, row.tenantName, row.tenantLogo, row.role).details.push({
id: 'daily-zone-check-in',
quiz: 'Daily Zone Check-In',
result: row.result,
date: row.status === 'complete'
? new Date(`${row.date}T00:00:00`).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
: 'Not completed',
status: row.status,
});
}
return [...rowsByUserId.entries()].map(([userId, row]) => ({
id: userId,
staffName: row.staffName,
avatar: row.avatar,
tenant: row.tenantName ?? tenantLabel,
tenantLogo: row.tenantLogo,
role: row.role,
completedCount: row.details.filter((detail) => detail.status === 'complete').length,
totalCount: row.details.length,
details: row.details,
}));
}
export function buildDirectorFramePreviews(
frameEntries: readonly FrameEntryViewModel[],
): readonly DirectorFramePreview[] {
return frameEntries.slice(0, DIRECTOR_DASHBOARD_FRAME_PREVIEW_LIMIT).map((entry) => ({
id: entry.id,
week: entry.weekOf,
sections: [
{ letter: 'F', text: truncatePreview(entry.formal) },
{ letter: 'R', text: truncatePreview(entry.recognition) },
{ letter: 'A', text: truncatePreview(entry.application) },
{ letter: 'M', text: truncatePreview(entry.management) },
{ letter: 'E', text: truncatePreview(entry.emotional) },
],
}));
}
function truncatePreview(value: string): string {
if (value.length <= DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH) {
return value;
}
return `${value.slice(0, DIRECTOR_DASHBOARD_TEXT_PREVIEW_LENGTH)}...`;
}
function formatPersonalityQuizResult(
result: PersonalityQuizResultDto | null,
fallback: string,
): string {
if (!result) {
return fallback;
}
if (result.personality_type) {
return result.personality_type;
}
if (result.result_label && result.score !== null) {
return `${result.result_label} (${result.score}/${result.total_questions * 4})`;
}
return result.result_label ?? 'Completed';
}
function formatPersonalityQuizDate(result: PersonalityQuizResultDto | null): string {
if (!result) {
return 'Not completed';
}
return new Date(result.completed_at).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
}