Files
CapaKraken/apps/web/src/components/vacations/TeamCalendar.tsx
T
Hartmut cd78f72f33 chore: full technical rename planarchy → capakraken
Complete rename of all technical identifiers across the codebase:

Package names (11 packages):
- @planarchy/* → @capakraken/* in all package.json, tsconfig, imports

Import statements: 277 files, 548 occurrences replaced

Database & Docker:
- PostgreSQL user/db: planarchy → capakraken
- Docker volumes: planarchy_pgdata → capakraken_pgdata
- Connection strings updated in docker-compose, .env, CI

CI/CD:
- GitHub Actions workflow: all filter commands updated
- Test database credentials updated

Infrastructure:
- Redis channel: planarchy:sse → capakraken:sse
- Logger service name: planarchy-api → capakraken-api
- Anonymization seed updated
- Start/stop/restart scripts updated

Test data:
- Seed emails: @planarchy.dev → @capakraken.dev
- E2E test credentials: all 11 spec files updated
- Email defaults: @planarchy.app → @capakraken.app
- localStorage keys: planarchy_* → capakraken_*

Documentation: 30+ .md files updated

Verification:
- pnpm install: workspace resolution works
- TypeScript: only pre-existing TS2589 (no new errors)
- Engine: 310/310 tests pass
- Staffing: 37/37 tests pass

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-03-27 13:18:09 +01:00

197 lines
8.2 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 "@capakraken/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.list.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>
);
}