465 lines
14 KiB
TypeScript
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',
|
|
});
|
|
}
|