import React, { useEffect, useMemo, useState } from 'react'; import { Avatar, Card, EmptyState, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls'; import { Icon } from '../ui/Icon'; import { MonthGrid } from './MonthGrid'; import { Timeline } from './Timeline'; import { calendarApi, type CalendarUser } from '../../lib/api'; import { useLoader, usePersistentState } from '../../lib/hooks'; import { MONTHS, addDays, addMonths, formatDate, fromIso, mondayOf, monthEnd, monthStart, todayIso } from '../../lib/format'; import { subordinates } from '../team/hierarchy'; import { buildIndex, holidaysOn, shortNames, userColor, vacationRanges, vacationsOn } from './calendarData'; type Mode = 'month' | 'timeline'; /** Tage als "5.10." bzw. "5.–9.10." / "28.9.–2.10." */ function rangeLabel(from: string, to: string) { const a = fromIso(from); const b = fromIso(to); if (from === to) return `${a.getDate()}.${a.getMonth() + 1}.`; if (a.getMonth() === b.getMonth()) return `${a.getDate()}.–${b.getDate()}.${b.getMonth() + 1}.`; return `${a.getDate()}.${a.getMonth() + 1}.–${b.getDate()}.${b.getMonth() + 1}.`; } const dayCount = (n: number) => `${n.toLocaleString('de-DE')} ${n === 1 ? 'Tag' : 'Tage'}`; /** * Kalender fuer alle: genehmigte Urlaube und Feiertage aus HRworks, auch in * der Zukunft. Monatsansicht als Standard, dazu eine Zeitleiste je Person. */ export const Calendar: React.FC = () => { const today = todayIso(); // Bewusst nicht gemerkt: der Kalender oeffnet immer in der Monatsansicht. const [mode, setMode] = useState('month'); const [month, setMonth] = useState(monthStart(today)); const [selected, setSelected] = useState(today); const [team, setTeam] = usePersistentState('pulse_calendar_team', 'all'); const [search, setSearch] = useState(''); const [onlyAway, setOnlyAway] = usePersistentState('pulse_calendar_only_away', false); const gridStart = mondayOf(month); const end = monthEnd(month); const weeks = Math.ceil(((fromIso(end).getTime() - fromIso(gridStart).getTime()) / 86400000 + 1) / 7); const gridEnd = addDays(gridStart, weeks * 7 - 1); const data = useLoader(() => calendarApi.get(gridStart, gridEnd), [gridStart, gridEnd]); const shift = (delta: number) => { const next = addMonths(month, delta); setMonth(next); setSelected(today.slice(0, 7) === next.slice(0, 7) ? today : null); }; const goToday = () => { setMonth(monthStart(today)); setSelected(today); }; useEffect(() => { const onKey = (e: KeyboardEvent) => { const target = e.target as HTMLElement | null; if (target && ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName)) return; if (e.key === 'ArrowLeft') shift(-1); if (e.key === 'ArrowRight') shift(1); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }); const users = data.data?.users ?? []; // Teams: jeder, dem jemand unterstellt ist, samt allen darunter. const leads = useMemo(() => { const ids = new Set(users.map((u) => u.manager_id).filter((id): id is number => id != null)); return users.filter((u) => ids.has(u.id)).sort((a, b) => a.name.localeCompare(b.name, 'de')); }, [users]); const visibleUsers = useMemo(() => { let list: CalendarUser[] = users; if (team.startsWith('lead:')) { const leadId = Number(team.slice(5)); const lead = users.find((u) => u.id === leadId); // Unbekanntes Team (Vorgesetzter weg): alle zeigen statt einer leeren Seite. if (lead) list = [lead, ...subordinates(users, leadId)]; } const q = search.trim().toLowerCase(); if (q) list = list.filter((u) => u.name.toLowerCase().includes(q)); return [...list].sort((a, b) => a.name.localeCompare(b.name, 'de')); }, [users, team, search]); const index = useMemo( () => buildIndex(data.data?.days ?? [], new Set(visibleUsers.map((u) => u.id))), [data.data, visibleUsers] ); const userMap = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]); const names = useMemo(() => shortNames(users), [users]); // Wer ist in diesem Monat weg, und wann? const monthly = useMemo( () => visibleUsers .map((u) => ({ user: u, ranges: vacationRanges(index.byUser.get(u.id), month, end) })) .filter((x) => x.ranges.length > 0), [visibleUsers, index, month, end] ); const timelineUsers = onlyAway ? visibleUsers.filter((u) => monthly.some((m) => m.user.id === u.id)) : visibleUsers; const selectedAway = selected ? vacationsOn(index, selected) .map((d) => ({ entry: d, user: userMap.get(d.user_id)! })) .filter((x) => x.user) .sort((a, b) => a.user.name.localeCompare(b.user.name, 'de')) : []; const selectedHolidays = selected ? holidaysOn(index, selected) : []; const monthHolidays = useMemo(() => { const out: { day: string; labels: string[] }[] = []; for (let d = month; d <= end; d = addDays(d, 1)) { const labels = holidaysOn(index, d); if (labels.length) out.push({ day: d, labels }); } return out; }, [index, month, end]); const label = `${MONTHS[fromIso(month).getMonth()]} ${fromIso(month).getFullYear()}`; return (
{/* Werkzeugleiste */}

{label}

setSearch(e.target.value)} aria-label="Name suchen" />
{data.error && } {data.loading && !data.data ? ( ) : data.data && !data.data.last_run && data.data.days.length === 0 ? ( ) : (
{mode === 'month' ? ( ) : ( <> {timelineUsers.length ? ( ) : ( )} )}
{selected && (

{formatDate(selected, 'long')}

{selectedHolidays.length > 0 && (

{selectedHolidays.join(' · ')}

)} {selectedAway.length ? (
    {selectedAway.map(({ entry, user }) => (
  • {user.name} {entry.label} {entry.half ? ' · halber Tag' : ''}
  • ))}
) : (

Niemand im Urlaub.

)}
)}

Urlaub im {MONTHS[fromIso(month).getMonth()]}

{monthly.length ? (
    {monthly.map(({ user, ranges }) => (
  • {user.name} {ranges.map((r) => `${rangeLabel(r.from, r.to)} (${dayCount(r.days)})`).join(', ')}
  • ))}
) : (

In diesem Monat ist kein Urlaub eingetragen.

)} {monthHolidays.length > 0 && (

Feiertage

    {monthHolidays.map((h) => (
  • {formatDate(h.day, 'day')} {h.labels.join(' · ')}
  • ))}
)}
)}
); }; const Legend: React.FC<{ lastRun: string | null }> = ({ lastRun }) => (
Urlaub (genehmigt) halber Tag Feiertag {lastRun && Stand HRworks: {new Date(lastRun).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })}}
);