Files
manager_employees_gui/src/pages/SchedulePage.tsx
T

332 lines
11 KiB
TypeScript

import { useEffect, useMemo, useState } from "react";
import { FaChevronLeft, FaChevronRight } from "react-icons/fa6";
import { getWeekSchedule } from "@api/scheduleApi";
import { PageHeader } from "@components/PageHeader";
import type { BackendPreference } from "@/types/backend";
import type { Schedule } from "@/types/schedule";
import styles from "./SchedulePage.module.scss";
import { useCompaniesStore } from "../store/companiesStore";
type SchedulePageProps = {
backendPreference: BackendPreference;
};
const businessTimeZone = "America/New_York";
const weekdayLabels = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const companyColorPalette = [
"#2563eb",
"#dc2626",
"#16a34a",
"#9333ea",
"#ea580c",
"#0891b2",
"#be123c",
"#4f46e5",
"#65a30d",
"#c026d3",
"#0e7490",
"#9d174d",
];
function getCompanyColor(company: string | undefined): string {
if (!company) {
return "#64748b";
}
const hash = [...company].reduce((total, char) => total + char.charCodeAt(0), 0);
return companyColorPalette[hash % companyColorPalette.length];
}
function normalizeBackendTimestamp(value: string): string {
const hasTimezone = /[Zz]$/.test(value) || /[+-]\d{2}:\d{2}$/.test(value);
const baseValue = hasTimezone ? value : `${value}Z`;
return baseValue.replace(/(\.\d{3})\d+/, "$1");
}
function parseBackendDateAsUTC(value: string): Date {
return new Date(normalizeBackendTimestamp(value));
}
function getBusinessDateKey(date: Date): string {
return date.toLocaleDateString("en-CA", { timeZone: businessTimeZone });
}
function getBusinessWeekdayIndex(date: Date): number {
const weekday = new Intl.DateTimeFormat("en-US", {
timeZone: businessTimeZone,
weekday: "short",
}).format(date);
const mapping: Record<string, number> = {
Sun: 0,
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
};
return mapping[weekday] ?? 0;
}
function addDaysToDateKey(dateKey: string, days: number): string {
const [year, month, day] = dateKey.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
function getBusinessMonthStartKey(date: Date): string {
const businessKey = getBusinessDateKey(date);
return `${businessKey.slice(0, 7)}-01`;
}
function getBusinessWeekdayIndexForKey(dateKey: string): number {
const [year, month, day] = dateKey.split("-").map(Number);
const date = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
return getBusinessWeekdayIndex(date);
}
function getCalendarGridStartKey(monthStartKey: string): string {
const weekday = getBusinessWeekdayIndexForKey(monthStartKey);
return addDaysToDateKey(monthStartKey, -weekday);
}
function formatMonthLabel(monthStartKey: string): string {
const [year, month] = monthStartKey.split("-").map(Number);
const monthNames = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
return `${monthNames[month - 1]} ${year}`;
}
function formatTime(value: string | null): string {
if (!value) {
return "Not registered";
}
return parseBackendDateAsUTC(value).toLocaleTimeString("en-US", {
timeZone: businessTimeZone,
hour: "2-digit",
minute: "2-digit",
});
}
export function SchedulePage({ backendPreference }: SchedulePageProps) {
const [schedules, setSchedules] = useState<Schedule[]>([]);
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(true);
const [selectedCompany, setSelectedCompany] = useState("");
const [selectedMonthStartKey, setSelectedMonthStartKey] = useState(() =>
getBusinessMonthStartKey(new Date()),
);
const companyNames = useCompaniesStore((state) => state.companyNames);
const companiesError = useCompaniesStore((state) => state.error);
const loadCompanies = useCompaniesStore((state) => state.loadCompanies);
const todayKey = useMemo(() => getBusinessDateKey(new Date()), []);
const calendarStartKey = useMemo(
() => getCalendarGridStartKey(selectedMonthStartKey),
[selectedMonthStartKey],
);
const calendarDays = useMemo(() => {
return Array.from({ length: 42 }, (_, index) => {
const dateKey = addDaysToDateKey(calendarStartKey, index);
const isCurrentMonth = dateKey.slice(0, 7) === selectedMonthStartKey.slice(0, 7);
const isToday = dateKey === todayKey;
const dayNumber = Number(dateKey.slice(8, 10));
return { dateKey, isCurrentMonth, isToday, dayNumber };
});
}, [calendarStartKey, selectedMonthStartKey, todayKey]);
const schedulesByDateKey = useMemo(() => {
return schedules.reduce<Record<string, Schedule[]>>((accumulator, schedule) => {
if (selectedCompany && schedule.company !== selectedCompany) {
return accumulator;
}
if (!schedule.enter_time) {
return accumulator;
}
const enterDate = parseBackendDateAsUTC(schedule.enter_time);
if (Number.isNaN(enterDate.getTime())) {
return accumulator;
}
const businessDateKey = getBusinessDateKey(enterDate);
accumulator[businessDateKey] = [...(accumulator[businessDateKey] ?? []), schedule];
return accumulator;
}, {});
}, [schedules, selectedCompany]);
function showPreviousMonth() {
setSelectedMonthStartKey((currentKey) => {
const [y, m] = currentKey.split("-").map(Number);
const d = new Date(Date.UTC(y, m - 1, 1));
d.setUTCMonth(d.getUTCMonth() - 1);
return d.toISOString().slice(0, 10);
});
}
function showNextMonth() {
setSelectedMonthStartKey((currentKey) => {
const [y, m] = currentKey.split("-").map(Number);
const d = new Date(Date.UTC(y, m - 1, 1));
d.setUTCMonth(d.getUTCMonth() + 1);
return d.toISOString().slice(0, 10);
});
}
function showToday() {
setSelectedMonthStartKey(getBusinessMonthStartKey(new Date()));
}
useEffect(() => {
let isMounted = true;
async function loadSchedule() {
setError("");
setIsLoading(true);
try {
const scheduleResponse = await getWeekSchedule(backendPreference);
if (isMounted) {
setSchedules(scheduleResponse.schedules);
}
} catch (loadError) {
if (isMounted) {
setError(loadError instanceof Error ? loadError.message : "Unable to load schedule");
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
}
void loadSchedule();
return () => {
isMounted = false;
};
}, [backendPreference]);
useEffect(() => {
void loadCompanies(backendPreference);
}, [backendPreference, loadCompanies]);
useEffect(() => {
if (selectedCompany && !companyNames.includes(selectedCompany)) {
setSelectedCompany("");
}
}, [companyNames, selectedCompany]);
return (
<div className={styles.schedulePage}>
<PageHeader
title="Schedule"
description="Monthly calendar shows who is working each day. Week starts on Sunday."
/>
{error ? <p className={styles.error}>{error}</p> : null}
{companiesError ? <p className={styles.error}>{companiesError}</p> : null}
{isLoading ? <p className={styles.muted}>Loading schedule...</p> : null}
<div className={styles.calendarShell} aria-label="Monthly schedule calendar">
<div className={styles.calendarHeader}>
<div className={styles.monthTitle}>
<h2>{formatMonthLabel(selectedMonthStartKey)}</h2>
</div>
<div className={styles.headerActions}>
<div className={styles.navGroup}>
<button type="button" onClick={showPreviousMonth} aria-label="Previous month">
<FaChevronLeft aria-hidden="true" />
</button>
<button type="button" className={styles.todayButton} onClick={showToday}>
Today
</button>
<button type="button" onClick={showNextMonth} aria-label="Next month">
<FaChevronRight aria-hidden="true" />
</button>
</div>
<label className={styles.companyFilter}>
<select
value={selectedCompany}
onChange={(event) => setSelectedCompany(event.target.value)}
>
<option value="">All companies</option>
{companyNames.map((company) => (
<option key={company} value={company}>
{company}
</option>
))}
</select>
</label>
</div>
</div>
<div className={styles.calendarWeekdays} aria-hidden="true">
{weekdayLabels.map((label) => (
<div key={label} className={styles.weekdayLabel}>
{label}
</div>
))}
</div>
<div className={styles.calendarScroll}>
<div className={styles.calendarGrid}>
{calendarDays.map(({ dateKey, isCurrentMonth, isToday, dayNumber }) => {
const daySchedules = schedulesByDateKey[dateKey] ?? [];
return (
<div
key={dateKey}
className={`${styles.calendarDay} ${isCurrentMonth ? "" : styles.outsideMonth} ${isToday ? styles.todayCell : ""}`}
>
<div className={styles.dayHeader}>
<span className={`${styles.dayNumber} ${isToday ? styles.todayBadge : ""}`}>
{dayNumber}
</span>
</div>
<div className={styles.eventList}>
{daySchedules.length > 0 ? (
daySchedules.map((schedule) => (
<div
className={styles.event}
key={schedule.id}
style={{ ["--company-color" as string]: getCompanyColor(schedule.company) }}
title={`${schedule.user ? `${schedule.user.name} ${schedule.user.last_name}` : "Unknown user"} ${formatTime(schedule.enter_time)} - ${formatTime(schedule.out_time)}`}
>
<span className={styles.eventTitle}>
{schedule.user ? `${schedule.user.name} ${schedule.user.last_name}` : "Unknown user"}
</span>
<span className={styles.eventMeta}>
<span className={styles.companyDot} /> {schedule.company ?? "No company"} · {formatTime(schedule.enter_time)} - {formatTime(schedule.out_time)}
</span>
</div>
))
) : (
<span className={styles.emptyDay} />
)}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}