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>
This commit is contained in:
schakal00783
2026-09-25 11:37:26 +02:00
co-authored by Claude Opus 5.5
parent 6ae5a01bda
commit c0eb00faee
21 changed files with 996 additions and 33 deletions
+3
View File
@@ -9,6 +9,7 @@ import { Dashboard } from './components/dashboard/Dashboard';
import { Booking } from './components/booking/Booking';
import { Projects } from './components/projects/Projects';
import { Team } from './components/team/Team';
import { Calendar } from './components/calendar/Calendar';
import { Customers } from './components/customers/Customers';
import { Reports } from './components/reports/Reports';
import { Settings } from './components/settings/Settings';
@@ -21,6 +22,7 @@ const SUBTITLES: Record<ViewKey, string> = {
dashboard: 'Gebuchte Zeiten, Auslastung und letzte Buchungen',
buchung: 'Zeiten erfassen, bearbeiten und exportieren',
projekte: 'Projekte, Budgettöpfe und Tasks',
kalender: 'Urlaube und Feiertage aller Mitarbeiter',
team: 'Hierarchie, Benutzer und externe Mitarbeiter',
kunden: 'Kundenstamm, Projekte und Umsatz',
reports: 'Auswertungen nach Kunde, Projekt, Task und Mitarbeiter',
@@ -68,6 +70,7 @@ const App: React.FC = () => {
{view === 'dashboard' && <Dashboard />}
{view === 'buchung' && <Booking />}
{view === 'projekte' && <Projects />}
{view === 'kalender' && <Calendar />}
{view === 'team' && <Team />}
{view === 'kunden' && <Customers />}
{view === 'reports' && <Reports />}
+298
View File
@@ -0,0 +1,298 @@
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>
);
+110
View File
@@ -0,0 +1,110 @@
import React from 'react';
import { WEEKDAYS_SHORT, addDays, fromIso, todayIso } from '../../lib/format';
import { useIsMobile } from '../../lib/hooks';
import type { CalendarUser } from '../../lib/api';
import { holidaysOn, userColor, vacationsOn, type CalendarIndex } from './calendarData';
/** Monatsraster: je Tag die Abwesenden, Feiertage in Bernstein. */
export const MonthGrid: React.FC<{
month: string;
gridStart: string;
weeks: number;
index: CalendarIndex;
users: Map<number, CalendarUser>;
names: Map<number, string>;
selected: string | null;
onSelect: (day: string) => void;
}> = ({ month, gridStart, weeks, index, users, names, selected, onSelect }) => {
const isMobile = useIsMobile();
const today = todayIso();
const cells = Array.from({ length: weeks * 7 }, (_, i) => addDays(gridStart, i));
const maxChips = isMobile ? 4 : 3;
return (
<div className="rounded-2xl border border-ink-200 bg-surface p-2 shadow-card sm:p-3">
<div className="mb-1 grid grid-cols-7 gap-1 sm:gap-2">
{WEEKDAYS_SHORT.map((d) => (
<p key={d} className="py-1 text-center text-[11px] font-extrabold uppercase tracking-wider text-ink-400">
{d}
</p>
))}
</div>
<div className="grid grid-cols-7 gap-1 sm:gap-2">
{cells.map((date, i) => {
const inMonth = date.slice(0, 7) === month.slice(0, 7);
const weekend = i % 7 >= 5;
const holidays = holidaysOn(index, date);
const away = vacationsOn(index, date)
.map((d) => ({ entry: d, user: users.get(d.user_id) }))
.filter((x): x is { entry: typeof x.entry; user: CalendarUser } => Boolean(x.user))
.sort((a, b) => a.user.name.localeCompare(b.user.name, 'de'));
const isSelected = selected === date;
const title = [
...holidays,
...away.map(({ entry, user }) => `${user.name}${entry.half ? ' (halber Tag)' : ''}`),
].join('\n');
return (
<button
key={date}
onClick={() => onSelect(date)}
title={title || undefined}
aria-pressed={isSelected}
className={`animate-rise relative flex min-h-[62px] min-w-0 flex-col overflow-hidden rounded-xl border p-1.5 text-left transition hover:border-brand-300 hover:shadow-card sm:min-h-[112px] sm:p-2 ${
!inMonth ? 'border-transparent opacity-35' : ''
} ${
isSelected
? 'border-brand-500 ring-2 ring-brand-200'
: date === today
? 'border-brand-400'
: holidays.length
? 'border-amber-200 bg-amber-50/70'
: weekend
? 'border-ink-100 bg-ink-50/60'
: 'border-ink-200'
}`}
style={{ '--i': Math.floor(i / 7) } as React.CSSProperties}
>
<span className="flex items-center justify-between gap-1">
<span className={`text-xs font-extrabold sm:text-sm ${date === today ? 'text-brand-700' : holidays.length ? 'text-amber-700' : 'text-ink-700'}`}>
{fromIso(date).getDate()}
</span>
{away.length > 0 && isMobile && <span className="text-[10px] font-extrabold text-ink-500">{away.length}</span>}
</span>
{holidays.length > 0 && !isMobile && (
<span className="mt-0.5 truncate text-[10px] font-bold leading-tight text-amber-700">{holidays.join(' · ')}</span>
)}
{isMobile ? (
away.length > 0 && (
<span className="mt-auto flex flex-wrap gap-0.5">
{away.slice(0, maxChips).map(({ entry, user }) => (
<span
key={user.id}
className="h-2 w-2 rounded-full"
style={{ background: userColor(user), opacity: entry.half ? 0.5 : 1 }}
/>
))}
</span>
)
) : (
<span className="mt-1 flex min-w-0 flex-col gap-0.5">
{away.slice(0, maxChips).map(({ entry, user }) => (
<span
key={user.id}
className="flex min-w-0 items-center gap-1 rounded-md px-1 py-px text-[11px] font-bold text-ink-800"
style={{ background: `color-mix(in srgb, ${userColor(user)} 16%, transparent)` }}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: userColor(user) }} />
<span className="truncate">{names.get(user.id)}</span>
{entry.half && <span className="ml-auto shrink-0 text-[10px] text-ink-500">½</span>}
</span>
))}
{away.length > maxChips && <span className="px-1 text-[10px] font-bold text-ink-400">+{away.length - maxChips} weitere</span>}
</span>
)}
</button>
);
})}
</div>
</div>
);
};
+97
View File
@@ -0,0 +1,97 @@
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>
);
};
+76
View File
@@ -0,0 +1,76 @@
import type { CalendarDay, CalendarUser } from '../../lib/api';
import { addDays, colorFor, weekdayIndex } from '../../lib/format';
/** Schnellzugriff auf die Kalendereintraege: je Tag und je Person. */
export interface CalendarIndex {
byDay: Map<string, CalendarDay[]>;
byUser: Map<number, Map<string, CalendarDay>>;
}
export function buildIndex(days: CalendarDay[], visible: Set<number>): CalendarIndex {
const byDay = new Map<string, CalendarDay[]>();
const byUser = new Map<number, Map<string, CalendarDay>>();
for (const d of days) {
if (!visible.has(d.user_id)) continue;
if (!byDay.has(d.day)) byDay.set(d.day, []);
byDay.get(d.day)!.push(d);
if (!byUser.has(d.user_id)) byUser.set(d.user_id, new Map());
byUser.get(d.user_id)!.set(d.day, d);
}
return { byDay, byUser };
}
/** Feiertage eines Tages (bei mehreren Standorten koennen es verschiedene sein). */
export function holidaysOn(index: CalendarIndex, day: string): string[] {
return [...new Set((index.byDay.get(day) ?? []).filter((d) => d.kind === 'holiday').map((d) => d.label))];
}
export function vacationsOn(index: CalendarIndex, day: string): CalendarDay[] {
return (index.byDay.get(day) ?? []).filter((d) => d.kind === 'vacation');
}
/**
* Kurzname fuer enge Stellen: der Vorname, bei Doppelten mit Initiale des
* Nachnamens ("Luisa S.").
*/
export function shortNames(users: CalendarUser[]): Map<number, string> {
const first = (name: string) => name.trim().split(/\s+/)[0] ?? name;
const count = new Map<string, number>();
for (const u of users) count.set(first(u.name), (count.get(first(u.name)) ?? 0) + 1);
return new Map(
users.map((u) => {
const parts = u.name.trim().split(/\s+/);
const f = first(u.name);
return [u.id, (count.get(f) ?? 0) > 1 && parts.length > 1 ? `${f} ${parts[parts.length - 1][0]}.` : f];
})
);
}
export const userColor = (u: Pick<CalendarUser, 'id' | 'color'>) => colorFor(u.id, u.color);
/**
* Urlaubstage einer Person zu Zeitraeumen zusammenfassen. Wochenenden und
* Feiertage dazwischen unterbrechen einen Urlaub nicht.
*/
export function vacationRanges(entries: Map<string, CalendarDay> | undefined, from: string, to: string) {
const ranges: { from: string; to: string; days: number; label: string }[] = [];
if (!entries) return ranges;
let current: (typeof ranges)[number] | null = null;
for (let day = from; day <= to; day = addDays(day, 1)) {
const entry = entries.get(day);
if (entry?.kind === 'vacation') {
if (current && current.label === entry.label) {
current.to = day;
} else {
current = { from: day, to: day, days: 0, label: entry.label };
ranges.push(current);
}
current.days += entry.half ? 0.5 : 1;
continue;
}
// Freie Tage ueberbruecken, alles andere beendet den Zeitraum.
const free = weekdayIndex(day) >= 5 || entry?.kind === 'holiday';
if (!free) current = null;
}
return ranges;
}
+1
View File
@@ -15,6 +15,7 @@ export const VIEW_ICONS: Record<ViewKey, IconName> = {
dashboard: 'dashboard',
buchung: 'clock',
projekte: 'folder',
kalender: 'calendar',
team: 'team',
kunden: 'building',
reports: 'chart',
+33 -4
View File
@@ -9,7 +9,8 @@ import { useToast } from '../Toast';
* Abwesenheiten aus HRworks.
*
* Urlaub, Krankheit, Elternzeit und Feiertage stehen in HRworks; Pulse holt sie
* einmal taeglich und bucht sie auf ein nicht abrechenbares Projekt. Hier
* zweimal taeglich, bucht sie auf ein nicht abrechenbares Projekt und fuellt
* den Kalender (nur Urlaub und Feiertage). Hier
* stehen die Zugangsdaten, die Zuordnung der Arten auf Tasks und der Knopf fuer
* einen Probelauf.
*/
@@ -44,10 +45,12 @@ export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () =>
setReport(r);
addToast(
was === 'apply'
? `Übernommen: ${r.created} neu, ${r.updated ?? 0} geändert, ${r.removed ?? 0} entfernt.`
? `Übernommen: ${r.created} neu, ${r.updated ?? 0} geändert, ${r.removed ?? 0} entfernt.` +
(r.calendar ? ` Kalender: ${r.calendar.vacation_days} Urlaubstage.` : '')
: `Probelauf: ${r.planned_create} neu, ${r.planned_update} geändert, ${r.planned_delete} zu entfernen.`,
'success'
);
if (r.calendar_error) addToast(`Kalender: ${r.calendar_error}`, 'error');
hrworksApi.status().then(setStatus).catch(() => undefined);
}
} catch (err) {
@@ -82,7 +85,7 @@ export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () =>
<Icon name={settings.hrworks.enabled ? 'check' : 'info'} className="mt-0.5 h-5 w-5 shrink-0" />
<span>
{settings.hrworks.enabled
? 'Der Abgleich läuft täglich um 5:30 Uhr und holt die letzten Tage nach.'
? 'Der Abgleich läuft täglich um 5:30 und 12:00 Uhr: Buchungen bis heute, dazu der Kalender mit den genehmigten Urlauben der Zukunft.'
: settings.hrworks.configured
? 'Zugangsdaten hinterlegt, der Abgleich ist noch nicht eingeschaltet.'
: 'Noch keine Zugangsdaten. Schlüsselpaar in HRworks unter Grundlagen → Integrationen → HRworks-API erzeugen.'}
@@ -148,7 +151,8 @@ export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () =>
<p className="mb-3 text-xs leading-snug text-ink-400">
Links der Schlüssel aus HRworks (<code className="rounded bg-ink-100 px-1">absence:</code> oder{' '}
<code className="rounded bg-ink-100 px-1">sick:</code>), rechts der Taskname in Pulse. Fehlt ein Eintrag, sucht Pulse
einen Task mit dem HRworks-Namen der Art – findet es keinen, meldet der Probelauf ihn als fehlend.
einen Task mit dem HRworks-Namen der Art – findet es keinen, meldet der Probelauf ihn als fehlend. Der Haken{' '}
<b>Kalender</b> legt fest, welche Arten im Kalender stehen, den alle Mitarbeiter sehen – Krankmeldungen nie.
</p>
<div className="grid gap-2 sm:grid-cols-2">
{eintraege.map(([key, value]) => (
@@ -159,6 +163,25 @@ export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () =>
value={value}
onChange={(e) => set({ type_map: { ...form.type_map, [key]: e.target.value } })}
/>
{key.startsWith('absence:') ? (
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-[11px] font-bold text-ink-500" title="Im Kalender für alle zeigen">
<input
type="checkbox"
className="accent-[var(--color-brand-500)]"
checked={(form.calendar_types ?? []).includes(key)}
onChange={(e) =>
set({
calendar_types: e.target.checked
? [...(form.calendar_types ?? []), key]
: (form.calendar_types ?? []).filter((t) => t !== key),
})
}
/>
Kalender
</label>
) : (
<span className="w-[66px] shrink-0" />
)}
</div>
))}
</div>
@@ -193,6 +216,12 @@ export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () =>
: ''}
</p>
)}
{status?.calendar?.last_run && (
<p className="mt-1 text-xs text-ink-400">
Kalender: {new Date(status.calendar.last_run).toLocaleString('de-DE')} – {status.calendar.vacation_days} Urlaubstage,{' '}
{status.calendar.holidays} Feiertage.
</p>
)}
</Card>
</div>
);
+30
View File
@@ -175,6 +175,8 @@ export interface HrworksSettings {
type_map: Record<string, string>;
holiday_task: string;
half_day_hours: number;
/** Abwesenheitsarten ("absence:<key>") im Kalender, den alle sehen. */
calendar_types: string[];
last_run: string | null;
last_result: { created: number; updated: number; removed: number; unchanged: number; hours: number; failed: number } | null;
}
@@ -190,6 +192,8 @@ export interface HrworksStatus {
type_map: Record<string, string>;
last_run: string | null;
last_result: HrworksSettings['last_result'];
calendar_types: string[];
calendar: { last_run: string | null; vacation_days: number; holidays: number };
entries: number;
hours: number;
first_date: string | null;
@@ -212,6 +216,8 @@ export interface HrworksReport {
unmatched_persons: { person_id: string; name: string; email: string | null }[];
missing_tasks: string[];
per_person: Record<string, Record<string, { hours: number; days: number }>>;
calendar?: { from: string; to: string; vacation_days: number; holidays: number };
calendar_error?: string;
}
export const hrworksApi = {
@@ -227,6 +233,30 @@ export const hrworksApi = {
removeAll: () => send<{ deleted: number }>('DELETE', '/hrworks/entries'),
};
export interface CalendarUser {
id: number;
name: string;
color: string | null;
manager_id: number | null;
is_external: boolean;
}
export interface CalendarDay {
user_id: number;
day: string;
kind: 'vacation' | 'holiday';
label: string;
half: boolean;
}
export interface CalendarData {
users: CalendarUser[];
days: CalendarDay[];
last_run: string | null;
}
export const calendarApi = {
get: (from: string, to: string) => get<CalendarData>(`/calendar${qs({ from, to })}`),
};
export interface AiModel {
id: string;
created: string | null;
+6 -5
View File
@@ -229,12 +229,13 @@ export interface PublicSettings {
project_name_pattern: string;
}
export type ViewKey = 'dashboard' | 'buchung' | 'projekte' | 'team' | 'kunden' | 'reports' | 'einstellungen';
export type ViewKey = 'dashboard' | 'buchung' | 'projekte' | 'kalender' | 'team' | 'kunden' | 'reports' | 'einstellungen';
export const VIEW_LABELS: Record<ViewKey, string> = {
dashboard: 'Dashboard',
buchung: 'Buchung',
projekte: 'Projekte',
kalender: 'Kalender',
team: 'Team',
kunden: 'Kunden',
reports: 'Reports',
@@ -246,15 +247,15 @@ export function viewsFor(role: Role): ViewKey[] {
switch (role) {
case 'user':
case 'external':
return ['buchung', 'projekte'];
return ['buchung', 'projekte', 'kalender'];
case 'lead':
return ['dashboard', 'buchung', 'projekte', 'team', 'kunden', 'reports'];
return ['dashboard', 'buchung', 'projekte', 'kalender', 'team', 'kunden', 'reports'];
case 'management':
// Einstellungen zeigen fuer Management nur die SSO-Verwaltung.
return ['dashboard', 'buchung', 'projekte', 'team', 'kunden', 'reports', 'einstellungen'];
return ['dashboard', 'buchung', 'projekte', 'kalender', 'team', 'kunden', 'reports', 'einstellungen'];
case 'admin':
// Administratoren sind keine Mitarbeiter und buchen keine Zeiten.
return ['dashboard', 'projekte', 'team', 'kunden', 'reports', 'einstellungen'];
return ['dashboard', 'projekte', 'kalender', 'team', 'kunden', 'reports', 'einstellungen'];
}
}