From 77116185b08e233cea73620c556897382ee4d98d Mon Sep 17 00:00:00 2001 From: Cornel-design Date: Fri, 8 May 2026 00:14:11 +0300 Subject: [PATCH] Adding Fatigue Dashboard --- .../src/pages/EmployerDashboard.css | 198 ++++++++++ .../src/pages/EmployerDashboard.js | 337 ++++++++++++++++-- 2 files changed, 506 insertions(+), 29 deletions(-) diff --git a/app-frontend/employer-panel/src/pages/EmployerDashboard.css b/app-frontend/employer-panel/src/pages/EmployerDashboard.css index 14ac5e1ca..d10015134 100644 --- a/app-frontend/employer-panel/src/pages/EmployerDashboard.css +++ b/app-frontend/employer-panel/src/pages/EmployerDashboard.css @@ -255,6 +255,200 @@ body { .ss-status--rejected { color: var(--ss-red); } .ss-status--completed { color: var(--ss-blue-700); } +/* 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-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; +} + /* Create Shift Card */ .ss-card--create { @@ -442,6 +636,10 @@ body { .ss-incident-toolbar { grid-template-columns: 1fr 1fr; } + + .ss-fatigue { + grid-template-columns: 1fr; + } } @media (max-width: 640px) { diff --git a/app-frontend/employer-panel/src/pages/EmployerDashboard.js b/app-frontend/employer-panel/src/pages/EmployerDashboard.js index c9bd2ba85..46b8e53fb 100644 --- a/app-frontend/employer-panel/src/pages/EmployerDashboard.js +++ b/app-frontend/employer-panel/src/pages/EmployerDashboard.js @@ -75,6 +75,51 @@ const parseIncidentDateTime = (incident) => { return baseDate.getTime(); }; +const LONG_SHIFT_HOURS = 12; +const MIN_REST_HOURS = 12; + +const parseShiftDateTime = (dateValue, timeValue) => { + if (!dateValue) return null; + + let year; + let month; + let day; + + if (typeof dateValue === "string") { + const normalizedDate = dateValue.includes("T") ? dateValue.split("T")[0] : dateValue; + if (normalizedDate.includes("-") && normalizedDate.split("-")[0].length === 4) { + [year, month, day] = normalizedDate.split("-").map(Number); + } else if (normalizedDate.includes("-")) { + [day, month, year] = normalizedDate.split("-").map(Number); + } + } else if (dateValue instanceof Date) { + year = dateValue.getFullYear(); + month = dateValue.getMonth() + 1; + day = dateValue.getDate(); + } + + if (!year || !month || !day) return null; + + const baseDate = new Date(year, month - 1, day, 0, 0, 0, 0); + + 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; + + baseDate.setHours(hours, minutes, 0, 0); + return baseDate; +}; + export default function EmployerDashboard() { const [view, setView] = useState("list"); // default list view const overviewScroller = useRef(null); @@ -83,6 +128,7 @@ export default function EmployerDashboard() { const [shifts, setShifts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [expandedGuard, setExpandedGuard] = useState(null); // States for Incident Management const [selectedIncident, setSelectedIncident] = useState(null); @@ -132,41 +178,54 @@ export default function EmployerDashboard() { } ]); - // Fetch shifts for the logged-in employer -useEffect(() => { - const fetchShifts = async () => { - try { - const token = localStorage.getItem("token"); + // Fetch shifts for the logged-in employer + useEffect(() => { + const fetchShifts = async () => { + try { + const token = localStorage.getItem("token"); - const response = await fetch(`${process.env.REACT_APP_API_BASE_URL}/shifts/myshifts`, { - headers: { - Authorization: `Bearer ${token}`, - }, - }); + const response = await fetch(`${process.env.REACT_APP_API_BASE_URL}/shifts/myshifts`, { + headers: { + Authorization: `Bearer ${token}`, + }, + }); - console.log("fetch url:", `${process.env.REACT_APP_API_BASE_URL}/shifts/myshifts`); - console.log("response status:", response.status); - console.log("content-type:", response.headers.get("content-type")); - console.log("response url:", response.url); + console.log("fetch url:", `${process.env.REACT_APP_API_BASE_URL}/shifts/myshifts`); + console.log("response status:", response.status); + console.log("content-type:", response.headers.get("content-type")); + console.log("response url:", response.url); - const data = await response.json(); - console.log("shift response:", data); + const data = await response.json(); + console.log("shift response:", data); - if (!response.ok) { - throw new Error(data.message || "Failed to load shifts."); + if (!response.ok) { + throw new Error(data.message || "Failed to load shifts."); + } + + setShifts(Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []); + } catch (err) { + setError(err.message || "Failed to load shifts."); + console.error(err); + } finally { + setLoading(false); } + }; - setShifts(Array.isArray(data?.data) ? data.data : Array.isArray(data) ? data : []); - } catch (err) { - setError(err.message || "Failed to load shifts."); - console.error(err); - } finally { - setLoading(false); - } - }; + fetchShifts(); + }, []); - 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(() => [ { name: "John Smith", role: "Crowd Control", stars: 5 }, @@ -243,6 +302,98 @@ useEffect(() => { ); }, [incidents]); + 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 || + shift.assignedGuard || + shift.user?.name || + shift.user?.fullName || + null; + + if (!guardName) return; + + const start = parseShiftDateTime(shift.date, shift.startTime || shift.start); + const end = parseShiftDateTime(shift.date, shift.endTime || shift.end); + 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.role || "Shift", + date: shift.date, + startLabel: shift.startTime || shift.start || "--", + endLabel: shift.endTime || shift.end || "--", + location: shift.location, + status: 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]); + return (
@@ -308,7 +459,7 @@ useEffect(() => { : "No location"; const displayDate = s.date - ? new Date(s.date).toLocaleDateString() + ? new Date(`${s.date}T00:00:00`).toLocaleDateString() : "--"; const displayTime = @@ -372,6 +523,134 @@ useEffect(() => {
+ {/* Shift Fatigue Monitoring */} +

Shift Fatigue Monitoring

+
+
+
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 */}

Incident Reports