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>
176 lines
6.3 KiB
TypeScript
176 lines
6.3 KiB
TypeScript
"use client";
|
||
|
||
import { useState } from "react";
|
||
import { VacationStatus, VacationType } from "@capakraken/shared";
|
||
import { VACATION_CALENDAR_COLORS } from "~/lib/status-styles.js";
|
||
|
||
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 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 = VACATION_CALENDAR_COLORS[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(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>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|