fixing the time between UTC and timezone from New York
This commit is contained in:
+76
-8
@@ -14,6 +14,65 @@ type HomePageProps = {
|
||||
onNavigate: (path: string) => void;
|
||||
};
|
||||
|
||||
const businessTimeZone = "America/New_York";
|
||||
|
||||
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 getNameScheduleKey(name: string, lastName: string): string {
|
||||
return `name:${name.trim().toLowerCase()}:${lastName.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function getScheduleUserKey(schedule: Schedule): string | null {
|
||||
if (typeof schedule.user_id === "number") {
|
||||
return `id:${schedule.user_id}`;
|
||||
}
|
||||
|
||||
if (schedule.user) {
|
||||
return getNameScheduleKey(schedule.user.name, schedule.user.last_name);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getEmployeeScheduleKeys(user: Employee): string[] {
|
||||
return [`id:${user.id}`, getNameScheduleKey(user.name, user.last_name)];
|
||||
}
|
||||
|
||||
function getTodaySchedules(schedules: Schedule[], today: Date): Schedule[] {
|
||||
const todayKey = getBusinessDateKey(today);
|
||||
return schedules.filter((schedule) => {
|
||||
if (!schedule.enter_time) {
|
||||
return false;
|
||||
}
|
||||
const enterDate = parseBackendDateAsUTC(schedule.enter_time);
|
||||
return !Number.isNaN(enterDate.getTime()) && getBusinessDateKey(enterDate) === todayKey;
|
||||
});
|
||||
}
|
||||
|
||||
function getLatestSchedule(schedules: Schedule[]): Schedule | undefined {
|
||||
return [...schedules].sort((firstSchedule, secondSchedule) => {
|
||||
const firstTime = firstSchedule.enter_time
|
||||
? parseBackendDateAsUTC(firstSchedule.enter_time).getTime()
|
||||
: 0;
|
||||
const secondTime = secondSchedule.enter_time
|
||||
? parseBackendDateAsUTC(secondSchedule.enter_time).getTime()
|
||||
: 0;
|
||||
return firstTime - secondTime;
|
||||
}).at(-1);
|
||||
}
|
||||
|
||||
export function HomePage({ backendPreference, onNavigate }: HomePageProps) {
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||
@@ -60,14 +119,23 @@ export function HomePage({ backendPreference, onNavigate }: HomePageProps) {
|
||||
};
|
||||
}, [backendPreference]);
|
||||
|
||||
const workingToday = new Set(
|
||||
schedules
|
||||
.filter(
|
||||
(schedule): schedule is Schedule & { user_id: number } =>
|
||||
typeof schedule.user_id === "number" && Boolean(schedule.enter_time && !schedule.out_time),
|
||||
)
|
||||
.map((schedule) => schedule.user_id),
|
||||
).size;
|
||||
const today = new Date();
|
||||
const schedulesByUser = schedules.reduce<Record<string, Schedule[]>>((accumulator, schedule) => {
|
||||
const userKey = getScheduleUserKey(schedule);
|
||||
if (!userKey) {
|
||||
return accumulator;
|
||||
}
|
||||
accumulator[userKey] = [...(accumulator[userKey] ?? []), schedule];
|
||||
return accumulator;
|
||||
}, {});
|
||||
|
||||
const workingToday = users.filter((user) => {
|
||||
const keys = getEmployeeScheduleKeys(user);
|
||||
const userSchedules = keys.flatMap((key) => schedulesByUser[key] ?? []);
|
||||
const todaySchedules = getTodaySchedules(userSchedules, today);
|
||||
const latestSchedule = getLatestSchedule(todaySchedules);
|
||||
return Boolean(latestSchedule?.enter_time && !latestSchedule.out_time);
|
||||
}).length;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
.schedulePage {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.scheduleToolbar {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
@@ -61,53 +67,191 @@
|
||||
}
|
||||
}
|
||||
|
||||
.calendar {
|
||||
.calendarShell {
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 20px 50px rgba(17, 24, 39, 0.08);
|
||||
display: grid;
|
||||
grid-template-rows: auto auto minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
border: 1px solid #e5eaf0;
|
||||
}
|
||||
|
||||
.calendarHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid #e5eaf0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.monthTitle h2 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
letter-spacing: -0.03em;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.navGroup {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
button {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: #1f2937;
|
||||
background: #f3f6fb;
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
|
||||
&:hover {
|
||||
background: #e8eefb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.todayButton {
|
||||
width: auto !important;
|
||||
padding: 0 14px;
|
||||
font-size: 0.82rem;
|
||||
background: #ffffff !important;
|
||||
box-shadow: inset 0 0 0 1px #d9e1ec;
|
||||
}
|
||||
|
||||
.calendarWeekdays {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e5eaf0;
|
||||
}
|
||||
|
||||
.weekdayLabel {
|
||||
padding: 10px 12px;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.calendarScroll {
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
max-height: min(68vh, 70vh);
|
||||
}
|
||||
|
||||
.calendarGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(130px, 1fr));
|
||||
gap: 12px;
|
||||
grid-auto-rows: minmax(122px, auto);
|
||||
gap: 1px;
|
||||
background: #e5eaf0;
|
||||
min-width: 980px;
|
||||
}
|
||||
|
||||
.dayCard {
|
||||
min-height: 210px;
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
.calendarDay {
|
||||
background: #ffffff;
|
||||
box-shadow: 0 14px 35px rgba(17, 24, 39, 0.07);
|
||||
|
||||
h3 {
|
||||
margin: 0 0 14px;
|
||||
font-size: 1rem;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: #687487;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
padding: 8px 8px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 122px;
|
||||
}
|
||||
|
||||
.shiftList {
|
||||
.outsideMonth {
|
||||
background: #fcfdff;
|
||||
color: #9aa6b8;
|
||||
}
|
||||
|
||||
.todayCell {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.dayHeader {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dayNumber {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.todayBadge {
|
||||
background: #ef4444;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.eventList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
gap: 6px;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.shift {
|
||||
border-radius: 14px;
|
||||
padding: 10px;
|
||||
background: #f3f6fb;
|
||||
.event {
|
||||
--company-color: #2563eb;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--company-color) 12%, #ffffff);
|
||||
border-left: 3px solid var(--company-color);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
strong,
|
||||
span {
|
||||
display: block;
|
||||
}
|
||||
.eventTitle {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #687487;
|
||||
font-size: 0.86rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.eventMeta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 0.72rem;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.companyDot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--company-color);
|
||||
}
|
||||
|
||||
.emptyDay {
|
||||
min-height: 1px;
|
||||
}
|
||||
|
||||
.error {
|
||||
@@ -121,26 +265,20 @@
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.scheduleToolbar {
|
||||
gap: 12px;
|
||||
.calendarHeader {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.weekNavigator {
|
||||
gap: 10px;
|
||||
|
||||
h2 {
|
||||
min-width: 0;
|
||||
font-size: 1rem;
|
||||
.headerActions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.monthTitle h2 {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.calendar {
|
||||
overflow-x: auto;
|
||||
.calendarScroll {
|
||||
max-height: 62vh;
|
||||
}
|
||||
}
|
||||
|
||||
+196
-99
@@ -4,51 +4,112 @@ import { getWeekSchedule } from "@api/scheduleApi";
|
||||
import { PageHeader } from "@components/PageHeader";
|
||||
import type { BackendPreference } from "@/types/backend";
|
||||
import type { Schedule } from "@/types/schedule";
|
||||
import type { DayOfWeek } from "@/types/users";
|
||||
import { useCompaniesStore } from "../store/companiesStore";
|
||||
import styles from "./SchedulePage.module.scss";
|
||||
import { useCompaniesStore } from "../store/companiesStore";
|
||||
|
||||
type SchedulePageProps = {
|
||||
backendPreference: BackendPreference;
|
||||
};
|
||||
|
||||
const weekDays: DayOfWeek[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
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 formatDay(day: DayOfWeek): string {
|
||||
return day.charAt(0).toUpperCase() + day.slice(1);
|
||||
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 getWeekStart(date: Date): Date {
|
||||
const weekStart = new Date(date);
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
||||
return weekStart;
|
||||
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 addDays(date: Date, days: number): Date {
|
||||
const nextDate = new Date(date);
|
||||
nextDate.setDate(nextDate.getDate() + days);
|
||||
return nextDate;
|
||||
function parseBackendDateAsUTC(value: string): Date {
|
||||
return new Date(normalizeBackendTimestamp(value));
|
||||
}
|
||||
|
||||
function formatDate(value: Date): string {
|
||||
return value.toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
function getBusinessDateKey(date: Date): string {
|
||||
return date.toLocaleDateString("en-CA", { timeZone: businessTimeZone });
|
||||
}
|
||||
|
||||
function formatShortDate(value: Date): string {
|
||||
return value.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
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 {
|
||||
@@ -56,7 +117,11 @@ function formatTime(value: string | null): string {
|
||||
return "Not registered";
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
return parseBackendDateAsUTC(value).toLocaleTimeString("en-US", {
|
||||
timeZone: businessTimeZone,
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function SchedulePage({ backendPreference }: SchedulePageProps) {
|
||||
@@ -64,24 +129,31 @@ export function SchedulePage({ backendPreference }: SchedulePageProps) {
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedCompany, setSelectedCompany] = useState("");
|
||||
const [selectedWeekStart, setSelectedWeekStart] = useState(() => getWeekStart(new Date()));
|
||||
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 selectedWeekEnd = useMemo(() => addDays(selectedWeekStart, 6), [selectedWeekStart]);
|
||||
const selectedWeekEndExclusive = useMemo(
|
||||
() => addDays(selectedWeekStart, 7),
|
||||
[selectedWeekStart],
|
||||
);
|
||||
const selectedWeekDays = useMemo(
|
||||
() => weekDays.map((day, index) => ({ day, date: addDays(selectedWeekStart, index) })),
|
||||
[selectedWeekStart],
|
||||
const todayKey = useMemo(() => getBusinessDateKey(new Date()), []);
|
||||
const calendarStartKey = useMemo(
|
||||
() => getCalendarGridStartKey(selectedMonthStartKey),
|
||||
[selectedMonthStartKey],
|
||||
);
|
||||
|
||||
const schedulesByDay = useMemo(() => {
|
||||
return schedules.reduce<Record<DayOfWeek, Schedule[]>>(
|
||||
(accumulator, schedule) => {
|
||||
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;
|
||||
}
|
||||
@@ -90,38 +162,38 @@ export function SchedulePage({ backendPreference }: SchedulePageProps) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
const enterDate = new Date(schedule.enter_time);
|
||||
const enterDate = parseBackendDateAsUTC(schedule.enter_time);
|
||||
|
||||
if (
|
||||
Number.isNaN(enterDate.getTime()) ||
|
||||
enterDate < selectedWeekStart ||
|
||||
enterDate >= selectedWeekEndExclusive
|
||||
) {
|
||||
if (Number.isNaN(enterDate.getTime())) {
|
||||
return accumulator;
|
||||
}
|
||||
|
||||
const day = weekDays[enterDate.getDay()];
|
||||
accumulator[day] = [...accumulator[day], schedule];
|
||||
const businessDateKey = getBusinessDateKey(enterDate);
|
||||
accumulator[businessDateKey] = [...(accumulator[businessDateKey] ?? []), schedule];
|
||||
return accumulator;
|
||||
},
|
||||
{
|
||||
sunday: [],
|
||||
monday: [],
|
||||
tuesday: [],
|
||||
wednesday: [],
|
||||
thursday: [],
|
||||
friday: [],
|
||||
saturday: [],
|
||||
},
|
||||
);
|
||||
}, [schedules, selectedCompany, selectedWeekEndExclusive, selectedWeekStart]);
|
||||
}, {});
|
||||
}, [schedules, selectedCompany]);
|
||||
|
||||
function showPreviousWeek() {
|
||||
setSelectedWeekStart((currentWeekStart) => addDays(currentWeekStart, -7));
|
||||
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 showNextWeek() {
|
||||
setSelectedWeekStart((currentWeekStart) => addDays(currentWeekStart, 7));
|
||||
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(() => {
|
||||
@@ -166,25 +238,31 @@ export function SchedulePage({ backendPreference }: SchedulePageProps) {
|
||||
}, [companyNames, selectedCompany]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={styles.schedulePage}>
|
||||
<PageHeader
|
||||
title="Schedule"
|
||||
description="Weekly scheduler calendar starts on Sunday and ends on Saturday so you can see who is working each day."
|
||||
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.scheduleToolbar}>
|
||||
<div className={styles.weekNavigator} aria-label="Select schedule week">
|
||||
<button type="button" onClick={showPreviousWeek} aria-label="Previous week">
|
||||
<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>
|
||||
<h2>
|
||||
Week from {formatDate(selectedWeekStart)} to {formatDate(selectedWeekEnd)}
|
||||
</h2>
|
||||
<button type="button" onClick={showNextWeek} aria-label="Next week">
|
||||
<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}
|
||||
@@ -200,35 +278,54 @@ export function SchedulePage({ backendPreference }: SchedulePageProps) {
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<section className={styles.calendar} aria-label="Weekly schedule from Sunday to Saturday">
|
||||
{selectedWeekDays.map(({ day, date }) => (
|
||||
<article className={styles.dayCard} key={day}>
|
||||
<h3>
|
||||
{formatDay(day)}
|
||||
<span>{formatShortDate(date)}</span>
|
||||
</h3>
|
||||
<div className={styles.shiftList}>
|
||||
{schedulesByDay[day].length > 0 ? (
|
||||
schedulesByDay[day].map((schedule) => (
|
||||
<div className={styles.shift} key={schedule.id}>
|
||||
<strong>
|
||||
{schedule.user
|
||||
? `${schedule.user.name} ${schedule.user.last_name}`
|
||||
: "Unknown user"}
|
||||
</strong>
|
||||
<span>{schedule.company ?? "No company"}</span>
|
||||
<span>
|
||||
{formatTime(schedule.enter_time)} - {formatTime(schedule.out_time)}
|
||||
<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>
|
||||
))
|
||||
) : (
|
||||
<p className={styles.muted}>No shifts scheduled</p>
|
||||
<span className={styles.emptyDay} />
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+155
-43
@@ -20,56 +20,96 @@ type UserStatus = "off-duty" | "working" | "worked";
|
||||
const workTimeOptions: WorkTime[] = ["morning", "afternoon", "evening"];
|
||||
const statusOptions: UserStatus[] = ["off-duty", "working", "worked"];
|
||||
const usersPerPage = 10;
|
||||
const businessTimeZone = "America/New_York";
|
||||
|
||||
function isSameLocalDate(firstDate: Date, secondDate: Date): boolean {
|
||||
return (
|
||||
firstDate.getFullYear() === secondDate.getFullYear() &&
|
||||
firstDate.getMonth() === secondDate.getMonth() &&
|
||||
firstDate.getDate() === secondDate.getDate()
|
||||
);
|
||||
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 getWeekStart(date: Date): Date {
|
||||
const weekStart = new Date(date);
|
||||
weekStart.setHours(0, 0, 0, 0);
|
||||
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
||||
return weekStart;
|
||||
function parseBackendDateAsUTC(value: string): Date {
|
||||
return new Date(normalizeBackendTimestamp(value));
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number): Date {
|
||||
const nextDate = new Date(date);
|
||||
nextDate.setDate(nextDate.getDate() + days);
|
||||
return nextDate;
|
||||
function getBusinessDateKey(date: Date): string {
|
||||
return date.toLocaleDateString("en-CA", { timeZone: businessTimeZone });
|
||||
}
|
||||
|
||||
function isScheduleInWeek(schedule: Schedule, weekStart: Date, weekEndExclusive: Date): boolean {
|
||||
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 getBusinessWeekStartKey(date: Date): string {
|
||||
const todayKey = getBusinessDateKey(date);
|
||||
const weekdayIndex = getBusinessWeekdayIndex(date);
|
||||
return addDaysToDateKey(todayKey, -weekdayIndex);
|
||||
}
|
||||
|
||||
function getBusinessWeekEndExclusiveKey(date: Date): string {
|
||||
return addDaysToDateKey(getBusinessWeekStartKey(date), 7);
|
||||
}
|
||||
|
||||
function isScheduleInWeek(schedule: Schedule, today: Date): boolean {
|
||||
if (!schedule.enter_time) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const enterDate = new Date(schedule.enter_time);
|
||||
const enterDate = parseBackendDateAsUTC(schedule.enter_time);
|
||||
|
||||
return (
|
||||
!Number.isNaN(enterDate.getTime()) && enterDate >= weekStart && enterDate < weekEndExclusive
|
||||
);
|
||||
if (Number.isNaN(enterDate.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const businessDateKey = getBusinessDateKey(enterDate);
|
||||
const weekStartKey = getBusinessWeekStartKey(today);
|
||||
const weekEndKey = getBusinessWeekEndExclusiveKey(today);
|
||||
|
||||
return businessDateKey >= weekStartKey && businessDateKey < weekEndKey;
|
||||
}
|
||||
|
||||
function getTodaySchedules(schedules: Schedule[], today: Date): Schedule[] {
|
||||
const todayKey = getBusinessDateKey(today);
|
||||
|
||||
return schedules.filter((schedule) => {
|
||||
if (!schedule.enter_time) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const enterDate = new Date(schedule.enter_time);
|
||||
const enterDate = parseBackendDateAsUTC(schedule.enter_time);
|
||||
|
||||
return !Number.isNaN(enterDate.getTime()) && isSameLocalDate(enterDate, today);
|
||||
return !Number.isNaN(enterDate.getTime()) && getBusinessDateKey(enterDate) === todayKey;
|
||||
});
|
||||
}
|
||||
|
||||
function getLatestSchedule(schedules: Schedule[]): Schedule | undefined {
|
||||
return [...schedules].sort((firstSchedule, secondSchedule) => {
|
||||
const firstTime = firstSchedule.enter_time ? new Date(firstSchedule.enter_time).getTime() : 0;
|
||||
const secondTime = secondSchedule.enter_time ? new Date(secondSchedule.enter_time).getTime() : 0;
|
||||
const firstTime = firstSchedule.enter_time
|
||||
? parseBackendDateAsUTC(firstSchedule.enter_time).getTime()
|
||||
: 0;
|
||||
const secondTime = secondSchedule.enter_time
|
||||
? parseBackendDateAsUTC(secondSchedule.enter_time).getTime()
|
||||
: 0;
|
||||
|
||||
return firstTime - secondTime;
|
||||
}).at(-1);
|
||||
@@ -107,7 +147,9 @@ function formatDateTime(value: string | null): string {
|
||||
return "--";
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString();
|
||||
return parseBackendDateAsUTC(value).toLocaleString("en-US", {
|
||||
timeZone: businessTimeZone,
|
||||
});
|
||||
}
|
||||
|
||||
function getNameScheduleKey(name: string, lastName: string): string {
|
||||
@@ -134,6 +176,29 @@ function getSchedulesForUser(user: Employee, schedulesByUser: Record<string, Sch
|
||||
return getEmployeeScheduleKeys(user).flatMap((key) => schedulesByUser[key] ?? []);
|
||||
}
|
||||
|
||||
function normalizeScheduleForUser(schedule: Schedule, user: Employee): Schedule {
|
||||
return {
|
||||
...schedule,
|
||||
user_id: schedule.user_id ?? user.id,
|
||||
user: schedule.user ?? {
|
||||
name: user.name,
|
||||
last_name: user.last_name,
|
||||
preference: user.preference,
|
||||
},
|
||||
out_time: schedule.out_time ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSchedule(schedules: Schedule[], schedule: Schedule): Schedule[] {
|
||||
const existingIndex = schedules.findIndex((item) => item.id === schedule.id);
|
||||
|
||||
if (existingIndex === -1) {
|
||||
return [...schedules, schedule];
|
||||
}
|
||||
|
||||
return schedules.map((item, index) => (index === existingIndex ? schedule : item));
|
||||
}
|
||||
|
||||
export function UsersPage({ backendPreference }: UsersPageProps) {
|
||||
const [companies, setCompanies] = useState<Company[]>([]);
|
||||
const [schedules, setSchedules] = useState<Schedule[]>([]);
|
||||
@@ -157,8 +222,6 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
|
||||
|
||||
const selectedUser = users.find((user) => user.id === selectedUserId) ?? null;
|
||||
const today = new Date();
|
||||
const currentWeekStart = getWeekStart(today);
|
||||
const currentWeekEndExclusive = addDays(currentWeekStart, 7);
|
||||
|
||||
const schedulesByUser = useMemo(() => {
|
||||
return schedules.reduce<Record<string, Schedule[]>>((accumulator, schedule) => {
|
||||
@@ -274,21 +337,31 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function getBusinessWeekdayFromDate(date: Date): import("@/types/users").DayOfWeek {
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: businessTimeZone,
|
||||
weekday: "long",
|
||||
})
|
||||
.format(date)
|
||||
.toLowerCase() as import("@/types/users").DayOfWeek;
|
||||
}
|
||||
|
||||
function getBusinessHourFromDate(date: Date): number {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: businessTimeZone,
|
||||
hour: "numeric",
|
||||
hour12: false,
|
||||
}).formatToParts(date);
|
||||
const hourPart = parts.find((part) => part.type === "hour");
|
||||
return hourPart ? Number(hourPart.value) % 24 : 0;
|
||||
}
|
||||
|
||||
function getDayOfWeekFromDateTime(value: string): import("@/types/users").DayOfWeek {
|
||||
const days: import("@/types/users").DayOfWeek[] = [
|
||||
"sunday",
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
];
|
||||
return days[new Date(value).getDay()];
|
||||
return getBusinessWeekdayFromDate(new Date(value));
|
||||
}
|
||||
|
||||
function getShiftFromDateTime(value: string): WorkTime {
|
||||
const hour = new Date(value).getHours();
|
||||
const hour = getBusinessHourFromDate(new Date(value));
|
||||
|
||||
if (hour < 12) {
|
||||
return "morning";
|
||||
@@ -321,13 +394,20 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
|
||||
setSavingActionUserId(checkingInUser.id);
|
||||
|
||||
try {
|
||||
await clockIn(backendPreference, checkingInUser.id, {
|
||||
const createdSchedule = await clockIn(backendPreference, checkingInUser.id, {
|
||||
day_of_week: getDayOfWeekFromDateTime(checkInDateTime),
|
||||
enter_time: new Date(checkInDateTime).toISOString(),
|
||||
shift: getShiftFromDateTime(checkInDateTime),
|
||||
});
|
||||
const normalizedSchedule = normalizeScheduleForUser(createdSchedule, checkingInUser);
|
||||
setSchedules((previousSchedules) => mergeSchedule(previousSchedules, normalizedSchedule));
|
||||
setCheckingInUser(null);
|
||||
await loadUsersPage();
|
||||
setSchedules((previousSchedules) =>
|
||||
previousSchedules.some((schedule) => schedule.id === normalizedSchedule.id)
|
||||
? previousSchedules
|
||||
: [...previousSchedules, normalizedSchedule],
|
||||
);
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : "Unable to check in user");
|
||||
} finally {
|
||||
@@ -340,11 +420,43 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
|
||||
setSavingActionUserId(userId);
|
||||
|
||||
try {
|
||||
await clockOut(backendPreference, userId, {
|
||||
const outTime = new Date().toISOString();
|
||||
const updatedSchedule = await clockOut(backendPreference, userId, {
|
||||
day_of_week: activeSchedule.day_of_week,
|
||||
out_time: new Date().toISOString(),
|
||||
out_time: outTime,
|
||||
});
|
||||
const user = users.find((item) => item.id === userId);
|
||||
|
||||
if (user) {
|
||||
const normalizedSchedule = normalizeScheduleForUser(
|
||||
{
|
||||
...updatedSchedule,
|
||||
out_time: updatedSchedule.out_time ?? outTime,
|
||||
},
|
||||
user,
|
||||
);
|
||||
setSchedules((previousSchedules) => mergeSchedule(previousSchedules, normalizedSchedule));
|
||||
await loadUsersPage();
|
||||
setSchedules((previousSchedules) => {
|
||||
const existingSchedule = previousSchedules.find(
|
||||
(schedule) => schedule.id === normalizedSchedule.id,
|
||||
);
|
||||
|
||||
if (!existingSchedule) {
|
||||
return [...previousSchedules, normalizedSchedule];
|
||||
}
|
||||
|
||||
if (!existingSchedule.out_time && normalizedSchedule.out_time) {
|
||||
return previousSchedules.map((schedule) =>
|
||||
schedule.id === normalizedSchedule.id ? normalizedSchedule : schedule,
|
||||
);
|
||||
}
|
||||
|
||||
return previousSchedules;
|
||||
});
|
||||
} else {
|
||||
await loadUsersPage();
|
||||
}
|
||||
} catch (saveError) {
|
||||
setError(saveError instanceof Error ? saveError.message : "Unable to check out user");
|
||||
} finally {
|
||||
@@ -433,8 +545,8 @@ export function UsersPage({ backendPreference }: UsersPageProps) {
|
||||
const company = companies.find((item) => item.id === user.company_id);
|
||||
const daysWorked = new Set(
|
||||
userSchedules
|
||||
.filter((schedule) => isScheduleInWeek(schedule, currentWeekStart, currentWeekEndExclusive))
|
||||
.map((schedule) => new Date(schedule.enter_time as string).getDay()),
|
||||
.filter((schedule) => isScheduleInWeek(schedule, today))
|
||||
.map((schedule) => getBusinessDateKey(parseBackendDateAsUTC(schedule.enter_time as string))),
|
||||
).size;
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user