866 lines
40 KiB
TypeScript
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>
|
|
);
|
|
}
|