Files
perinco-Pulse/src/components/calendar/Calendar.tsx
T
schakal00783andClaude Opus 5.5 c0eb00faee feat(kalender): Kalender mit Urlauben und Feiertagen fuer alle
Neuer Menuepunkt "Kalender" fuer alle Rollen: genehmigte Urlaube aller
Mitarbeiter, auch in der Zukunft, dazu Feiertage. Monatsansicht (Standard)
und Zeitleiste, filterbar nach Team und Name.

- Eigene Tabelle pulse_calendar_days, gefuellt von lib/calendarSync.js im
  HRworks-Lauf (Fenster bis 400 Tage voraus). Buchungen entstehen weiter
  erst am jeweiligen Tag.
- Nur validatedOk und nur freigegebene Arten (calendar_types, Haken in den
  HRworks-Einstellungen). Krankmeldungen werden dafuer nicht abgefragt.
- GET /api/calendar fuer alle Angemeldeten, ohne Mails und Profilbilder.
- Timer laeuft zusaetzlich um 12:00 Uhr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:37:26 +02:00

299 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Mode>('month');
const [month, setMonth] = useState(monthStart(today));
const [selected, setSelected] = useState<string | null>(today);
const [team, setTeam] = usePersistentState<string>('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 (
<div className="mx-auto max-w-[1500px]">
{/* Werkzeugleiste */}
<div className="mb-4 flex flex-wrap items-center gap-2 sm:gap-3">
<div className="flex items-center rounded-xl border border-ink-200 bg-surface shadow-card">
<button onClick={() => shift(-1)} className="rounded-l-xl p-2.5 text-ink-500 hover:bg-ink-50 hover:text-ink-900" aria-label="Vorheriger Monat">
<Icon name="chevronLeft" className="h-4 w-4" strokeWidth={2.2} />
</button>
<button onClick={goToday} className="border-x border-ink-200 px-3 py-2 text-xs font-bold text-ink-600 hover:bg-ink-50">
Heute
</button>
<button onClick={() => shift(1)} className="rounded-r-xl p-2.5 text-ink-500 hover:bg-ink-50 hover:text-ink-900" aria-label="Nächster Monat">
<Icon name="chevronRight" className="h-4 w-4" strokeWidth={2.2} />
</button>
</div>
<h2 className="tabular min-w-0 flex-1 truncate text-base text-ink-900 sm:flex-none sm:text-lg">{label}</h2>
<div className="order-last flex w-full flex-wrap items-center gap-2 sm:order-none sm:ml-auto sm:w-auto sm:flex-nowrap">
<Segmented
value={mode}
onChange={setMode}
className="flex-1 sm:flex-none"
options={[
{ value: 'month', label: 'Monat', icon: 'calendar', title: 'Monatsansicht' },
{ value: 'timeline', label: 'Zeitleiste', icon: 'list', title: 'Eine Zeile je Mitarbeiter' },
]}
/>
<select
className={`${inputClass} w-auto min-w-0 max-w-[200px] flex-1 py-2 text-xs font-bold sm:flex-none`}
value={team.startsWith('lead:') && !leads.some((l) => `lead:${l.id}` === team) ? 'all' : team}
onChange={(e) => setTeam(e.target.value)}
title="Team filtern"
>
<option value="all">Alle Mitarbeiter</option>
{leads.map((l) => (
<option key={l.id} value={`lead:${l.id}`}>
Team {l.name}
</option>
))}
</select>
<div className="relative min-w-0 flex-1 sm:w-44 sm:flex-none">
<Icon name="search" className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-400" />
<input
className={`${inputClass} py-2 pl-8 text-xs`}
placeholder="Name suchen"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Name suchen"
/>
</div>
</div>
</div>
{data.error && <ErrorBox message={data.error} onRetry={data.reload} />}
{data.loading && !data.data ? (
<Spinner />
) : data.data && !data.data.last_run && data.data.days.length === 0 ? (
<EmptyState
icon="calendar"
title="Noch keine Urlaube übernommen"
text="Der Kalender füllt sich mit dem nächsten Abgleich aus HRworks (morgens und mittags)."
/>
) : (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<div key={`${mode}-${month}`} className="animate-fade min-w-0 space-y-3">
{mode === 'month' ? (
<MonthGrid
month={month}
gridStart={gridStart}
weeks={weeks}
index={index}
users={userMap}
names={names}
selected={selected}
onSelect={setSelected}
/>
) : (
<>
<label className="flex w-fit cursor-pointer items-center gap-2 text-xs font-bold text-ink-600">
<input type="checkbox" className="accent-[var(--color-brand-500)]" checked={onlyAway} onChange={(e) => setOnlyAway(e.target.checked)} />
Nur Mitarbeiter mit Urlaub in diesem Monat
</label>
{timelineUsers.length ? (
<Timeline month={month} index={index} users={timelineUsers} selected={selected} onSelect={setSelected} />
) : (
<EmptyState icon="calendar" title="Niemand im Urlaub" text="In diesem Monat ist für die Auswahl kein Urlaub eingetragen." />
)}
</>
)}
<Legend lastRun={data.data?.last_run ?? null} />
</div>
<div className="min-w-0 space-y-4">
{selected && (
<Card className="animate-rise p-4">
<h3 className="text-sm text-ink-900">{formatDate(selected, 'long')}</h3>
{selectedHolidays.length > 0 && (
<p className="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-amber-50 px-2 py-1 text-xs font-bold text-amber-700">
<Icon name="sun" className="h-3.5 w-3.5" />
{selectedHolidays.join(' · ')}
</p>
)}
{selectedAway.length ? (
<ul className="mt-3 space-y-2">
{selectedAway.map(({ entry, user }) => (
<li key={user.id} className="flex items-center gap-2.5">
<Avatar id={user.id} name={user.name} color={user.color} size={28} external={user.is_external} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink-800">{user.name}</span>
<span className="block text-xs text-ink-400">
{entry.label}
{entry.half ? ' · halber Tag' : ''}
</span>
</span>
</li>
))}
</ul>
) : (
<p className="mt-2 text-xs text-ink-400">Niemand im Urlaub.</p>
)}
</Card>
)}
<Card className="animate-rise p-4" style={{ '--i': 1 } as React.CSSProperties}>
<h3 className="text-sm text-ink-900">Urlaub im {MONTHS[fromIso(month).getMonth()]}</h3>
{monthly.length ? (
<ul className="mt-3 space-y-2.5">
{monthly.map(({ user, ranges }) => (
<li key={user.id} className="flex items-start gap-2.5">
<span className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: userColor(user) }} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink-800">{user.name}</span>
<span className="block text-xs text-ink-500">
{ranges.map((r) => `${rangeLabel(r.from, r.to)} (${dayCount(r.days)})`).join(', ')}
</span>
</span>
</li>
))}
</ul>
) : (
<p className="mt-2 text-xs text-ink-400">In diesem Monat ist kein Urlaub eingetragen.</p>
)}
{monthHolidays.length > 0 && (
<div className="mt-4 border-t border-ink-100 pt-3">
<p className="mb-1.5 text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Feiertage</p>
<ul className="space-y-1 text-xs text-ink-600">
{monthHolidays.map((h) => (
<li key={h.day}>
<b className="tabular text-amber-700">{formatDate(h.day, 'day')}</b> {h.labels.join(' · ')}
</li>
))}
</ul>
</div>
)}
</Card>
</div>
</div>
)}
</div>
);
};
const Legend: React.FC<{ lastRun: string | null }> = ({ lastRun }) => (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 px-1 text-[11px] font-semibold text-ink-400">
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-4 rounded-sm bg-brand-500" /> Urlaub (genehmigt)
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-4 rounded-sm bg-brand-500 opacity-50" /> halber Tag
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-4 rounded-sm border border-amber-200 bg-amber-50" /> Feiertag
</span>
{lastRun && <span className="sm:ml-auto">Stand HRworks: {new Date(lastRun).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })}</span>}
</div>
);