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>
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useRef, useState } from "react";
|
||
import { useFocusTrap } from "~/hooks/useFocusTrap.js";
|
||
import { VacationType } from "@capakraken/shared";
|
||
import { trpc } from "~/lib/trpc/client.js";
|
||
import { DateInput } from "~/components/ui/DateInput.js";
|
||
import { useDebounce } from "~/hooks/useDebounce.js";
|
||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||
import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js";
|
||
|
||
const VACATION_TYPES = Object.values(VacationType);
|
||
|
||
interface VacationModalProps {
|
||
resourceId?: string;
|
||
onClose: () => void;
|
||
onSuccess: () => void;
|
||
}
|
||
|
||
function toDateInputValue(date: Date | string | null | undefined): string {
|
||
if (!date) return "";
|
||
const d = typeof date === "string" ? new Date(date) : date;
|
||
const y = d.getFullYear();
|
||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||
const day = String(d.getDate()).padStart(2, "0");
|
||
return `${y}-${m}-${day}`;
|
||
}
|
||
|
||
export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) {
|
||
const [resourceId, setResourceId] = useState(initialResourceId ?? "");
|
||
const [type, setType] = useState<VacationType>(VacationType.ANNUAL);
|
||
const [startDate, setStartDate] = useState("");
|
||
const [endDate, setEndDate] = useState("");
|
||
const [note, setNote] = useState("");
|
||
const [isHalfDay, setIsHalfDay] = useState(false);
|
||
const [halfDayPart, setHalfDayPart] = useState<"MORNING" | "AFTERNOON">("MORNING");
|
||
const [serverError, setServerError] = useState<string | null>(null);
|
||
|
||
const panelRef = useRef<HTMLDivElement>(null);
|
||
useFocusTrap(panelRef, true);
|
||
|
||
const debouncedStart = useDebounce(startDate, 400);
|
||
const debouncedEnd = useDebounce(endDate, 400);
|
||
|
||
const { data: resources } = trpc.resource.list.useQuery(
|
||
{ isActive: true, limit: 500 },
|
||
{ staleTime: 60_000 },
|
||
);
|
||
|
||
// Team overlap: other resources in same chapter off in this period
|
||
const { data: teamOverlap } = trpc.vacation.getTeamOverlap.useQuery(
|
||
{
|
||
resourceId: resourceId,
|
||
startDate: debouncedStart ? new Date(debouncedStart) : new Date(),
|
||
endDate: debouncedEnd ? new Date(debouncedEnd) : new Date(),
|
||
},
|
||
{
|
||
enabled: !!resourceId && !!debouncedStart && !!debouncedEnd,
|
||
staleTime: 10_000,
|
||
},
|
||
);
|
||
|
||
// Show existing vacations for this resource in the selected period
|
||
const { data: existingVacations } = trpc.vacation.list.useQuery(
|
||
{
|
||
resourceId: resourceId || undefined,
|
||
startDate: startDate ? new Date(startDate) : undefined,
|
||
endDate: endDate ? new Date(endDate) : undefined,
|
||
},
|
||
{ enabled: !!resourceId && !!startDate && !!endDate, staleTime: 10_000 },
|
||
);
|
||
|
||
const utils = trpc.useUtils();
|
||
|
||
const createMutation = trpc.vacation.create.useMutation({
|
||
onSuccess: async () => {
|
||
await utils.vacation.list.invalidate();
|
||
onSuccess();
|
||
},
|
||
onError: (err) => setServerError(err.message),
|
||
});
|
||
|
||
const isPending = createMutation.isPending;
|
||
|
||
function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
setServerError(null);
|
||
|
||
if (!resourceId || !startDate || !endDate) {
|
||
setServerError("Please fill in all required fields.");
|
||
return;
|
||
}
|
||
|
||
const start = new Date(startDate);
|
||
const end = new Date(endDate);
|
||
if (end < start) {
|
||
setServerError("End date must be on or after start date.");
|
||
return;
|
||
}
|
||
|
||
createMutation.mutate({
|
||
resourceId,
|
||
type,
|
||
startDate: start,
|
||
endDate: end,
|
||
note: note || undefined,
|
||
isHalfDay,
|
||
...(isHalfDay ? { halfDayPart } : {}),
|
||
});
|
||
}
|
||
|
||
const inputClass =
|
||
"w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-500 text-sm dark:bg-gray-900 dark:text-gray-100";
|
||
const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1";
|
||
const resourceList: { id: string; displayName: string; eid: string }[] = resources?.resources ?? [];
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 bg-black/50 z-50 flex items-start justify-center overflow-y-auto py-8"
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) onClose();
|
||
}}
|
||
>
|
||
<div
|
||
ref={panelRef}
|
||
className="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4"
|
||
onKeyDown={(e) => { if (e.key === "Escape") onClose(); }}
|
||
>
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Request Vacation</h2>
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors text-xl leading-none"
|
||
aria-label="Close"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="px-6 py-5 space-y-4">
|
||
{/* Resource */}
|
||
{!initialResourceId && (
|
||
<div>
|
||
<label htmlFor="vac-resource" className={labelClass}>
|
||
Resource <span className="text-red-500">*</span><InfoTooltip content="The employee this vacation request is for." />
|
||
</label>
|
||
<select
|
||
id="vac-resource"
|
||
value={resourceId}
|
||
onChange={(e) => setResourceId(e.target.value)}
|
||
className={inputClass}
|
||
required
|
||
>
|
||
<option value="">Select a resource…</option>
|
||
{resourceList.map((r) => (
|
||
<option key={r.id} value={r.id}>
|
||
{r.displayName} ({r.eid})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
)}
|
||
|
||
{/* Type */}
|
||
<div>
|
||
<label htmlFor="vac-type" className={labelClass}>
|
||
Type <span className="text-red-500">*</span><InfoTooltip content="ANNUAL = paid leave (deducted from entitlement) · SICK = sick leave · PUBLIC_HOLIDAY = national/regional holiday · OTHER = special leave." />
|
||
</label>
|
||
<select
|
||
id="vac-type"
|
||
value={type}
|
||
onChange={(e) => setType(e.target.value as VacationType)}
|
||
className={inputClass}
|
||
>
|
||
{VACATION_TYPES.map((t) => (
|
||
<option key={t} value={t}>
|
||
{VACATION_TYPE_LABELS[t]}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
{/* Dates */}
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label htmlFor="vac-start" className={labelClass}>
|
||
Start Date <span className="text-red-500">*</span><InfoTooltip content="First day of leave (inclusive)." />
|
||
</label>
|
||
<DateInput
|
||
id="vac-start"
|
||
value={startDate}
|
||
onChange={setStartDate}
|
||
className={inputClass}
|
||
required
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label htmlFor="vac-end" className={labelClass}>
|
||
End Date <span className="text-red-500">*</span><InfoTooltip content="Last day of leave (inclusive)." />
|
||
</label>
|
||
<DateInput
|
||
id="vac-end"
|
||
value={endDate}
|
||
onChange={setEndDate}
|
||
min={startDate}
|
||
className={inputClass}
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Half-day toggle */}
|
||
<div className="flex items-center gap-4">
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={isHalfDay}
|
||
onChange={(e) => setIsHalfDay(e.target.checked)}
|
||
className="rounded border-gray-300 dark:border-gray-600 text-brand-600 focus:ring-brand-500"
|
||
/>
|
||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">Half day</span><InfoTooltip content="Request only half a day off (morning or afternoon). Counts as 0.5 days against entitlement." />
|
||
</label>
|
||
{isHalfDay && (
|
||
<div className="flex gap-3">
|
||
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-gray-600 dark:text-gray-400">
|
||
<input
|
||
type="radio"
|
||
value="MORNING"
|
||
checked={halfDayPart === "MORNING"}
|
||
onChange={() => setHalfDayPart("MORNING")}
|
||
className="text-brand-600 focus:ring-brand-500"
|
||
/>
|
||
Morning
|
||
</label>
|
||
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-gray-600 dark:text-gray-400">
|
||
<input
|
||
type="radio"
|
||
value="AFTERNOON"
|
||
checked={halfDayPart === "AFTERNOON"}
|
||
onChange={() => setHalfDayPart("AFTERNOON")}
|
||
className="text-brand-600 focus:ring-brand-500"
|
||
/>
|
||
Afternoon
|
||
</label>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Conflict warning */}
|
||
{existingVacations && existingVacations.length > 0 && (
|
||
<div className="rounded-lg bg-amber-50 border border-amber-200 px-4 py-3 text-sm text-amber-700">
|
||
<strong>Existing vacation in this period:</strong>
|
||
<ul className="mt-1 space-y-0.5">
|
||
{existingVacations.map((v) => (
|
||
<li key={v.id}>
|
||
{VACATION_TYPE_LABELS[v.type as VacationType]} —{" "}
|
||
{new Date(v.startDate).toLocaleDateString("en-GB")} to{" "}
|
||
{new Date(v.endDate).toLocaleDateString("en-GB")} ({v.status})
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{/* Team overlap warning */}
|
||
{teamOverlap && teamOverlap.length > 0 && (
|
||
<div className="rounded-lg bg-blue-50 border border-blue-200 px-4 py-3 text-sm text-blue-700">
|
||
<strong>Team members off in this period ({teamOverlap.length}):</strong>
|
||
<ul className="mt-1 space-y-0.5">
|
||
{teamOverlap.map((v) => {
|
||
const r = v.resource as { displayName: string; eid: string } | null;
|
||
return (
|
||
<li key={v.id}>
|
||
{r?.displayName ?? "—"}{" "}
|
||
<span className="text-blue-500">({new Date(v.startDate).toLocaleDateString("en-GB")} – {new Date(v.endDate).toLocaleDateString("en-GB")})</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
|
||
{/* Note */}
|
||
<div>
|
||
<label htmlFor="vac-note" className={labelClass}>
|
||
Note (optional)
|
||
</label>
|
||
<textarea
|
||
id="vac-note"
|
||
value={note}
|
||
onChange={(e) => setNote(e.target.value)}
|
||
rows={2}
|
||
maxLength={500}
|
||
className={`${inputClass} resize-none`}
|
||
placeholder="Optional reason or description…"
|
||
/>
|
||
</div>
|
||
|
||
{/* Server error */}
|
||
{serverError && (
|
||
<div className="rounded-lg bg-red-50 border border-red-200 px-4 py-3 text-sm text-red-700">
|
||
{serverError}
|
||
</div>
|
||
)}
|
||
|
||
{/* Footer */}
|
||
<div className="flex items-center justify-end gap-3 pt-2">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
disabled={isPending}
|
||
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
|
||
>
|
||
Cancel
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
disabled={isPending}
|
||
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
|
||
>
|
||
{isPending ? "Submitting…" : "Submit Request"}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|