332 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|