287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import ShiftCard from "@/components/molecules/cards/ShiftCard";
|
|
import { DEPARTMENTS } from "@/lib/constants";
|
|
import { useShift } from "@/lib/shift-context";
|
|
import { useMemo, useState } from "react";
|
|
|
|
import type { WeeklyScheduleProps } from "./ShiftSchedule.types";
|
|
|
|
const MONTH_NAMES_EN = [
|
|
"January",
|
|
"February",
|
|
"March",
|
|
"April",
|
|
"May",
|
|
"June",
|
|
"July",
|
|
"August",
|
|
"September",
|
|
"October",
|
|
"November",
|
|
"December",
|
|
];
|
|
|
|
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
const DAY_LABELS_EN = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
|
|
function formatDateShort(d: Date): string {
|
|
return `${d.getDate().toString().padStart(2, "0")}/${(d.getMonth() + 1).toString().padStart(2, "0")}`;
|
|
}
|
|
|
|
function formatDateISO(d: Date): string {
|
|
const y = d.getFullYear();
|
|
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
|
const day = d.getDate().toString().padStart(2, "0");
|
|
return `${y}-${m}-${day}`;
|
|
}
|
|
|
|
function isToday(d: Date): boolean {
|
|
const today = new Date(Date.now());
|
|
return (
|
|
d.getDate() === today.getDate() &&
|
|
d.getMonth() === today.getMonth() &&
|
|
d.getFullYear() === today.getFullYear()
|
|
);
|
|
}
|
|
|
|
export default function WeeklySchedule({
|
|
onShiftClick,
|
|
onCreateShift,
|
|
mobileCalendarHeader = false,
|
|
}: WeeklyScheduleProps) {
|
|
const {
|
|
currentDate,
|
|
getWeekDates,
|
|
getShiftsForDate,
|
|
goToNextWeek,
|
|
goToPrevWeek,
|
|
} = useShift();
|
|
const weekDates = getWeekDates();
|
|
|
|
const [selectedDate, setSelectedDate] = useState<Date>(
|
|
weekDates[0] ?? currentDate,
|
|
);
|
|
|
|
const statusDotsByDate = useMemo(() => {
|
|
const map: Record<string, string[]> = {};
|
|
weekDates.forEach((date) => {
|
|
const dots: string[] = [];
|
|
// if (dayShifts.some((s) => s.status === "available"))
|
|
// dots.push("bg-sky-300");
|
|
// if (dayShifts.some((s) => s.status === "registered"))
|
|
// dots.push("bg-blue-600");
|
|
// if (dayShifts.some((s) => s.status === "approved_leave"))
|
|
// dots.push("bg-purple-400");
|
|
// if (dayShifts.some((s) => s.status === "absent"))
|
|
// dots.push("bg-rose-400");
|
|
map[date.toISOString()] = dots.slice(0, 3);
|
|
});
|
|
return map;
|
|
}, [weekDates, getShiftsForDate]);
|
|
|
|
const selectedDateStr = useMemo(() => {
|
|
const inWeek = weekDates.find((d) => d === selectedDate);
|
|
return inWeek ?? weekDates[0] ?? currentDate;
|
|
}, [selectedDate, weekDates, currentDate]);
|
|
|
|
const renderMobileDayView = mobileCalendarHeader && (
|
|
<div className="space-y-3">
|
|
<div className="rounded-xl border border-(--color-border-light) bg-white p-3">
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<button
|
|
title="Previous week"
|
|
type="button"
|
|
onClick={goToPrevWeek}
|
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
|
>
|
|
<i className="fa-solid fa-chevron-left text-xs"></i>
|
|
</button>
|
|
<h3 className="text-base font-bold text-(--color-primary-dark)">
|
|
{MONTH_NAMES_EN[currentDate.getMonth()]} {currentDate.getFullYear()}
|
|
</h3>
|
|
<button
|
|
title="Next week"
|
|
type="button"
|
|
onClick={goToNextWeek}
|
|
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-(--color-text-muted) transition hover:bg-gray-100"
|
|
>
|
|
<i className="fa-solid fa-chevron-right text-xs"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{weekDates.map((date, i) => {
|
|
const active = date === selectedDateStr;
|
|
const dots = statusDotsByDate[date.toISOString()] ?? [];
|
|
return (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
onClick={() => setSelectedDate(date)}
|
|
className={`flex cursor-pointer flex-col items-center gap-1 rounded-xl border-none p-1 ${
|
|
active ? "bg-(--color-primary)/10" : "bg-transparent"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`text-xs font-medium ${i >= 5 ? "text-pink-500" : "text-(--color-primary-dark)"}`}
|
|
>
|
|
{DAY_LABELS_EN[i]}
|
|
</span>
|
|
<span
|
|
className={`flex h-9 w-9 items-center justify-center rounded-full text-sm font-semibold ${
|
|
active || isToday(new Date(date))
|
|
? "bg-indigo-500 text-white"
|
|
: i >= 5
|
|
? "text-pink-500"
|
|
: "text-sky-500"
|
|
}`}
|
|
>
|
|
{new Date(date).getDate()}
|
|
</span>
|
|
<div className="flex min-h-2 items-center gap-0.5">
|
|
{dots.map((dot, idx) => (
|
|
<span
|
|
key={idx}
|
|
className={`h-1.5 w-1.5 rounded-full ${dot}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{DEPARTMENTS.map((dept) => {
|
|
const deptShifts = getShiftsForDate(new Date(selectedDateStr));
|
|
// .filter(
|
|
// (s) => s.department === dept.id,
|
|
// );
|
|
if (deptShifts.length === 0 && !onCreateShift) return null;
|
|
return (
|
|
<div
|
|
key={dept.id}
|
|
className="rounded-xl border border-(--color-border-light) bg-white p-3"
|
|
>
|
|
<div className="mb-2 flex items-center gap-2">
|
|
<i
|
|
className={`${dept.icon} text-xs text-(--color-primary)`}
|
|
></i>
|
|
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
|
{dept.name}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{deptShifts.map((shift) => (
|
|
<ShiftCard
|
|
key={shift.id}
|
|
shift={shift}
|
|
onClick={onShiftClick}
|
|
/>
|
|
))}
|
|
{onCreateShift && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onCreateShift(selectedDateStr)}
|
|
className="flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-2 text-xs text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
|
>
|
|
<i className="fa-solid fa-plus mr-1"></i>
|
|
Add shift
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
if (mobileCalendarHeader) {
|
|
return renderMobileDayView ?? null;
|
|
}
|
|
|
|
return (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-225 border-collapse">
|
|
<thead>
|
|
<tr>
|
|
<th className="w-28 border-r border-b border-(--color-border-light) bg-gray-50 px-3 py-3 text-left text-xs font-semibold text-(--color-text-muted) uppercase">
|
|
Department
|
|
</th>
|
|
{weekDates.map((date, i) => (
|
|
<th
|
|
key={i}
|
|
className={`border-r border-b border-(--color-border-light) px-2 py-3 text-center text-xs ${
|
|
isToday(new Date(date))
|
|
? "bg-(--color-primary)/10 font-bold text-(--color-primary)"
|
|
: "bg-gray-50 font-semibold text-(--color-text-muted)"
|
|
}`}
|
|
>
|
|
<span className="block uppercase">{DAY_LABELS[i]}</span>
|
|
<span className="mt-0.5 block text-[11px] font-normal">
|
|
{date.toISOString()}
|
|
</span>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{DEPARTMENTS.map((dept) => (
|
|
<tr key={dept.id}>
|
|
<td className="border-r border-b border-(--color-border-light) bg-gray-50/50 px-3 py-3 align-top">
|
|
<div className="flex items-center gap-2">
|
|
<i
|
|
className={`${dept.icon} text-xs text-(--color-primary)`}
|
|
></i>
|
|
<span className="text-xs font-semibold text-(--color-text-secondary)">
|
|
{dept.name}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
{weekDates.map((date, i) => {
|
|
const dateStr = date;
|
|
const shifts = getShiftsForDate(new Date(date));
|
|
// .filter(
|
|
// (s) => s.department === dept.id,
|
|
// );
|
|
return (
|
|
<td
|
|
key={i}
|
|
className={`border-r border-b border-(--color-border-light) p-1.5 align-top ${
|
|
isToday(new Date(date)) ? "bg-(--color-primary)/5" : ""
|
|
}`}
|
|
>
|
|
<div className="flex min-h-20 flex-col gap-1">
|
|
{shifts.map((shift) => (
|
|
<ShiftCard
|
|
key={shift.id}
|
|
shift={shift}
|
|
compact
|
|
onClick={onShiftClick}
|
|
/>
|
|
))}
|
|
{onCreateShift && (
|
|
<button
|
|
type="button"
|
|
onClick={() => onCreateShift(dateStr)}
|
|
className="mt-auto flex cursor-pointer items-center justify-center rounded-lg border border-dashed border-gray-300 bg-transparent py-1 text-[10px] text-gray-400 transition hover:border-(--color-primary) hover:text-(--color-primary)"
|
|
>
|
|
<i className="fa-solid fa-plus mr-1"></i>
|
|
Add shift
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|