Files
Nexus/apps/web/src/components/vacations/TeamCalendar.tsx
T
Hartmut b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61)

Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
2026-05-21 16:28:40 +02:00

230 lines
8.5 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 } from "@nexus/shared";
import { trpc } from "~/lib/trpc/client.js";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { VACATION_CALENDAR_COLORS } from "~/lib/status-styles.js";
const MONTH_NAMES = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
function isoDate(d: Date | string): string {
const date = typeof d === "string" ? new Date(d) : d;
return date.toISOString().slice(0, 10);
}
export function TeamCalendar() {
const now = new Date();
const [month, setMonth] = useState(now.getMonth());
const [year, setYear] = useState(now.getFullYear());
const [chapter, setChapter] = useState<string>("");
const firstDay = new Date(Date.UTC(year, month, 1));
const daysInMonth = new Date(Date.UTC(year, month + 1, 0)).getUTCDate();
const { data: resources } = trpc.resource.directory.useQuery(
{ isActive: true, limit: 500, ...(chapter ? { chapter } : {}) },
{ staleTime: 60_000 },
);
const { data: vacations } = trpc.vacation.list.useQuery(
{
startDate: firstDay,
endDate: new Date(Date.UTC(year, month + 1, 0)),
limit: 500,
},
{ staleTime: 15_000 },
);
// Fetch all chapters independently so the dropdown isn't affected by chapter filter
const { data: allChapters } = trpc.resource.chapters.useQuery(undefined, { staleTime: 60_000 });
const chapters = allChapters ?? [];
const resourceList: { id: string; displayName: string }[] = resources?.resources ?? [];
const vacationList = (vacations ?? []).filter(
(v) => v.status !== VacationStatus.CANCELLED && v.status !== VacationStatus.REJECTED,
);
// Build map: resourceId → date → vacation
const vacationMap = new Map<string, Map<string, { type: string; status: string }>>();
for (const v of vacationList) {
const start = isoDate(v.startDate);
const end = isoDate(v.endDate);
let cur = start;
while (cur <= end) {
const resourceVacs = vacationMap.get(v.resourceId) ?? new Map();
if (!resourceVacs.has(cur)) {
resourceVacs.set(cur, { type: v.type, status: v.status });
}
vacationMap.set(v.resourceId, resourceVacs);
// next day
const d = new Date(cur);
d.setUTCDate(d.getUTCDate() + 1);
cur = d.toISOString().slice(0, 10);
}
}
const days = Array.from({ length: daysInMonth }, (_, i) => i + 1);
const today = now.toISOString().slice(0, 10);
function prevMonth() {
if (month === 0) {
setMonth(11);
setYear((y) => y - 1);
} else setMonth((m) => m - 1);
}
function nextMonth() {
if (month === 11) {
setMonth(0);
setYear((y) => y + 1);
} else setMonth((m) => m + 1);
}
return (
<div className="bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-gray-100 dark:border-gray-700 flex-wrap">
<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>
<span className="text-sm font-semibold text-gray-900 dark:text-gray-100 min-w-[120px] text-center">
{MONTH_NAMES[month]} {year}
</span>
<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 className="flex-1" />
<select
value={chapter}
onChange={(e) => setChapter(e.target.value)}
className="text-sm border border-gray-200 dark:border-gray-600 rounded-lg px-2 py-1 focus:outline-none focus:ring-2 focus:ring-brand-500 dark:bg-gray-900 dark:text-gray-100"
>
<option value="">All chapters</option>
{chapters.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</div>
{/* Grid */}
<div className="overflow-x-auto">
<table className="min-w-full text-xs">
<thead>
<tr className="bg-gray-50 dark:bg-gray-900 border-b border-gray-100 dark:border-gray-700">
<th className="sticky left-0 z-10 bg-gray-50 dark:bg-gray-900 px-3 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-400 w-36 border-r border-gray-100 dark:border-gray-700">
<span className="inline-flex items-center gap-0.5">
Resource
<InfoTooltip
content="Matrix view: each row is a resource, each column is a calendar day. Colored cells indicate vacation days (color = type, faded = pending)."
width="w-72"
/>
</span>
</th>
{days.map((d) => {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const dow = new Date(dateStr).getUTCDay(); // 0=Sun, 6=Sat
const isWeekend = dow === 0 || dow === 6;
const isToday = dateStr === today;
return (
<th
key={d}
className={`px-1 py-2 text-center font-medium w-7 ${isWeekend ? "text-gray-300 dark:text-gray-600 bg-gray-50 dark:bg-gray-900" : isToday ? "text-brand-700 bg-brand-50" : "text-gray-500 dark:text-gray-400"}`}
>
{d}
</th>
);
})}
</tr>
</thead>
<tbody className="divide-y divide-gray-50 dark:divide-gray-700">
{resourceList.map((r) => {
const rMap = vacationMap.get(r.id);
return (
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="sticky left-0 z-10 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700/50 px-3 py-1.5 border-r border-gray-100 dark:border-gray-700 font-medium text-gray-800 dark:text-gray-100 truncate max-w-[9rem]">
{r.displayName}
</td>
{days.map((d) => {
const dateStr = `${year}-${String(month + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
const vac = rMap?.get(dateStr);
const dow = new Date(dateStr).getUTCDay();
const isWeekend = dow === 0 || dow === 6;
const isToday = dateStr === today;
let cellClass = "w-7 h-7";
if (vac) {
const color = VACATION_CALENDAR_COLORS[vac.type] ?? "bg-gray-400";
const opacity = vac.status === "PENDING" ? "opacity-50" : "";
cellClass += ` ${color} ${opacity}`;
} else if (isWeekend) {
cellClass += " bg-gray-50 dark:bg-gray-900";
} else if (isToday) {
cellClass += " bg-brand-50";
}
return (
<td key={d} className="px-0.5 py-0.5">
<div
className={cellClass + " rounded-sm"}
title={vac ? `${r.displayName}: ${vac.type} (${vac.status})` : undefined}
/>
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
{resourceList.length === 0 && (
<div className="p-8 text-center text-sm text-gray-400">No resources found.</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(VACATION_CALENDAR_COLORS).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>
))}
<span className="flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-500">
<span className="opacity-50 bg-brand-500 w-3 h-3 rounded-sm inline-block" />
Pending
</span>
</div>
</div>
);
}