Files
Nexus/apps/web/src/components/vacations/VacationModal.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

331 lines
12 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 { 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"
>
&times;
</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>
);
}