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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user