Files
perinco-Pulse/src/components/calendar/Timeline.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

98 lines
4.7 KiB
TypeScript

import React from 'react';
import { WEEKDAYS_SHORT, addDays, fromIso, monthEnd, todayIso, weekdayIndex } from '../../lib/format';
import { Avatar } from '../ui/controls';
import type { CalendarUser } from '../../lib/api';
import { holidaysOn, userColor, type CalendarIndex } from './calendarData';
/**
* Zeitleiste: Personen in Zeilen, Tage des Monats in Spalten. Zeigt auf einen
* Blick, wo sich Urlaube ueberschneiden. Breiter als das Handy - die Tabelle
* scrollt seitlich, die Namensspalte bleibt stehen.
*/
export const Timeline: React.FC<{
month: string;
index: CalendarIndex;
users: CalendarUser[];
selected: string | null;
onSelect: (day: string) => void;
}> = ({ month, index, users, selected, onSelect }) => {
const today = todayIso();
const days: string[] = [];
for (let d = month; d <= monthEnd(month); d = addDays(d, 1)) days.push(d);
return (
<div className="scrollbar-slim overflow-x-auto rounded-2xl border border-ink-200 bg-surface shadow-card">
<table className="w-full border-separate border-spacing-0 text-xs">
<thead>
<tr>
<th className="sticky left-0 z-20 min-w-[128px] border-b border-ink-200 bg-surface px-3 py-2 text-left text-[11px] font-extrabold uppercase tracking-wider text-ink-400 sm:min-w-[180px]">
Mitarbeiter
</th>
{days.map((day) => {
const weekend = weekdayIndex(day) >= 5;
const holidays = holidaysOn(index, day);
return (
<th
key={day}
title={holidays.join(', ') || undefined}
onClick={() => onSelect(day)}
className={`min-w-[28px] cursor-pointer border-b border-ink-200 px-0 py-1.5 text-center font-extrabold ${
day === selected ? 'bg-brand-100 text-brand-700' : holidays.length ? 'bg-amber-50 text-amber-700' : weekend ? 'bg-ink-50 text-ink-400' : 'text-ink-600'
}`}
>
<span className="block text-[9px] font-bold uppercase text-ink-400">{WEEKDAYS_SHORT[weekdayIndex(day)].slice(0, 2)}</span>
<span className={day === today ? 'rounded-full bg-brand-500 px-1 text-white' : ''}>{fromIso(day).getDate()}</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{users.map((user) => {
const entries = index.byUser.get(user.id);
const color = userColor(user);
return (
<tr key={user.id} className="group">
<td className="sticky left-0 z-10 border-b border-ink-100 bg-surface px-3 py-1.5 group-hover:bg-ink-50">
<span className="flex min-w-0 items-center gap-2">
<Avatar id={user.id} name={user.name} color={user.color} size={22} external={user.is_external} />
<span className="max-w-[92px] truncate font-bold text-ink-800 sm:max-w-[140px]">{user.name}</span>
</span>
</td>
{days.map((day) => {
const entry = entries?.get(day);
const weekend = weekdayIndex(day) >= 5;
const vacation = entry?.kind === 'vacation';
// Aneinanderstossende Urlaubstage als ein Balken zeichnen.
const prev = entries?.get(addDays(day, -1))?.kind === 'vacation';
const next = entries?.get(addDays(day, 1))?.kind === 'vacation';
return (
<td
key={day}
title={entry ? `${user.name}: ${entry.label}${entry.half ? ' (halber Tag)' : ''}` : undefined}
onClick={() => onSelect(day)}
className={`h-9 cursor-pointer border-b border-ink-100 px-0 py-1 ${
day === selected ? 'bg-brand-50' : entry?.kind === 'holiday' ? 'bg-amber-50' : weekend ? 'bg-ink-50/70' : ''
}`}
>
{vacation && (
<span
className={`block h-5 ${prev ? '' : 'ml-0.5 rounded-l-md'} ${next ? '' : 'mr-0.5 rounded-r-md'}`}
style={{
background: entry.half ? `linear-gradient(to top, ${color} 50%, color-mix(in srgb, ${color} 25%, transparent) 50%)` : color,
}}
/>
)}
{entry?.kind === 'holiday' && <span className="mx-auto block h-1.5 w-1.5 rounded-full bg-amber-400" />}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
};