diff --git a/app-frontend/employer-panel/src/pages/EmployerDashboard.css b/app-frontend/employer-panel/src/pages/EmployerDashboard.css index d46d5d17f..374e54821 100644 --- a/app-frontend/employer-panel/src/pages/EmployerDashboard.css +++ b/app-frontend/employer-panel/src/pages/EmployerDashboard.css @@ -487,6 +487,206 @@ body { gap: 10px; } +/* Shift Fatigue Monitoring */ +.ss-fatigue { + display: grid; + grid-template-columns: 1.1fr 1.4fr; + gap: 18px; + margin-bottom: 10px; +} + +.ss-fatigue__summary, +.ss-fatigue__list { + background: #fff; + border-radius: 18px; + padding: 18px 20px; + box-shadow: var(--shadow-soft); + border: 1px solid #e2e6f0; +} + +.ss-fatigue__title { + font-weight: 700; + font-size: 16px; + margin-bottom: 12px; +} + +.ss-fatigue__stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 12px; +} + +.ss-fatigue__stat { + background: #f6f7fb; + border-radius: 14px; + padding: 12px; + text-align: center; +} + +.ss-fatigue__stat-value { + font-size: 22px; + font-weight: 700; + color: #0f3a8a; +} + +.ss-fatigue__stat-label { + font-size: 12px; + color: var(--ss-muted); +} + +.ss-fatigue__note { + font-size: 12px; + color: #5f6b85; +} + +.ss-fatigue__rows { + display: flex; + flex-direction: column; + gap: 10px; +} + +.ss-fatigue__row { + display: grid; + grid-template-columns: 1.6fr 1fr 1fr; + gap: 10px; + align-items: center; + background: #f9f9fc; + border-radius: 14px; + padding: 12px 14px; + cursor: pointer; + transition: background 0.2s ease, box-shadow 0.2s ease; +} + +.ss-fatigue__row:hover { + background: #f1f4fb; +} + +.ss-fatigue__row.is-expanded { + background: #eef3ff; + box-shadow: inset 0 0 0 1px #d6e2ff; +} + +.ss-fatigue__guard { + display: flex; + flex-direction: column; + gap: 4px; +} + +.ss-fatigue__guard-name { + font-weight: 700; + color: #1b2b4a; +} + +.ss-fatigue__guard-sub { + font-size: 12px; + color: var(--ss-muted); +} + +.ss-fatigue__metric { + text-align: center; +} + +.ss-fatigue__metric-value { + font-weight: 700; + color: #e14b4b; +} + +.ss-fatigue__metric-label { + display: block; + font-size: 12px; + color: var(--ss-muted); +} + +.ss-fatigue__detail { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed #d5ddee; + display: grid; + gap: 10px; +} + +.ss-fatigue__detail-row { + background: #ffffff; + border-radius: 12px; + padding: 10px 12px; + box-shadow: var(--shadow-soft); + border: 1px solid #e2e6f0; +} + +.ss-fatigue__detail-title { + font-weight: 700; + color: #1b2b4a; + margin-bottom: 6px; +} + +.ss-fatigue__detail-meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 6px 12px; + font-size: 12px; + color: var(--ss-muted); +} + +.ss-fatigue__modal-backdrop { + position: fixed; + inset: 0; + background: rgba(11, 25, 63, 0.45); + display: grid; + place-items: center; + z-index: 1200; + padding: 24px; +} + +.ss-fatigue__modal { + width: min(680px, 100%); + background: #ffffff; + border-radius: 18px; + padding: 18px 20px 20px; + box-shadow: 0 18px 48px rgba(10, 26, 65, 0.25); + animation: modalSlideUp 0.25s ease-out; +} + +.ss-fatigue__modal-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; + border-bottom: 1px solid #e2e6f0; + padding-bottom: 12px; + margin-bottom: 6px; +} + +.ss-fatigue__modal-title { + font-size: 18px; + font-weight: 700; + color: #1b2b4a; +} + +.ss-fatigue__modal-close { + border: none; + background: #f0f2f8; + color: #1b2b4a; + font-size: 22px; + width: 36px; + height: 36px; + border-radius: 50%; + cursor: pointer; + line-height: 1; +} + +.ss-fatigue__modal-close:hover { + background: #e3e8f4; +} + +.ss-fatigue__empty { + padding: 16px; + border-radius: 12px; + background: #f5f7fb; + color: var(--ss-muted); + font-weight: 600; +} + .ss-incident-row { position: relative; display: grid; @@ -812,6 +1012,10 @@ body { grid-template-columns: 1fr 1fr; } + .ss-fatigue { + grid-template-columns: 1fr; + } + .ss-table__head { display: none; } diff --git a/app-frontend/employer-panel/src/pages/EmployerDashboard.js b/app-frontend/employer-panel/src/pages/EmployerDashboard.js index 3c0b4b774..3f387877c 100644 --- a/app-frontend/employer-panel/src/pages/EmployerDashboard.js +++ b/app-frontend/employer-panel/src/pages/EmployerDashboard.js @@ -101,6 +101,62 @@ const parseIncidentDateTime = (incident) => { return baseDate.getTime(); }; +const LONG_SHIFT_HOURS = 12; +const MIN_REST_HOURS = 12; + +const splitTimeRange = (value) => { + if (!value) return { start: null, end: null }; + const parts = String(value) + .split("-") + .map((part) => part.trim()) + .filter(Boolean); + + if (parts.length >= 2) return { start: parts[0], end: parts[1] }; + return { start: parts[0] || null, end: null }; +}; + +const parseShiftDateTime = (dateValue, timeValue) => { + if (!dateValue) return null; + + let baseDate = null; + if (dateValue instanceof Date) { + baseDate = new Date(dateValue); + } else { + const dateStr = String(dateValue).trim(); + if (/^\d{2}-\d{2}-\d{4}$/.test(dateStr)) { + const [day, month, year] = dateStr.split("-").map(Number); + baseDate = new Date(year, month - 1, day); + } else if (/^\d{4}-\d{2}-\d{2}/.test(dateStr)) { + const datePart = dateStr.slice(0, 10); + const [year, month, day] = datePart.split("-").map(Number); + baseDate = new Date(year, month - 1, day); + } else { + const parsed = new Date(dateStr); + if (!Number.isNaN(parsed.getTime())) { + baseDate = parsed; + } + } + } + + if (!baseDate || Number.isNaN(baseDate.getTime())) return null; + if (!timeValue) return baseDate; + + const timeStr = String(timeValue).trim(); + const timeMatch = timeStr.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)?$/i); + if (!timeMatch) return baseDate; + + let hours = Number(timeMatch[1]); + const minutes = Number(timeMatch[2]); + const meridian = (timeMatch[3] || "").toUpperCase(); + + if (meridian === "PM" && hours < 12) hours += 12; + if (meridian === "AM" && hours === 12) hours = 0; + + const result = new Date(baseDate); + result.setHours(hours, minutes, 0, 0); + return result; +}; + const getShiftStatusCategory = (shift) => { const text = String(shift?.status?.text || "").toLowerCase(); const tone = String(shift?.status?.tone || "").toLowerCase(); @@ -138,6 +194,7 @@ export default function EmployerDashboard() { const [incidentStatusFilter, setIncidentStatusFilter] = useState("All"); const [incidentSeverityFilter, setIncidentSeverityFilter] = useState("All"); const [incidentSort, setIncidentSort] = useState("Newest"); + const [expandedGuard, setExpandedGuard] = useState(null); const [incidents, setIncidents] = useState([ { @@ -205,6 +262,23 @@ export default function EmployerDashboard() { const rawShifts = Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []; const normalizedShifts = rawShifts.map((shift, idx) => { + const guardName = + shift.guardName || + (typeof shift.guard === "string" ? shift.guard : null) || + shift.guard?.name || + shift.guard?.fullName || + shift.acceptedBy?.name || + shift.acceptedBy?.fullName || + (typeof shift.acceptedBy === "string" ? shift.acceptedBy : null) || + shift.assignedGuard?.name || + shift.assignedGuard?.fullName || + (typeof shift.assignedGuard === "string" ? shift.assignedGuard : null) || + shift.user?.name || + shift.user?.fullName || + null; + const startTime = shift.startTime || shift.start || null; + const endTime = shift.endTime || shift.end || null; + const rawDate = shift.date || shift.shiftDate || null; const rawStatus = shift.status; const normalizedStatus = typeof rawStatus === "object" && rawStatus !== null @@ -229,14 +303,21 @@ export default function EmployerDashboard() { title: shift.title || shift.role || "Shift 1", location: formatLocation(shift.location || shift.venue), date: formatShiftDate(shift.date || shift.shiftDate), + rawDate, time: shift.startTime && shift.endTime ? `${shift.startTime} - ${shift.endTime}` : shift.time || "--", + startTime, + endTime, status: normalizedStatus, payRate: shift.payRate ?? shift.rate ?? shift.hourlyRate ?? 0, priority: shift.priority || (idx % 3 === 0 ? "High" : idx % 3 === 1 ? "Medium" : "Low"), + guardName, + guard: shift.guard || null, + acceptedBy: shift.acceptedBy || null, + assignedGuard: shift.assignedGuard || null, }; }); @@ -324,6 +405,19 @@ export default function EmployerDashboard() { fetchShifts(); }, []); + useEffect(() => { + if (!expandedGuard) return; + + const handleKeyDown = (event) => { + if (event.key === "Escape") { + setExpandedGuard(null); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [expandedGuard]); + const reviews = useMemo( () => [ { @@ -395,6 +489,100 @@ export default function EmployerDashboard() { }; }, [shifts]); + const fatigueData = useMemo(() => { + const guardMap = new Map(); + + shifts.forEach((shift) => { + const guardName = + shift.guardName || + (typeof shift.guard === "string" ? shift.guard : null) || + shift.guard?.name || + shift.guard?.fullName || + shift.acceptedBy?.name || + shift.acceptedBy?.fullName || + (typeof shift.acceptedBy === "string" ? shift.acceptedBy : null) || + shift.assignedGuard?.name || + shift.assignedGuard?.fullName || + (typeof shift.assignedGuard === "string" ? shift.assignedGuard : null) || + shift.user?.name || + shift.user?.fullName || + null; + if (!guardName) return; + + const { start: rangeStart, end: rangeEnd } = splitTimeRange(shift.time); + const startLabel = shift.startTime || rangeStart; + const endLabel = shift.endTime || rangeEnd; + const start = parseShiftDateTime(shift.rawDate || shift.date, startLabel); + const end = parseShiftDateTime(shift.rawDate || shift.date, endLabel); + + if (!start || !end || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) return; + + const startTime = start.getTime(); + let endTime = end.getTime(); + + if (endTime <= startTime) { + endTime += 24 * 60 * 60 * 1000; + } + + const durationHours = (endTime - startTime) / (1000 * 60 * 60); + if (durationHours <= 0 || durationHours > 23) return; + + if (!guardMap.has(guardName)) { + guardMap.set(guardName, []); + } + + guardMap.get(guardName).push({ + startTime, + endTime, + durationHours, + title: shift.title || "Shift", + date: shift.rawDate || shift.date, + startLabel: startLabel || "--", + endLabel: endLabel || "--", + location: shift.location, + status: shift.status?.text || shift.status || "Open", + }); + }); + + const guards = Array.from(guardMap.entries()).map(([guard, guardShifts]) => { + const sorted = [...guardShifts].sort((a, b) => a.startTime - b.startTime); + let longShiftCount = 0; + let consecutiveCount = 0; + let minRestGap = Number.POSITIVE_INFINITY; + + sorted.forEach((shift, idx) => { + if (shift.durationHours >= LONG_SHIFT_HOURS) longShiftCount += 1; + if (idx === 0) return; + const previous = sorted[idx - 1]; + const restHours = (shift.startTime - previous.endTime) / (1000 * 60 * 60); + if (restHours >= 0 && restHours < MIN_REST_HOURS) consecutiveCount += 1; + if (restHours >= 0) minRestGap = Math.min(minRestGap, restHours); + }); + + const restRecommendation = Number.isFinite(minRestGap) + ? Math.min(100, Math.max(0, Math.round((minRestGap / MIN_REST_HOURS) * 100))) + : 100; + + return { + guard, + longShiftCount, + consecutiveCount, + restRecommendation, + shifts: sorted, + }; + }); + + const overworked = guards.filter( + (guard) => guard.longShiftCount > 0 || guard.consecutiveCount > 0 + ); + + const avgRestRecommendation = guards.length + ? Math.round(guards.reduce((sum, guard) => sum + guard.restRecommendation, 0) / guards.length) + : 0; + + return { guards, overworked, avgRestRecommendation }; + }, [shifts]); + const filteredIncidents = useMemo(() => { const normalizedQuery = incidentQuery.trim().toLowerCase(); @@ -661,6 +849,140 @@ export default function EmployerDashboard() { +
+

Shift Fatigue Monitoring

+

Fatigue signals from recent shifts

+
+ +
+
+
+
Risk Signals
+
+
+
{fatigueData.guards.length}
+
Guards Monitored
+
+
+
{fatigueData.overworked.length}
+
Overworked Guards
+
+
+
{fatigueData.avgRestRecommendation}%
+
Avg Rest Recommendation
+
+
+
+ Long shift threshold: {LONG_SHIFT_HOURS}h · Minimum rest target: {MIN_REST_HOURS}h +
+
+ +
+
Overworked Guards
+ {fatigueData.overworked.length === 0 ? ( +
No fatigue risks detected yet.
+ ) : ( +
+ {fatigueData.overworked.map((guard) => { + const isExpanded = expandedGuard?.guard === guard.guard; + return ( +
setExpandedGuard(isExpanded ? null : guard)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setExpandedGuard(isExpanded ? null : guard); + } + }} + > +
+
{guard.guard}
+
+ Rest recommendation {guard.restRecommendation}% +
+
+
+ {guard.longShiftCount} + Long Shifts +
+
+ {guard.consecutiveCount} + Consecutive Shifts +
+
+ ); + })} +
+ )} +
+
+
+ + {expandedGuard && ( +
setExpandedGuard(null)} + > +
event.stopPropagation()}> +
+
+
{expandedGuard.guard}
+
+ Rest recommendation {expandedGuard.restRecommendation}% +
+
+ +
+
+ {expandedGuard.shifts.map((shiftItem, index) => { + const locationText = + typeof shiftItem.location === "string" + ? shiftItem.location + : shiftItem.location + ? [ + shiftItem.location.street, + shiftItem.location.suburb, + shiftItem.location.state, + ] + .filter(Boolean) + .join(", ") + : "No location"; + + const dateText = shiftItem.date + ? new Date(`${shiftItem.date}T00:00:00`).toLocaleDateString() + : "--"; + + return ( +
+
{shiftItem.title}
+
+ {dateText} + + {shiftItem.startLabel} - {shiftItem.endLabel} + + {locationText} + Status: {shiftItem.status} +
+
+ ); + })} +
+
+
+ )} +

Incident Reports