Files
CapaKraken/apps/web/src/components/vacations/VacationCalendar.tsx
T

182 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState } from "react";
import { VacationStatus, VacationType } from "@planarchy/shared";
interface VacationEntry {
id: string;
startDate: Date | string;
endDate: Date | string;
type: string;
status: string;
resource?: { displayName: string; eid: string } | null;
}
interface VacationCalendarProps {
vacations: VacationEntry[];
year?: number;
initialMonth?: number; // 0-indexed
}
const TYPE_COLOR: Record<string, string> = {
ANNUAL: "bg-brand-500",
SICK: "bg-red-400",
PUBLIC_HOLIDAY: "bg-emerald-400",
OTHER: "bg-purple-400",
};
const STATUS_OPACITY: Record<string, string> = {
APPROVED: "opacity-100",
PENDING: "opacity-60",
REJECTED: "opacity-30",
CANCELLED: "opacity-20",
};
const DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const MONTH_NAMES = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
function isoDate(d: Date | string): string {
const date = typeof d === "string" ? new Date(d) : d;
return date.toISOString().slice(0, 10);
}
function addDays(dateStr: string, n: number): string {
const d = new Date(dateStr);
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
function getDatesInRange(start: Date | string, end: Date | string): Set<string> {
const dates = new Set<string>();
let cur = isoDate(start);
const last = isoDate(end);
while (cur <= last) {
dates.add(cur);
cur = addDays(cur, 1);
}
return dates;
}
export function VacationCalendar({ vacations, year = new Date().getFullYear(), initialMonth = new Date().getMonth() }: VacationCalendarProps) {
const [month, setMonth] = useState(initialMonth);
const [currentYear, setCurrentYear] = useState(year);
function prevMonth() {
if (month === 0) { setMonth(11); setCurrentYear(y => y - 1); }
else setMonth(m => m - 1);
}
function nextMonth() {
if (month === 11) { setMonth(0); setCurrentYear(y => y + 1); }
else setMonth(m => m + 1);
}
// Build a set of date → vacation entries for fast lookup
const dateMap = new Map<string, VacationEntry[]>();
for (const v of vacations) {
if ([VacationStatus.CANCELLED, VacationStatus.REJECTED].includes(v.status as VacationStatus)) continue;
const dates = getDatesInRange(v.startDate, v.endDate);
for (const d of dates) {
const existing = dateMap.get(d) ?? [];
existing.push(v);
dateMap.set(d, existing);
}
}
// Build calendar grid
const firstDay = new Date(Date.UTC(currentYear, month, 1));
const daysInMonth = new Date(Date.UTC(currentYear, month + 1, 0)).getUTCDate();
// ISO weekday: Mon=1, Sun=7 → index 0-6
const startOffset = (firstDay.getUTCDay() + 6) % 7; // Mon first
const cells: (number | null)[] = [
...Array<null>(startOffset).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
];
// Pad to complete last row
while (cells.length % 7 !== 0) cells.push(null);
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-700">
<button type="button" onClick={prevMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
</button>
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100">
{MONTH_NAMES[month]} {currentYear}
</h3>
<button type="button" onClick={nextMonth} className="p-1 hover:bg-gray-100 dark:hover:bg-gray-700 rounded text-gray-500 dark:text-gray-400">
</button>
</div>
{/* Day names */}
<div className="grid grid-cols-7 border-b border-gray-100 dark:border-gray-700">
{DAYS.map((d) => (
<div key={d} className="px-2 py-1.5 text-center text-xs font-medium text-gray-400 dark:text-gray-500">
{d}
</div>
))}
</div>
{/* Days grid */}
<div className="grid grid-cols-7">
{cells.map((day, idx) => {
if (!day) {
return <div key={`empty-${idx}`} className="p-1 min-h-[60px] border-b border-r border-gray-50 dark:border-gray-700 bg-gray-50/30 dark:bg-gray-900/30" />;
}
const dateStr = `${currentYear}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
const dayVacations = dateMap.get(dateStr) ?? [];
const today = new Date().toISOString().slice(0, 10);
const isToday = dateStr === today;
return (
<div
key={dateStr}
className={`p-1 min-h-[60px] border-b border-r border-gray-50 dark:border-gray-700 ${isToday ? "bg-brand-50" : ""}`}
>
<span className={`text-xs font-medium block mb-1 ${isToday ? "text-brand-700" : "text-gray-500 dark:text-gray-400"}`}>
{day}
</span>
<div className="space-y-0.5">
{dayVacations.slice(0, 3).map((v) => {
const colorClass = TYPE_COLOR[v.type] ?? "bg-gray-400";
const opacityClass = STATUS_OPACITY[v.status] ?? "opacity-100";
const name = v.resource?.displayName ?? "—";
return (
<div
key={v.id + dateStr}
className={`${colorClass} ${opacityClass} text-white text-xs px-1 rounded truncate`}
title={`${name}${v.type} (${v.status})`}
>
{name.split(" ")[0]}
</div>
);
})}
{dayVacations.length > 3 && (
<div className="text-xs text-gray-400 dark:text-gray-500 pl-1">+{dayVacations.length - 3}</div>
)}
</div>
</div>
);
})}
</div>
{/* Legend */}
<div className="px-4 py-2 border-t border-gray-100 dark:border-gray-700 flex gap-4 flex-wrap">
{Object.entries(TYPE_COLOR).map(([type, color]) => (
<span key={type} className="flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className={`${color} w-3 h-3 rounded-sm inline-block`} />
{type.replace("_", " ")}
</span>
))}
</div>
</div>
);
}