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>
98 lines
4.7 KiB
TypeScript
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>
|
|
);
|
|
};
|