feat(planning): ship holiday-aware planning and assistant upgrades
This commit is contained in:
@@ -0,0 +1,865 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
|
||||
type ScopeType = "COUNTRY" | "STATE" | "CITY";
|
||||
|
||||
type CalendarRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
scopeType: ScopeType;
|
||||
stateCode: string | null;
|
||||
metroCityId: string | null;
|
||||
isActive: boolean;
|
||||
priority: number;
|
||||
country: { id: string; code: string; name: string };
|
||||
metroCity: { id: string; name: string } | null;
|
||||
entries: Array<{
|
||||
id: string;
|
||||
date: string | Date;
|
||||
name: string;
|
||||
isRecurringAnnual: boolean;
|
||||
source: string | null;
|
||||
}>;
|
||||
_count?: { entries: number };
|
||||
};
|
||||
|
||||
type CountryRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
metroCities: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
const SCOPE_LABELS: Record<ScopeType, string> = {
|
||||
COUNTRY: "Land",
|
||||
STATE: "Bundesland/Region",
|
||||
CITY: "Stadt",
|
||||
};
|
||||
|
||||
function formatDate(value: string | Date): string {
|
||||
return new Date(value).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function HolidayCalendarEditor() {
|
||||
const utils = trpc.useUtils();
|
||||
const [selectedCalendarId, setSelectedCalendarId] = useState<string | null>(null);
|
||||
const [scopeType, setScopeType] = useState<ScopeType>("COUNTRY");
|
||||
const [countryId, setCountryId] = useState("");
|
||||
const [stateCode, setStateCode] = useState("");
|
||||
const [metroCityId, setMetroCityId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [priority, setPriority] = useState(0);
|
||||
const [entryDate, setEntryDate] = useState("");
|
||||
const [entryName, setEntryName] = useState("");
|
||||
const [entryRecurring, setEntryRecurring] = useState(false);
|
||||
const [entrySource, setEntrySource] = useState("");
|
||||
const [previewYear, setPreviewYear] = useState(new Date().getFullYear());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [calendarDraft, setCalendarDraft] = useState({
|
||||
name: "",
|
||||
priority: 0,
|
||||
stateCode: "",
|
||||
metroCityId: "",
|
||||
isActive: true,
|
||||
});
|
||||
const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
|
||||
const [entryDraft, setEntryDraft] = useState({
|
||||
date: "",
|
||||
name: "",
|
||||
isRecurringAnnual: false,
|
||||
source: "",
|
||||
});
|
||||
|
||||
const { data: countries } = trpc.country.list.useQuery();
|
||||
const { data: calendars } = trpc.holidayCalendar.listCalendars.useQuery({ includeInactive: true });
|
||||
|
||||
const selectedCalendar = ((calendars ?? []) as unknown as CalendarRow[]).find((calendar) => calendar.id === selectedCalendarId) ?? null;
|
||||
|
||||
const selectedCountry = useMemo(() => {
|
||||
const rows = (countries ?? []) as unknown as CountryRow[];
|
||||
return rows.find((country) => country.id === countryId) ?? null;
|
||||
}, [countries, countryId]);
|
||||
|
||||
const selectedCalendarCountry = useMemo(() => {
|
||||
const rows = (countries ?? []) as unknown as CountryRow[];
|
||||
return rows.find((country) => country.id === selectedCalendar?.country.id) ?? null;
|
||||
}, [countries, selectedCalendar]);
|
||||
|
||||
const previewQuery = trpc.holidayCalendar.previewResolvedHolidays.useQuery(
|
||||
{
|
||||
countryId: selectedCalendar?.country.id ?? countryId,
|
||||
year: previewYear,
|
||||
...(selectedCalendar?.stateCode ? { stateCode: selectedCalendar.stateCode } : {}),
|
||||
...(selectedCalendar?.metroCityId ? { metroCityId: selectedCalendar.metroCityId } : {}),
|
||||
},
|
||||
{
|
||||
enabled: Boolean(selectedCalendar?.country.id ?? countryId),
|
||||
staleTime: 30_000,
|
||||
},
|
||||
);
|
||||
|
||||
const invalidate = async () => {
|
||||
await Promise.all([
|
||||
utils.holidayCalendar.listCalendars.invalidate(),
|
||||
utils.holidayCalendar.getCalendarById.invalidate(),
|
||||
utils.holidayCalendar.previewResolvedHolidays.invalidate(),
|
||||
]);
|
||||
};
|
||||
|
||||
const createCalendar = trpc.holidayCalendar.createCalendar.useMutation({
|
||||
onSuccess: async (calendar) => {
|
||||
await invalidate();
|
||||
setSelectedCalendarId(calendar.id);
|
||||
setName("");
|
||||
setStateCode("");
|
||||
setMetroCityId("");
|
||||
setPriority(0);
|
||||
setError(null);
|
||||
},
|
||||
onError: (mutationError) => setError(mutationError.message),
|
||||
});
|
||||
|
||||
const updateCalendar = trpc.holidayCalendar.updateCalendar.useMutation({
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
setError(null);
|
||||
},
|
||||
onError: (mutationError) => setError(mutationError.message),
|
||||
});
|
||||
|
||||
const deleteCalendar = trpc.holidayCalendar.deleteCalendar.useMutation({
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
setSelectedCalendarId(null);
|
||||
setError(null);
|
||||
},
|
||||
onError: (mutationError) => setError(mutationError.message),
|
||||
});
|
||||
|
||||
const createEntry = trpc.holidayCalendar.createEntry.useMutation({
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
setEntryDate("");
|
||||
setEntryName("");
|
||||
setEntryRecurring(false);
|
||||
setEntrySource("");
|
||||
setError(null);
|
||||
},
|
||||
onError: (mutationError) => setError(mutationError.message),
|
||||
});
|
||||
|
||||
const updateEntry = trpc.holidayCalendar.updateEntry.useMutation({
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
setEditingEntryId(null);
|
||||
setError(null);
|
||||
},
|
||||
onError: (mutationError) => setError(mutationError.message),
|
||||
});
|
||||
|
||||
const deleteEntry = trpc.holidayCalendar.deleteEntry.useMutation({
|
||||
onSuccess: async () => {
|
||||
await invalidate();
|
||||
setError(null);
|
||||
},
|
||||
onError: (mutationError) => setError(mutationError.message),
|
||||
});
|
||||
|
||||
const countryRows = (countries ?? []) as unknown as CountryRow[];
|
||||
const calendarRows = (calendars ?? []) as unknown as CalendarRow[];
|
||||
const isCreateScopeValid = scopeType === "COUNTRY"
|
||||
? Boolean(countryId && name.trim())
|
||||
: scopeType === "STATE"
|
||||
? Boolean(countryId && name.trim() && stateCode.trim())
|
||||
: Boolean(countryId && name.trim() && metroCityId);
|
||||
const isCalendarDirty = selectedCalendar !== null && (
|
||||
calendarDraft.name !== selectedCalendar.name
|
||||
|| calendarDraft.priority !== selectedCalendar.priority
|
||||
|| calendarDraft.isActive !== selectedCalendar.isActive
|
||||
|| calendarDraft.stateCode !== (selectedCalendar.stateCode ?? "")
|
||||
|| calendarDraft.metroCityId !== (selectedCalendar.metroCityId ?? "")
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedCalendar) {
|
||||
setCalendarDraft({
|
||||
name: "",
|
||||
priority: 0,
|
||||
stateCode: "",
|
||||
metroCityId: "",
|
||||
isActive: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setCalendarDraft({
|
||||
name: selectedCalendar.name,
|
||||
priority: selectedCalendar.priority,
|
||||
stateCode: selectedCalendar.stateCode ?? "",
|
||||
metroCityId: selectedCalendar.metroCityId ?? "",
|
||||
isActive: selectedCalendar.isActive,
|
||||
});
|
||||
setEditingEntryId(null);
|
||||
}, [selectedCalendar]);
|
||||
|
||||
function handleCreateCalendar(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!isCreateScopeValid) {
|
||||
setError("Bitte alle Pflichtfelder fuer den gewaehlten Scope ausfuellen.");
|
||||
return;
|
||||
}
|
||||
createCalendar.mutate({
|
||||
name: name.trim(),
|
||||
scopeType,
|
||||
countryId,
|
||||
...(scopeType === "STATE" && stateCode.trim() ? { stateCode: stateCode.trim().toUpperCase() } : {}),
|
||||
...(scopeType === "CITY" && metroCityId ? { metroCityId } : {}),
|
||||
priority,
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
function handleAddEntry(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selectedCalendarId) return;
|
||||
if (!entryDate || !entryName.trim()) {
|
||||
setError("Datum und Feiertagsname sind erforderlich.");
|
||||
return;
|
||||
}
|
||||
createEntry.mutate({
|
||||
holidayCalendarId: selectedCalendarId,
|
||||
date: new Date(`${entryDate}T00:00:00.000Z`),
|
||||
name: entryName.trim(),
|
||||
isRecurringAnnual: entryRecurring,
|
||||
...(entrySource.trim() ? { source: entrySource.trim() } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function resetCalendarDraft() {
|
||||
if (!selectedCalendar) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCalendarDraft({
|
||||
name: selectedCalendar.name,
|
||||
priority: selectedCalendar.priority,
|
||||
stateCode: selectedCalendar.stateCode ?? "",
|
||||
metroCityId: selectedCalendar.metroCityId ?? "",
|
||||
isActive: selectedCalendar.isActive,
|
||||
});
|
||||
}
|
||||
|
||||
function handleUpdateCalendar(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!selectedCalendar) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
const normalizedStateCode = calendarDraft.stateCode.trim().toUpperCase();
|
||||
if (selectedCalendar.scopeType === "STATE" && !normalizedStateCode) {
|
||||
setError("State-Kalender benoetigen einen Regionscode.");
|
||||
return;
|
||||
}
|
||||
if (selectedCalendar.scopeType === "CITY" && !calendarDraft.metroCityId) {
|
||||
setError("City-Kalender benoetigen eine Stadtzuordnung.");
|
||||
return;
|
||||
}
|
||||
|
||||
updateCalendar.mutate({
|
||||
id: selectedCalendar.id,
|
||||
data: {
|
||||
name: calendarDraft.name.trim(),
|
||||
priority: calendarDraft.priority,
|
||||
isActive: calendarDraft.isActive,
|
||||
...(selectedCalendar.scopeType === "STATE" ? { stateCode: normalizedStateCode } : {}),
|
||||
...(selectedCalendar.scopeType === "CITY" ? { metroCityId: calendarDraft.metroCityId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function startEditingEntry(entry: CalendarRow["entries"][number]) {
|
||||
setEditingEntryId(entry.id);
|
||||
setEntryDraft({
|
||||
date: formatDate(entry.date),
|
||||
name: entry.name,
|
||||
isRecurringAnnual: entry.isRecurringAnnual,
|
||||
source: entry.source ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
function handleUpdateEntry(entryId: string) {
|
||||
if (!entryDraft.date || !entryDraft.name.trim()) {
|
||||
setError("Ein Feiertagseintrag braucht Datum und Name.");
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
updateEntry.mutate({
|
||||
id: entryId,
|
||||
data: {
|
||||
date: new Date(`${entryDraft.date}T00:00:00.000Z`),
|
||||
name: entryDraft.name.trim(),
|
||||
isRecurringAnnual: entryDraft.isRecurringAnnual,
|
||||
source: entryDraft.source.trim() || null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleDeleteCalendar(calendar: CalendarRow) {
|
||||
if (deleteCalendar.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = globalThis.confirm(
|
||||
`Feiertagskalender "${calendar.name}" wirklich loeschen? Alle Eintraege gehen dabei verloren.`,
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
deleteCalendar.mutate({ id: calendar.id });
|
||||
}
|
||||
|
||||
function handleDeleteEntry(entry: CalendarRow["entries"][number]) {
|
||||
if (deleteEntry.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = globalThis.confirm(
|
||||
`Feiertag "${entry.name}" am ${formatDate(entry.date)} wirklich entfernen?`,
|
||||
);
|
||||
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
deleteEntry.mutate({ id: entry.id });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="holiday-calendar-editor"
|
||||
className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5 space-y-5"
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-800 dark:text-gray-100">Holiday Calendar Editor</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Pflege Feiertagskalender pro Land, Bundesland/Region oder Stadt. Die Vorschau zeigt den effektiv aufgeloesten Kalender fuer den gewaelten Scope.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-[1.1fr_1.4fr]">
|
||||
<form onSubmit={handleCreateCalendar} className="space-y-4 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Name</span>
|
||||
<input
|
||||
data-testid="holiday-calendar-name-input"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="Bayern Feiertage"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Scope</span>
|
||||
<select
|
||||
data-testid="holiday-calendar-scope-select"
|
||||
value={scopeType}
|
||||
onChange={(e) => setScopeType(e.target.value as ScopeType)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{Object.entries(SCOPE_LABELS).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Land</span>
|
||||
<select
|
||||
data-testid="holiday-calendar-country-select"
|
||||
value={countryId}
|
||||
onChange={(e) => {
|
||||
setCountryId(e.target.value);
|
||||
setMetroCityId("");
|
||||
}}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
required
|
||||
>
|
||||
<option value="">Land waehlen</option>
|
||||
{countryRows.map((country) => (
|
||||
<option key={country.id} value={country.id}>{country.name} ({country.code})</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Prioritaet</span>
|
||||
<input
|
||||
data-testid="holiday-calendar-priority-input"
|
||||
type="number"
|
||||
value={priority}
|
||||
onChange={(e) => setPriority(parseInt(e.target.value, 10) || 0)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{scopeType === "STATE" && (
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Bundesland/Region Code</span>
|
||||
<input
|
||||
data-testid="holiday-calendar-state-input"
|
||||
value={stateCode}
|
||||
onChange={(e) => setStateCode(e.target.value.toUpperCase())}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="BY"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{scopeType === "CITY" && (
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Stadt</span>
|
||||
<select
|
||||
data-testid="holiday-calendar-city-select"
|
||||
value={metroCityId}
|
||||
onChange={(e) => setMetroCityId(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
required
|
||||
>
|
||||
<option value="">Stadt waehlen</option>
|
||||
{(selectedCountry?.metroCities ?? []).map((city) => (
|
||||
<option key={city.id} value={city.id}>{city.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<button
|
||||
data-testid="holiday-calendar-create-button"
|
||||
type="submit"
|
||||
disabled={createCalendar.isPending || !isCreateScopeValid}
|
||||
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{createCalendar.isPending ? "Speichert..." : "Kalender anlegen"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/60">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Kalender</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Scope</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Zuordnung</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500">Eintraege</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{calendarRows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-3 py-6 text-center text-sm text-gray-400">Noch keine Feiertagskalender vorhanden.</td>
|
||||
</tr>
|
||||
)}
|
||||
{calendarRows.map((calendar) => (
|
||||
<tr
|
||||
key={calendar.id}
|
||||
data-testid={`holiday-calendar-row-${calendar.id}`}
|
||||
className={`cursor-pointer border-t border-gray-200 dark:border-gray-700 ${selectedCalendarId === calendar.id ? "bg-brand-50 dark:bg-brand-950/20" : "hover:bg-gray-50 dark:hover:bg-gray-900/40"}`}
|
||||
onClick={() => setSelectedCalendarId(calendar.id)}
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">{calendar.name}</div>
|
||||
<div className="text-xs text-gray-500">{calendar.country.name}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{SCOPE_LABELS[calendar.scopeType]}</td>
|
||||
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">
|
||||
{calendar.scopeType === "COUNTRY" && calendar.country.code}
|
||||
{calendar.scopeType === "STATE" && calendar.stateCode}
|
||||
{calendar.scopeType === "CITY" && calendar.metroCity?.name}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-gray-600 dark:text-gray-400">{calendar._count?.entries ?? calendar.entries.length}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{selectedCalendar && (
|
||||
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
||||
<div className="space-y-4 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">{selectedCalendar.name}</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{SCOPE_LABELS[selectedCalendar.scopeType]} · {selectedCalendar.country.name}
|
||||
{selectedCalendar.stateCode ? ` · ${selectedCalendar.stateCode}` : ""}
|
||||
{selectedCalendar.metroCity?.name ? ` · ${selectedCalendar.metroCity.name}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
data-testid="holiday-calendar-toggle-active-button"
|
||||
type="button"
|
||||
onClick={() => updateCalendar.mutate({
|
||||
id: selectedCalendar.id,
|
||||
data: { isActive: !selectedCalendar.isActive },
|
||||
})}
|
||||
className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-900"
|
||||
>
|
||||
{selectedCalendar.isActive ? "Deaktivieren" : "Aktivieren"}
|
||||
</button>
|
||||
<button
|
||||
data-testid="holiday-calendar-delete-button"
|
||||
type="button"
|
||||
onClick={() => handleDeleteCalendar(selectedCalendar)}
|
||||
disabled={deleteCalendar.isPending}
|
||||
className="rounded-lg border border-red-300 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-700 dark:text-red-300 dark:hover:bg-red-950/30"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleUpdateCalendar} className="grid gap-3 md:grid-cols-2 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Kalendername</span>
|
||||
<input
|
||||
data-testid="holiday-calendar-draft-name-input"
|
||||
value={calendarDraft.name}
|
||||
onChange={(e) => setCalendarDraft((current) => ({ ...current, name: e.target.value }))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Prioritaet</span>
|
||||
<input
|
||||
data-testid="holiday-calendar-draft-priority-input"
|
||||
type="number"
|
||||
value={calendarDraft.priority}
|
||||
onChange={(e) => setCalendarDraft((current) => ({
|
||||
...current,
|
||||
priority: parseInt(e.target.value, 10) || 0,
|
||||
}))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{selectedCalendar.scopeType === "STATE" && (
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Bundesland/Region Code</span>
|
||||
<input
|
||||
data-testid="holiday-calendar-draft-state-input"
|
||||
value={calendarDraft.stateCode}
|
||||
onChange={(e) => setCalendarDraft((current) => ({
|
||||
...current,
|
||||
stateCode: e.target.value.toUpperCase(),
|
||||
}))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="BY"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
{selectedCalendar.scopeType === "CITY" && (
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Stadt</span>
|
||||
<select
|
||||
data-testid="holiday-calendar-draft-city-select"
|
||||
value={calendarDraft.metroCityId}
|
||||
onChange={(e) => setCalendarDraft((current) => ({
|
||||
...current,
|
||||
metroCityId: e.target.value,
|
||||
}))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
required
|
||||
>
|
||||
<option value="">Stadt waehlen</option>
|
||||
{(selectedCalendarCountry?.metroCities ?? []).map((city) => (
|
||||
<option key={city.id} value={city.id}>{city.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={calendarDraft.isActive}
|
||||
onChange={(e) => setCalendarDraft((current) => ({
|
||||
...current,
|
||||
isActive: e.target.checked,
|
||||
}))}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Kalender aktiv
|
||||
</label>
|
||||
|
||||
<div className="flex items-end justify-end gap-2 md:col-span-2">
|
||||
<button
|
||||
data-testid="holiday-calendar-reset-button"
|
||||
type="button"
|
||||
onClick={resetCalendarDraft}
|
||||
disabled={!isCalendarDirty || updateCalendar.isPending}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-900"
|
||||
>
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
<button
|
||||
data-testid="holiday-calendar-save-button"
|
||||
type="submit"
|
||||
disabled={!isCalendarDirty || updateCalendar.isPending}
|
||||
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
{updateCalendar.isPending ? "Speichert..." : "Kalender speichern"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form onSubmit={handleAddEntry} className="grid gap-3 md:grid-cols-[1fr_1.25fr_1fr_auto]">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Datum</span>
|
||||
<input
|
||||
data-testid="holiday-entry-date-input"
|
||||
type="date"
|
||||
value={entryDate}
|
||||
onChange={(e) => setEntryDate(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Feiertagsname</span>
|
||||
<input
|
||||
data-testid="holiday-entry-name-input"
|
||||
value={entryName}
|
||||
onChange={(e) => setEntryName(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="Augsburger Friedensfest"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span className="mb-1 block text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Quelle</span>
|
||||
<input
|
||||
data-testid="holiday-entry-source-input"
|
||||
value={entrySource}
|
||||
onChange={(e) => setEntrySource(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="Kommunale Satzung"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
data-testid="holiday-entry-create-button"
|
||||
type="submit"
|
||||
disabled={createEntry.isPending || !entryDate || !entryName.trim()}
|
||||
className="self-end rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50"
|
||||
>
|
||||
Hinzufuegen
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
<input
|
||||
data-testid="holiday-entry-recurring-checkbox"
|
||||
type="checkbox"
|
||||
checked={entryRecurring}
|
||||
onChange={(e) => setEntryRecurring(e.target.checked)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Jaehrlich wiederkehrend
|
||||
</label>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 dark:border-gray-700">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/60">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Datum</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Typ</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Quelle</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-medium uppercase tracking-wide text-gray-500">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedCalendar.entries.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-6 text-center text-sm text-gray-400">Keine Eintraege vorhanden.</td>
|
||||
</tr>
|
||||
)}
|
||||
{selectedCalendar.entries.map((entry) => (
|
||||
<tr key={entry.id} className="border-t border-gray-200 dark:border-gray-700">
|
||||
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">
|
||||
{editingEntryId === entry.id ? (
|
||||
<input
|
||||
data-testid={`holiday-entry-edit-date-${entry.id}`}
|
||||
type="date"
|
||||
value={entryDraft.date}
|
||||
onChange={(e) => setEntryDraft((current) => ({ ...current, date: e.target.value }))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
) : formatDate(entry.date)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-900 dark:text-gray-100">
|
||||
{editingEntryId === entry.id ? (
|
||||
<input
|
||||
data-testid={`holiday-entry-edit-name-${entry.id}`}
|
||||
value={entryDraft.name}
|
||||
onChange={(e) => setEntryDraft((current) => ({ ...current, name: e.target.value }))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
) : entry.name}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">
|
||||
{editingEntryId === entry.id ? (
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
data-testid={`holiday-entry-edit-recurring-${entry.id}`}
|
||||
type="checkbox"
|
||||
checked={entryDraft.isRecurringAnnual}
|
||||
onChange={(e) => setEntryDraft((current) => ({
|
||||
...current,
|
||||
isRecurringAnnual: e.target.checked,
|
||||
}))}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Jaehrlich
|
||||
</label>
|
||||
) : entry.isRecurringAnnual ? "jaehrlich" : "fix"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">
|
||||
{editingEntryId === entry.id ? (
|
||||
<input
|
||||
data-testid={`holiday-entry-edit-source-${entry.id}`}
|
||||
value={entryDraft.source}
|
||||
onChange={(e) => setEntryDraft((current) => ({ ...current, source: e.target.value }))}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
placeholder="Quelle"
|
||||
/>
|
||||
) : entry.source ?? "System/ohne Quelle"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-3">
|
||||
{editingEntryId === entry.id ? (
|
||||
<>
|
||||
<button
|
||||
data-testid={`holiday-entry-save-${entry.id}`}
|
||||
type="button"
|
||||
onClick={() => handleUpdateEntry(entry.id)}
|
||||
disabled={updateEntry.isPending || !entryDraft.date || !entryDraft.name.trim()}
|
||||
className="text-xs font-medium text-brand-600 hover:text-brand-700 disabled:opacity-50"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
data-testid={`holiday-entry-cancel-${entry.id}`}
|
||||
type="button"
|
||||
onClick={() => setEditingEntryId(null)}
|
||||
disabled={updateEntry.isPending}
|
||||
className="text-xs font-medium text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
data-testid={`holiday-entry-edit-${entry.id}`}
|
||||
type="button"
|
||||
onClick={() => startEditingEntry(entry)}
|
||||
className="text-xs font-medium text-brand-600 hover:text-brand-700"
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
data-testid={`holiday-entry-delete-${entry.id}`}
|
||||
type="button"
|
||||
onClick={() => handleDeleteEntry(entry)}
|
||||
disabled={deleteEntry.isPending}
|
||||
className="text-xs font-medium text-red-600 hover:text-red-700"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-900 dark:text-gray-100">Vorschau</h4>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">Effektiv aufgeloeste Feiertage fuer den gewaehlten Scope.</p>
|
||||
</div>
|
||||
<input
|
||||
data-testid="holiday-preview-year-input"
|
||||
type="number"
|
||||
value={previewYear}
|
||||
onChange={(e) => setPreviewYear(parseInt(e.target.value, 10) || new Date().getFullYear())}
|
||||
className="w-24 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-auto rounded-lg border border-gray-200 dark:border-gray-700">
|
||||
<table data-testid="holiday-preview-table" className="w-full text-sm">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900/60">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Datum</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Name</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium uppercase tracking-wide text-gray-500">Quelle</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(previewQuery.data ?? []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-3 py-6 text-center text-sm text-gray-400">
|
||||
{previewQuery.isLoading ? "Laedt Vorschau..." : "Keine Feiertage fuer diese Auswahl vorhanden."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{(previewQuery.data ?? []).map((entry) => (
|
||||
<tr key={`${entry.date}-${entry.name}`} className="border-t border-gray-200 dark:border-gray-700">
|
||||
<td className="px-3 py-2 text-gray-700 dark:text-gray-300">{entry.date}</td>
|
||||
<td className="px-3 py-2 text-gray-900 dark:text-gray-100">{entry.name}</td>
|
||||
<td className="px-3 py-2 text-gray-600 dark:text-gray-400">{entry.calendarName}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { VacationStatus, VacationType } from "@capakraken/shared";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { VacationModal } from "./VacationModal.js";
|
||||
@@ -137,6 +138,13 @@ export function VacationClient() {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Vacations</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Manage vacation requests and approvals</p>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Regional public holidays are maintained in{" "}
|
||||
<Link href="/admin/vacations" className="font-medium text-brand-700 hover:text-brand-800 dark:text-brand-400 dark:hover:text-brand-300">
|
||||
Holiday Calendars
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -10,6 +10,34 @@ import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import { VACATION_TYPE_LABELS } from "~/lib/status-styles.js";
|
||||
|
||||
const VACATION_TYPES = Object.values(VacationType);
|
||||
const REQUESTABLE_VACATION_TYPES = VACATION_TYPES.filter((type) => type !== VacationType.PUBLIC_HOLIDAY);
|
||||
|
||||
const HOLIDAY_SOURCE_LABELS = {
|
||||
CALENDAR: "Calendar",
|
||||
LEGACY_PUBLIC_HOLIDAY: "Legacy import",
|
||||
CALENDAR_AND_LEGACY: "Calendar + legacy",
|
||||
} as const;
|
||||
|
||||
type VacationPreviewData = {
|
||||
requestedDays: number;
|
||||
effectiveDays: number;
|
||||
deductedDays: number;
|
||||
publicHolidayDates: string[];
|
||||
holidayDetails: Array<{
|
||||
date: string;
|
||||
source: string;
|
||||
}>;
|
||||
holidayContext: {
|
||||
countryCode: string | null;
|
||||
countryName: string | null;
|
||||
federalState: string | null;
|
||||
metroCityName: string | null;
|
||||
sources: {
|
||||
hasCalendarHolidays: boolean;
|
||||
hasLegacyPublicHolidayEntries: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
interface VacationModalProps {
|
||||
resourceId?: string;
|
||||
@@ -17,13 +45,34 @@ interface VacationModalProps {
|
||||
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}`;
|
||||
function toUtcInputDate(value: string): Date {
|
||||
return new Date(`${value}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function buildHolidayBasisLabel(preview: VacationPreviewData): string[] {
|
||||
const parts = [];
|
||||
|
||||
if (preview.holidayContext.countryName || preview.holidayContext.countryCode) {
|
||||
parts.push(preview.holidayContext.countryName ?? preview.holidayContext.countryCode ?? "");
|
||||
}
|
||||
|
||||
if (preview.holidayContext.federalState) {
|
||||
parts.push(preview.holidayContext.federalState);
|
||||
}
|
||||
|
||||
if (preview.holidayContext.metroCityName) {
|
||||
parts.push(preview.holidayContext.metroCityName);
|
||||
}
|
||||
|
||||
return parts.filter(Boolean);
|
||||
}
|
||||
|
||||
function getHolidaySourceLabel(source: string): string {
|
||||
if (source in HOLIDAY_SOURCE_LABELS) {
|
||||
return HOLIDAY_SOURCE_LABELS[source as keyof typeof HOLIDAY_SOURCE_LABELS];
|
||||
}
|
||||
|
||||
return source;
|
||||
}
|
||||
|
||||
export function VacationModal({ resourceId: initialResourceId, onClose, onSuccess }: VacationModalProps) {
|
||||
@@ -70,6 +119,24 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
{ enabled: !!resourceId && !!startDate && !!endDate, staleTime: 10_000 },
|
||||
);
|
||||
|
||||
const previewQuery = trpc.vacation.previewRequest.useQuery(
|
||||
{
|
||||
resourceId,
|
||||
type,
|
||||
startDate: toUtcInputDate(debouncedStart || "1970-01-01"),
|
||||
endDate: toUtcInputDate(debouncedEnd || "1970-01-01"),
|
||||
...(isHalfDay ? { isHalfDay: true } : {}),
|
||||
},
|
||||
{
|
||||
enabled:
|
||||
!!resourceId
|
||||
&& !!debouncedStart
|
||||
&& !!debouncedEnd
|
||||
&& (!isHalfDay || debouncedStart === debouncedEnd),
|
||||
staleTime: 10_000,
|
||||
},
|
||||
);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const createMutation = trpc.vacation.create.useMutation({
|
||||
@@ -166,7 +233,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
{/* 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." />
|
||||
Type <span className="text-red-500">*</span><InfoTooltip content="ANNUAL = paid leave (deducted from entitlement) · SICK = sick leave · OTHER = special leave. Public holidays come from Holiday Calendars and are excluded automatically." />
|
||||
</label>
|
||||
<select
|
||||
id="vac-type"
|
||||
@@ -174,7 +241,7 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
onChange={(e) => setType(e.target.value as VacationType)}
|
||||
className={inputClass}
|
||||
>
|
||||
{VACATION_TYPES.map((t) => (
|
||||
{REQUESTABLE_VACATION_TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{VACATION_TYPE_LABELS[t]}
|
||||
</option>
|
||||
@@ -282,6 +349,81 @@ export function VacationModal({ resourceId: initialResourceId, onClose, onSucces
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!!resourceId && !!startDate && !!endDate && (
|
||||
<div
|
||||
data-testid="vacation-preview-card"
|
||||
className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<strong>Leave preview</strong>
|
||||
{previewQuery.isLoading && (
|
||||
<span className="text-xs text-emerald-700">Calculating…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewQuery.data && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="grid grid-cols-3 gap-2 text-xs sm:text-sm">
|
||||
<div className="rounded-md bg-white/70 px-3 py-2">
|
||||
<div className="text-emerald-700">Requested</div>
|
||||
<div data-testid="vacation-preview-requested-days" className="font-semibold">
|
||||
{previewQuery.data.requestedDays}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/70 px-3 py-2">
|
||||
<div className="text-emerald-700">Effective</div>
|
||||
<div data-testid="vacation-preview-effective-days" className="font-semibold">
|
||||
{previewQuery.data.effectiveDays}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md bg-white/70 px-3 py-2">
|
||||
<div className="text-emerald-700">Deducted</div>
|
||||
<div data-testid="vacation-preview-deducted-days" className="font-semibold">
|
||||
{previewQuery.data.deductedDays}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{buildHolidayBasisLabel(previewQuery.data).length > 0 && (
|
||||
<div data-testid="vacation-preview-holiday-basis" className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm">
|
||||
<span className="font-medium">Holiday basis:</span>{" "}
|
||||
{buildHolidayBasisLabel(previewQuery.data).join(" / ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(previewQuery.data.holidayContext.sources.hasCalendarHolidays || previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries) && (
|
||||
<div data-testid="vacation-preview-holiday-sources" className="rounded-md bg-white/70 px-3 py-2 text-xs sm:text-sm">
|
||||
<span className="font-medium">Sources:</span>{" "}
|
||||
{[
|
||||
previewQuery.data.holidayContext.sources.hasCalendarHolidays ? "Holiday Calendar" : null,
|
||||
previewQuery.data.holidayContext.sources.hasLegacyPublicHolidayEntries ? "Legacy public holiday entries" : null,
|
||||
].filter(Boolean).join(" + ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewQuery.data.publicHolidayDates.length > 0 && (
|
||||
<div data-testid="vacation-preview-public-holidays" className="text-xs sm:text-sm">
|
||||
<span className="font-medium">Excluded public holidays:</span>{" "}
|
||||
{previewQuery.data.holidayDetails.map((holiday) => `${holiday.date} (${getHolidaySourceLabel(holiday.source)})`).join(", ")}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewQuery.data.requestedDays !== previewQuery.data.deductedDays && (
|
||||
<div className="text-xs sm:text-sm text-emerald-800">
|
||||
Public holidays in the selected range are excluded from deducted leave days.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewQuery.error && (
|
||||
<div className="mt-2 text-xs text-red-700">
|
||||
{previewQuery.error.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note */}
|
||||
<div>
|
||||
<label htmlFor="vac-note" className={labelClass}>
|
||||
|
||||
Reference in New Issue
Block a user