Files
CapaKraken/apps/web/src/components/vacations/HolidayCalendarEditor.tsx
T

866 lines
40 KiB
TypeScript

"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>
);
}