feat(planning): ship holiday-aware planning and assistant upgrades

This commit is contained in:
2026-03-28 22:49:28 +01:00
parent 2a005794e7
commit 4f48afe7b4
151 changed files with 17738 additions and 1940 deletions
@@ -1,3 +1,4 @@
import { HolidayCalendarEditor } from "~/components/vacations/HolidayCalendarEditor.js";
import { PublicHolidayBatch } from "~/components/vacations/PublicHolidayBatch.js";
import { EntitlementManager } from "~/components/vacations/EntitlementManager.js";
@@ -8,10 +9,40 @@ export default function AdminVacationsPage() {
<div className="p-6 max-w-5xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Vacation Management</h1>
<p className="text-sm text-gray-500 mt-1">Manage public holidays, entitlements, and year summaries</p>
<p className="mt-1 text-sm text-gray-500">
Verwalte Feiertagskalender pro Land, Bundesland und Stadt sowie Entitlements und Fallback-Importe.
</p>
</div>
<PublicHolidayBatch />
<EntitlementManager />
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Holiday Calendars</h2>
<p className="text-sm text-gray-600">
Fachliche Quelle fuer regionale Feiertage. Diese Kalender werden fuer Urlaubszaehlung, Timeline-Overlay und Assistant-Abfragen verwendet.
</p>
</div>
<HolidayCalendarEditor />
</section>
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Legacy Batch Import</h2>
<p className="text-sm text-gray-600">
Nur als Fallback fuer bestaende Prozesse. Bevorzugt sollen Feiertage ueber die Kalenderlogik und nicht als statische Urlaubseintraege gepflegt werden.
</p>
</div>
<PublicHolidayBatch />
</section>
<section className="space-y-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-wide text-gray-500">Entitlements</h2>
<p className="text-sm text-gray-600">
Jahresansprueche und Resttage im gleichen Kontext pruefen, nachdem Feiertage regional aufgeloest wurden.
</p>
</div>
<EntitlementManager />
</section>
</div>
);
}
@@ -33,6 +33,7 @@ import { usePermissions } from "~/hooks/usePermissions.js";
import { useColumnConfig } from "~/hooks/useColumnConfig.js";
import { useViewPrefs } from "~/hooks/useViewPrefs.js";
import { useRowOrder } from "~/hooks/useRowOrder.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
import { DraggableTableRow } from "~/components/ui/DraggableTableRow.js";
type ModalState =
@@ -85,68 +86,22 @@ function FilterDropdown({
tooltipContent?: ReactNode;
}) {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [panelPosition, setPanelPosition] = useState({ top: 0, left: 0, minWidth: 0 });
const updatePanelPosition = useCallback(() => {
const trigger = dropdownRef.current;
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const panelWidth = panelRef.current?.offsetWidth ?? rect.width;
const viewportPadding = 16;
const maxLeft = Math.max(viewportPadding, window.innerWidth - panelWidth - viewportPadding);
setPanelPosition({
top: rect.bottom + 8,
left: Math.min(Math.max(rect.left, viewportPadding), maxLeft),
minWidth: rect.width,
});
}, []);
useEffect(() => {
function handlePointerDown(event: MouseEvent) {
const target = event.target as Node;
if (dropdownRef.current?.contains(target) || panelRef.current?.contains(target)) {
return;
}
setIsOpen(false);
}
document.addEventListener("mousedown", handlePointerDown);
return () => document.removeEventListener("mousedown", handlePointerDown);
}, []);
useEffect(() => {
if (!isOpen) return;
updatePanelPosition();
const rafId = window.requestAnimationFrame(updatePanelPosition);
const handleEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setIsOpen(false);
}
};
window.addEventListener("resize", updatePanelPosition);
window.addEventListener("scroll", updatePanelPosition, true);
window.addEventListener("keydown", handleEscape);
return () => {
window.cancelAnimationFrame(rafId);
window.removeEventListener("resize", updatePanelPosition);
window.removeEventListener("scroll", updatePanelPosition, true);
window.removeEventListener("keydown", handleEscape);
};
}, [isOpen, updatePanelPosition]);
const { triggerRef, panelRef, position, handleOpenChange } = useAnchoredOverlay<HTMLDivElement>({
open: isOpen,
onClose: () => setIsOpen(false),
matchTriggerWidth: true,
});
return (
<div ref={dropdownRef} className="relative">
<div ref={triggerRef} className="relative">
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
onClick={() => {
const nextOpen = !isOpen;
handleOpenChange(nextOpen);
setIsOpen(nextOpen);
}}
className={`inline-flex items-center justify-between gap-3 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-700 shadow-sm transition hover:border-gray-400 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 ${buttonClassName}`}
>
<span className="text-left">{label}</span>
@@ -160,9 +115,9 @@ function FilterDropdown({
ref={panelRef}
style={{
position: "fixed",
top: panelPosition.top,
left: panelPosition.left,
minWidth: panelPosition.minWidth,
top: position.top,
left: position.left,
minWidth: position.minWidth,
}}
className={`z-[9998] rounded-2xl border border-gray-200 bg-white p-3 shadow-xl dark:border-gray-700 dark:bg-gray-900 ${widthClassName}`}
>