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>
299 lines
14 KiB
TypeScript
299 lines
14 KiB
TypeScript
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>
|
||
);
|