cd78f72f33
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>
197 lines
8.2 KiB
TypeScript
197 lines
8.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|