From c0eb00faee2993d83ff9857bb3c0d6ff8a153628 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Fri, 25 Sep 2026 11:37:26 +0200 Subject: [PATCH] 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 --- README.md | 39 ++- deploy/perinco-pulse-hrworks.timer | 8 +- server/src/jobs/hrworks-sync.js | 44 ++- server/src/lib/calendarSync.js | 109 +++++++ server/src/lib/hrworks.js | 11 + server/src/lib/settings.js | 11 + server/src/routes/calendar.js | 50 ++++ server/src/routes/hrworks.js | 20 +- server/src/routes/settings.js | 6 + server/src/schema.sql | 16 ++ server/src/server.js | 3 + server/test/calendar.test.mjs | 49 ++++ src/App.tsx | 3 + src/components/calendar/Calendar.tsx | 298 ++++++++++++++++++++ src/components/calendar/MonthGrid.tsx | 110 ++++++++ src/components/calendar/Timeline.tsx | 97 +++++++ src/components/calendar/calendarData.ts | 76 +++++ src/components/layout/Sidebar.tsx | 1 + src/components/settings/HrworksSettings.tsx | 37 ++- src/lib/api.ts | 30 ++ src/types.ts | 11 +- 21 files changed, 996 insertions(+), 33 deletions(-) create mode 100644 server/src/lib/calendarSync.js create mode 100644 server/src/routes/calendar.js create mode 100644 server/test/calendar.test.mjs create mode 100644 src/components/calendar/Calendar.tsx create mode 100644 src/components/calendar/MonthGrid.tsx create mode 100644 src/components/calendar/Timeline.tsx create mode 100644 src/components/calendar/calendarData.ts diff --git a/README.md b/README.md index 91b03f0..7a9edc7 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,11 @@ Erster Login: Benutzer `admin`, Passwort aus `ADMIN_PASSWORD` in `server/.env` ( | Rolle | Startseite | Menü | | --- | --- | --- | -| Mitarbeiter (`user`) | Buchung | Buchung, Projekte (nur zugewiesene, ohne Beträge) | +| Mitarbeiter (`user`) | Buchung | Buchung, Projekte (nur zugewiesene, ohne Beträge), Kalender | | Externe (`external`) | Buchung | wie Mitarbeiter – die Rolle trennt nur, wer zum Haus gehört; `is_external` wird automatisch gesetzt | -| Vorgesetzter (`lead`) | Buchung | Dashboard (nur eigenes Team, rekursiv), Buchung, Projekte, Team, Kunden, Reports | +| Vorgesetzter (`lead`) | Buchung | Dashboard (nur eigenes Team, rekursiv), Buchung, Projekte, Kalender, Team, Kunden, Reports | | Management | Dashboard | wie Vorgesetzter, sieht alles; Einstellungen nur „SSO-Verwaltung“ | -| Administrator | Einstellungen | Dashboard, Projekte, Team, Kunden, Reports, Einstellungen – keine Buchung, taucht in Auswertungen nicht auf | +| Administrator | Einstellungen | Dashboard, Projekte, Kalender, Team, Kunden, Reports, Einstellungen – keine Buchung, taucht in Auswertungen nicht auf | Die Startseite gilt nach jeder Anmeldung (Passwort und SSO), auch wenn noch eine alte Seite in der Adresse steht; Neuladen bleibt auf der aktuellen Seite. Das Logo oben links führt ebenfalls zur Startseite. @@ -140,7 +140,7 @@ Team, Dashboard, Reports, Projekt- und Kundenansicht. Ohne Bild bleiben die Init ## Abwesenheiten aus HRworks -Urlaub, Krankheit, Elternzeit und Feiertage stehen in HRworks. Pulse holt sie einmal täglich +Urlaub, Krankheit, Elternzeit und Feiertage stehen in HRworks. Pulse holt sie zweimal täglich und bucht sie auf das nicht abrechenbare Projekt `PERINCO-2024-001` („Abwesenheiten"). Geschrieben wird nur in Pulse – nach HRworks geht nichts zurück. @@ -169,9 +169,9 @@ Jede Buchung trägt `pulse_time_entries.hrworks_ref` = `@ a.startsWith('--calendar-from='))?.split('=')[1]; +if (calendarFrom && !/^\d{4}-\d{2}-\d{2}$/.test(calendarFrom)) { + console.error(`[Pulse/Kalender] --calendar-from erwartet JJJJ-MM-TT, nicht "${calendarFrom}".`); + process.exit(1); +} try { await initDatabase(); @@ -23,6 +32,33 @@ try { console.log('[Pulse/HRworks] Abgleich ist in den Einstellungen abgeschaltet - nichts zu tun.'); process.exit(0); } + let failed = false; + try { + await syncBookings(); + } catch (err) { + failed = true; + console.error('[Pulse/HRworks] Abgleich der Buchungen fehlgeschlagen:', err.message); + } + // Der Kalender laeuft auch, wenn die Buchungen scheitern (z. B. Projekt archiviert). + try { + const cal = await syncCalendar({ dryRun, from: calendarFrom }); + console.log( + `[Pulse/Kalender]${dryRun ? ' (Probelauf)' : ''} ${cal.from} bis ${cal.to}: ` + + `${cal.vacation_days} Urlaubstage, ${cal.holidays} Feiertage.` + ); + } catch (err) { + failed = true; + console.error('[Pulse/Kalender] Abgleich fehlgeschlagen:', err.message); + } + process.exit(failed ? 1 : 0); +} catch (err) { + console.error('[Pulse/HRworks] Abgleich fehlgeschlagen:', err.message); + process.exit(1); +} finally { + await pool.end().catch(() => undefined); +} + +async function syncBookings() { const report = await syncHrworks({ dryRun }); console.log( `[Pulse/HRworks]${dryRun ? ' (Probelauf)' : ''} ${report.from} bis ${report.to}: ` + @@ -39,10 +75,4 @@ try { for (const problem of report.failed) { console.warn(`[Pulse/HRworks] ${problem.ref}: ${problem.error}`); } - process.exit(0); -} catch (err) { - console.error('[Pulse/HRworks] Abgleich fehlgeschlagen:', err.message); - process.exit(1); -} finally { - await pool.end().catch(() => undefined); } diff --git a/server/src/lib/calendarSync.js b/server/src/lib/calendarSync.js new file mode 100644 index 0000000..f9f4d49 --- /dev/null +++ b/server/src/lib/calendarSync.js @@ -0,0 +1,109 @@ +import { pool, withTransaction } from '../db.js'; +import { getSettings, saveSetting } from './settings.js'; +import { addDays, today } from './dates.js'; +import { Hrworks, ABSENCE_STATES } from './hrworks.js'; +import { chunks, matchUsers } from './hrworksSync.js'; + +/* + * Kalender aus HRworks: genehmigte Urlaube und Feiertage je Person und Tag, + * auch weit in der Zukunft. + * + * Getrennt von den Buchungen (hrworksSync.js): die entstehen weiterhin erst am + * jeweiligen Tag. Hier landet nur, was im Kalender zu sehen sein soll - und den + * sehen alle Benutzer. Deshalb: + * - nur Abwesenheitsarten aus `calendar_types` (Urlaub), nur `validatedOk` + * - Krankmeldungen werden gar nicht erst abgefragt; ein Urlaubstag bleibt + * auch dann stehen, wenn an ihm eine Krankmeldung haengt - sonst liesse + * sich die Krankheit an der Luecke ablesen. + * + * Jeder Lauf ersetzt das Fenster komplett (loeschen und neu schreiben, in + * einer Transaktion). Was davor liegt, bleibt als Rueckblick stehen. + */ + +/** Wie weit reichen lange Urlaube vor das Fenster zurueck? Fuer die Artabfrage. */ +const LOOKBACK_DAYS = 90; + +/** + * Was steht an einem Tag im Kalender? Feiertag vor Urlaub; Wochenenden und + * freie Tage (Soll 0) tragen keinen Urlaub. + */ +export function kalenderEintrag(day, meta, types, typeMap = {}) { + if (day.holiday) return { kind: 'holiday', label: day.holiday_name || 'Feiertag', half: false }; + if (!(day.regular_hours > 0)) return null; + for (const id of day.absences) { + const info = meta.get(id); + if (!info || !ABSENCE_STATES.includes(info.status)) continue; + const key = `absence:${info.type}`; + if (!types.includes(key)) continue; + return { + kind: 'vacation', + label: typeMap[key] || info.name || 'Urlaub', + // Halber Tag: es bleibt noch Arbeitszeit uebrig. + half: day.available_hours > 0 && day.available_hours < day.regular_hours, + }; + } + return null; +} + +/** + * @param {object} options + * @param {boolean} options.dryRun nichts schreiben, nur zaehlen + * @param {string} [options.from] Standard: heute minus window_days + * @param {string} [options.to] Standard: heute plus calendar_days_ahead + */ +export async function syncCalendar({ dryRun = true, from, to } = {}) { + const { hrworks: config } = await getSettings(); + const heute = today(); + const von = from || addDays(heute, -Number(config.window_days || 60)); + const bis = to || addDays(heute, Number(config.calendar_days_ahead || 400)); + const types = Array.isArray(config.calendar_types) ? config.calendar_types.filter((t) => t.startsWith('absence:')) : []; + + const hr = new Hrworks(config); + const persons = await hr.persons(); + const [users] = await pool.query("SELECT id, name, email FROM pulse_users WHERE is_active = 1 AND role <> 'admin'"); + const { matched, unmatched } = matchUsers(persons, users); + + const absences = await hr.absences(addDays(von, -LOOKBACK_DAYS), bis); + const meta = new Map(absences.map((a) => [a.number, { type: String(a.type), name: a.name, status: a.statusIdentifier }])); + + // Je Person und Tag ein Eintrag - auch wenn zwei HRworks-Konten auf + // denselben Pulse-Benutzer zeigen. + const byKey = new Map(); + const personIds = [...matched.keys()]; + for (const [start, end] of chunks(von, bis)) { + for (const day of await hr.days(start, end, personIds)) { + const user = matched.get(day.person); + if (!user) continue; + const eintrag = kalenderEintrag(day, meta, types, config.type_map); + if (eintrag && !byKey.has(`${user.id}@${day.date}`)) { + byKey.set(`${user.id}@${day.date}`, [user.id, day.date, eintrag.kind, eintrag.label, eintrag.half ? 1 : 0]); + } + } + } + const rows = [...byKey.values()]; + + const bericht = { + dry_run: dryRun, + from: von, + to: bis, + vacation_days: rows.filter((r) => r[2] === 'vacation').length, + holidays: rows.filter((r) => r[2] === 'holiday').length, + unmatched_persons: unmatched, + }; + if (dryRun) return bericht; + + await withTransaction(async (conn) => { + await conn.query('DELETE FROM pulse_calendar_days WHERE day BETWEEN ? AND ?', [von, bis]); + for (let i = 0; i < rows.length; i += 500) { + await conn.query('INSERT INTO pulse_calendar_days (user_id, day, kind, label, half) VALUES ?', [rows.slice(i, i + 500)]); + } + }); + + bericht.finished_at = new Date().toISOString(); + await saveSetting('calendar_status', { + last_run: bericht.finished_at, + vacation_days: bericht.vacation_days, + holidays: bericht.holidays, + }); + return bericht; +} diff --git a/server/src/lib/hrworks.js b/server/src/lib/hrworks.js index d690bc7..f601bcd 100644 --- a/server/src/lib/hrworks.js +++ b/server/src/lib/hrworks.js @@ -162,6 +162,9 @@ export class Hrworks { sick_leaves: (events.sickLeaves || []).map((e) => e?.id).filter(Boolean), // Feiertage tragen keine id, nur das Vorhandensein zaehlt. holiday: Array.isArray(events.holidays) && events.holidays.length > 0, + // Fuer den Kalender; wie der Eintrag genau aussieht, ist nicht + // dokumentiert - fehlt der Name, heisst er dort schlicht "Feiertag". + holiday_name: holidayName(events.holidays), }); } } @@ -169,6 +172,14 @@ export class Hrworks { } } +export function holidayName(holidays) { + for (const h of Array.isArray(holidays) ? holidays : []) { + const name = typeof h === 'string' ? h : h?.name || h?.description || h?.title; + if (typeof name === 'string' && name.trim()) return name.trim().slice(0, 160); + } + return null; +} + /** { person: [{ absences: [...] }] } -> [{ person, ...absence }] */ function flatten(data, key) { const out = []; diff --git a/server/src/lib/settings.js b/server/src/lib/settings.js index 20d64d1..38612aa 100644 --- a/server/src/lib/settings.js +++ b/server/src/lib/settings.js @@ -64,6 +64,14 @@ export const DEFAULTS = { 'sick:KWW': 'Krankheit', 'sick:KEK': 'Kind krank', }, + /* + * Welche Abwesenheitsarten im Kalender stehen, den alle sehen. Nur + * Urlaub - Elternzeit, Mutterschutz & Co. sagen mehr ueber eine Person, + * als die Kollegen wissen muessen. Krankheit ist nie waehlbar. + */ + calendar_types: ['absence:1', 'absence:10'], + // So weit reicht der Kalender in die Zukunft. + calendar_days_ahead: 400, // Task fuer Feiertage; leer = Feiertage nicht buchen. holiday_task: 'Feiertag', // Halber Tag: feste Stundenzahl (0 = aus HRworks uebernehmen). @@ -71,6 +79,9 @@ export const DEFAULTS = { last_run: null, last_result: null, }, + // Letzter Kalenderabgleich - eigener Schluessel, damit sich die beiden + // Laeufe ihre Ergebnisse nicht gegenseitig ueberschreiben. + calendar_status: { last_run: null, vacation_days: 0, holidays: 0 }, /* * KI-Assistent im Kopf der Anwendung. Der Schluessel liegt verschluesselt, * genau wie das SSO-Secret; das Modell ist einstellbar, weil OpenAI die diff --git a/server/src/routes/calendar.js b/server/src/routes/calendar.js new file mode 100644 index 0000000..24181dc --- /dev/null +++ b/server/src/routes/calendar.js @@ -0,0 +1,50 @@ +import express from 'express'; +import { pool } from '../db.js'; +import { getSettings } from '../lib/settings.js'; +import { daysBetween, parseDate } from '../lib/dates.js'; + +/* + * Kalender: Urlaube und Feiertage aller Mitarbeiter - fuer jeden Angemeldeten + * (Absprache). Die Daten stellt der HRworks-Abgleich bereit + * (lib/calendarSync.js); hier wird nur gelesen. Krankmeldungen stehen in der + * Tabelle gar nicht erst. + */ + +const router = express.Router(); + +/** Hoechstens so viele Tage je Abfrage - eine Monatsansicht braucht 42. */ +const MAX_DAYS = 400; + +router.get('/', async (req, res, next) => { + try { + const { from, to } = req.query; + if (!parseDate(from) || !parseDate(to) || to < from) { + return res.status(400).json({ error: 'Zeitraum ungültig (from/to als JJJJ-MM-TT).' }); + } + if (daysBetween(from, to) > MAX_DAYS) return res.status(400).json({ error: `Höchstens ${MAX_DAYS} Tage je Abfrage.` }); + + // Nur, was der Kalender braucht - keine Profilbilder, keine Mailadressen. + const [users] = await pool.query( + `SELECT id, name, color, manager_id, is_external + FROM pulse_users WHERE is_active = 1 AND role <> 'admin' ORDER BY name` + ); + const [days] = await pool.query( + `SELECT c.user_id, c.day, c.kind, c.label, c.half + FROM pulse_calendar_days c + JOIN pulse_users u ON u.id = c.user_id AND u.is_active = 1 AND u.role <> 'admin' + WHERE c.day BETWEEN ? AND ? + ORDER BY c.day, c.user_id`, + [from, to] + ); + const { calendar_status } = await getSettings(); + res.json({ + users: users.map((u) => ({ ...u, is_external: Boolean(u.is_external) })), + days: days.map((d) => ({ ...d, half: Boolean(d.half) })), + last_run: calendar_status.last_run, + }); + } catch (err) { + next(err); + } +}); + +export default router; diff --git a/server/src/routes/hrworks.js b/server/src/routes/hrworks.js index 65349dd..8c5123e 100644 --- a/server/src/routes/hrworks.js +++ b/server/src/routes/hrworks.js @@ -3,6 +3,7 @@ import { pool } from '../db.js'; import { getSettings } from '../lib/settings.js'; import { Hrworks } from '../lib/hrworks.js'; import { syncHrworks } from '../lib/hrworksSync.js'; +import { syncCalendar } from '../lib/calendarSync.js'; /* * Abwesenheiten aus HRworks - Bedienung und Kontrolle. @@ -16,7 +17,7 @@ const router = express.Router(); /** Was ist eingerichtet, was hat der letzte Lauf gebracht? */ router.get('/status', async (req, res, next) => { try { - const { hrworks } = await getSettings(); + const { hrworks, calendar_status } = await getSettings(); const [[project]] = await pool.query('SELECT id, code, name, type, start_date FROM pulse_projects WHERE code = ? LIMIT 1', [ hrworks.project_code, ]); @@ -35,6 +36,8 @@ router.get('/status', async (req, res, next) => { type_map: hrworks.type_map, last_run: hrworks.last_run, last_result: hrworks.last_result, + calendar_types: hrworks.calendar_types, + calendar: calendar_status, entries: Number(n), hours: Math.round((Number(range.minutes) / 60) * 100) / 100, first_date: range.von, @@ -77,11 +80,16 @@ router.post('/test', async (req, res, next) => { */ router.post('/sync', async (req, res, next) => { try { - const bericht = await syncHrworks({ - dryRun: req.body?.apply !== true, - from: req.body?.from, - to: req.body?.to, - }); + const dryRun = req.body?.apply !== true; + const bericht = await syncHrworks({ dryRun, from: req.body?.from, to: req.body?.to }); + // Der Kalender zieht beim Uebernehmen gleich mit - er hat sein eigenes Fenster. + if (!dryRun) { + try { + bericht.calendar = await syncCalendar({ dryRun: false }); + } catch (err) { + bericht.calendar_error = err.message; + } + } res.json(bericht); } catch (err) { if (err.status) return res.status(400).json({ error: err.message }); diff --git a/server/src/routes/settings.js b/server/src/routes/settings.js index ae8d150..86fcbea 100644 --- a/server/src/routes/settings.js +++ b/server/src/routes/settings.js @@ -118,6 +118,12 @@ router.put('/', requireRole('admin'), async (req, res, next) => { } next_.type_map = map; } + // Kalender: nur Abwesenheiten, nie Krankmeldungen - den sehen alle. + if (Array.isArray(body.hrworks.calendar_types)) { + next_.calendar_types = [ + ...new Set(body.hrworks.calendar_types.map((t) => String(t)).filter((t) => /^absence:[A-Za-z0-9_-]{1,20}$/.test(t))), + ]; + } // Leer = unveraendert; der geheime Teil wird nie ausgeliefert. if (body.hrworks.secret_access_key) next_.secret_access_key = text(body.hrworks.secret_access_key, 500).trim(); if (body.hrworks.clear_secret) next_.secret_access_key = ''; diff --git a/server/src/schema.sql b/server/src/schema.sql index 94cb4fc..ae08fae 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -164,3 +164,19 @@ CREATE TABLE IF NOT EXISTS pulse_settings ( updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (k) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- Kalender: genehmigte Urlaube und Feiertage aus HRworks, ein Eintrag je +-- Person und Tag - auch in der Zukunft. Getrennt von den Buchungen, die erst +-- am jeweiligen Tag entstehen. Krankmeldungen stehen hier bewusst nie: alle +-- Benutzer sehen den Kalender. +CREATE TABLE IF NOT EXISTS pulse_calendar_days ( + user_id INT UNSIGNED NOT NULL, + day DATE NOT NULL, + kind ENUM('vacation','holiday') NOT NULL, + label VARCHAR(160) NOT NULL, + half TINYINT(1) NOT NULL DEFAULT 0, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, day), + KEY idx_pulse_calendar_day (day), + CONSTRAINT fk_pulse_calendar_user FOREIGN KEY (user_id) REFERENCES pulse_users (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/server/src/server.js b/server/src/server.js index bf03c21..08746b8 100644 --- a/server/src/server.js +++ b/server/src/server.js @@ -16,6 +16,7 @@ import directoryRoutes from './routes/directory.js'; import importRoutes from './routes/imports.js'; import hrworksRoutes from './routes/hrworks.js'; import assistantRoutes from './routes/assistant.js'; +import calendarRoutes from './routes/calendar.js'; const app = express(); const PORT = Number(process.env.PORT || 5010); @@ -50,6 +51,8 @@ app.use('/api/entries', requireAuth, entryRoutes); app.use('/api/dashboard', requireAuth, requireRole('lead'), dashboardRoutes); app.use('/api/reports', requireAuth, requireRole('lead'), reportRoutes); app.use('/api/exports', requireAuth, exportRoutes); +// Urlaube und Feiertage - fuer alle Angemeldeten. +app.use('/api/calendar', requireAuth, calendarRoutes); app.use('/api/settings', requireAuth, settingsRoutes); app.use('/api/directory', requireAuth, requireRole('management'), directoryRoutes); // Uebernahme aus Fremdsystemen - nur Administratoren. diff --git a/server/test/calendar.test.mjs b/server/test/calendar.test.mjs new file mode 100644 index 0000000..7be760f --- /dev/null +++ b/server/test/calendar.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { kalenderEintrag } from '../src/lib/calendarSync.js'; +import { holidayName } from '../src/lib/hrworks.js'; + +const tag = (extra = {}) => ({ holiday: false, holiday_name: null, absences: [], sick_leaves: [], regular_hours: 8, available_hours: 0, ...extra }); +const meta = new Map([ + ['30_1', { type: '1', name: 'Urlaub', status: 'validatedOk' }], + ['30_2', { type: '1', name: 'Urlaub', status: 'requested' }], + ['30_3', { type: '3', name: 'Elternzeit', status: 'validatedOk' }], +]); +const types = ['absence:1']; + +test('Kalender: nur genehmigter Urlaub der freigegebenen Arten', () => { + assert.deepEqual(kalenderEintrag(tag({ absences: ['30_1'] }), meta, types, { 'absence:1': 'Urlaub' }), { + kind: 'vacation', + label: 'Urlaub', + half: false, + }); + assert.equal(kalenderEintrag(tag({ absences: ['30_2'] }), meta, types), null, 'nur beantragt'); + assert.equal(kalenderEintrag(tag({ absences: ['30_3'] }), meta, types), null, 'Art nicht freigegeben'); + assert.equal(kalenderEintrag(tag({ absences: ['99_9'] }), meta, types), null, 'unbekannt'); + assert.equal(kalenderEintrag(tag(), meta, types), null); +}); + +test('Kalender: Feiertag vor Urlaub, Wochenende ohne Urlaub, halbe Tage', () => { + assert.deepEqual(kalenderEintrag(tag({ holiday: true, holiday_name: 'Tag der Deutschen Einheit', absences: ['30_1'] }), meta, types), { + kind: 'holiday', + label: 'Tag der Deutschen Einheit', + half: false, + }); + assert.equal(kalenderEintrag(tag({ holiday: true }), meta, types).label, 'Feiertag'); + assert.equal(kalenderEintrag(tag({ absences: ['30_1'], regular_hours: 0 }), meta, types), null); + assert.equal(kalenderEintrag(tag({ absences: ['30_1'], available_hours: 4 }), meta, types).half, true); +}); + +test('Kalender: eine Krankmeldung verraet sich nicht', () => { + // Urlaub bleibt stehen, auch wenn an dem Tag eine Krankmeldung haengt ... + assert.equal(kalenderEintrag(tag({ absences: ['30_1'], sick_leaves: ['31_9'] }), meta, types).kind, 'vacation'); + // ... und Krankheit allein erscheint nie. + assert.equal(kalenderEintrag(tag({ sick_leaves: ['31_9'] }), meta, types), null); +}); + +test('Feiertagsname aus unterschiedlichen Formen', () => { + assert.equal(holidayName([{ name: 'Neujahr' }]), 'Neujahr'); + assert.equal(holidayName(['Karfreitag']), 'Karfreitag'); + assert.equal(holidayName([{}]), null); + assert.equal(holidayName(undefined), null); +}); diff --git a/src/App.tsx b/src/App.tsx index f9ade93..be30123 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 = { 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' && } {view === 'buchung' && } {view === 'projekte' && } + {view === 'kalender' && } {view === 'team' && } {view === 'kunden' && } {view === 'reports' && } diff --git a/src/components/calendar/Calendar.tsx b/src/components/calendar/Calendar.tsx new file mode 100644 index 0000000..d06076b --- /dev/null +++ b/src/components/calendar/Calendar.tsx @@ -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('month'); + const [month, setMonth] = useState(monthStart(today)); + const [selected, setSelected] = useState(today); + const [team, setTeam] = usePersistentState('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 ( +
+ {/* Werkzeugleiste */} +
+
+ + + +
+

{label}

+ +
+ + +
+ + setSearch(e.target.value)} + aria-label="Name suchen" + /> +
+
+
+ + {data.error && } + {data.loading && !data.data ? ( + + ) : data.data && !data.data.last_run && data.data.days.length === 0 ? ( + + ) : ( +
+
+ {mode === 'month' ? ( + + ) : ( + <> + + {timelineUsers.length ? ( + + ) : ( + + )} + + )} + +
+ +
+ {selected && ( + +

{formatDate(selected, 'long')}

+ {selectedHolidays.length > 0 && ( +

+ + {selectedHolidays.join(' · ')} +

+ )} + {selectedAway.length ? ( +
    + {selectedAway.map(({ entry, user }) => ( +
  • + + + {user.name} + + {entry.label} + {entry.half ? ' · halber Tag' : ''} + + +
  • + ))} +
+ ) : ( +

Niemand im Urlaub.

+ )} +
+ )} + + +

Urlaub im {MONTHS[fromIso(month).getMonth()]}

+ {monthly.length ? ( +
    + {monthly.map(({ user, ranges }) => ( +
  • + + + {user.name} + + {ranges.map((r) => `${rangeLabel(r.from, r.to)} (${dayCount(r.days)})`).join(', ')} + + +
  • + ))} +
+ ) : ( +

In diesem Monat ist kein Urlaub eingetragen.

+ )} + {monthHolidays.length > 0 && ( +
+

Feiertage

+
    + {monthHolidays.map((h) => ( +
  • + {formatDate(h.day, 'day')} {h.labels.join(' · ')} +
  • + ))} +
+
+ )} +
+
+
+ )} +
+ ); +}; + +const Legend: React.FC<{ lastRun: string | null }> = ({ lastRun }) => ( +
+ + Urlaub (genehmigt) + + + halber Tag + + + Feiertag + + {lastRun && Stand HRworks: {new Date(lastRun).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })}} +
+); diff --git a/src/components/calendar/MonthGrid.tsx b/src/components/calendar/MonthGrid.tsx new file mode 100644 index 0000000..da5a42c --- /dev/null +++ b/src/components/calendar/MonthGrid.tsx @@ -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; + names: Map; + 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 ( +
+
+ {WEEKDAYS_SHORT.map((d) => ( +

+ {d} +

+ ))} +
+
+ {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 ( + + ); + })} +
+
+ ); +}; diff --git a/src/components/calendar/Timeline.tsx b/src/components/calendar/Timeline.tsx new file mode 100644 index 0000000..d765bb6 --- /dev/null +++ b/src/components/calendar/Timeline.tsx @@ -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 ( +
+ + + + + {days.map((day) => { + const weekend = weekdayIndex(day) >= 5; + const holidays = holidaysOn(index, day); + return ( + + ); + })} + + + + {users.map((user) => { + const entries = index.byUser.get(user.id); + const color = userColor(user); + return ( + + + {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 ( + + ); + })} + + ); + })} + +
+ Mitarbeiter + 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' + }`} + > + {WEEKDAYS_SHORT[weekdayIndex(day)].slice(0, 2)} + {fromIso(day).getDate()} +
+ + + {user.name} + + 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 && ( + + )} + {entry?.kind === 'holiday' && } +
+
+ ); +}; diff --git a/src/components/calendar/calendarData.ts b/src/components/calendar/calendarData.ts new file mode 100644 index 0000000..1a8ef3c --- /dev/null +++ b/src/components/calendar/calendarData.ts @@ -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; + byUser: Map>; +} + +export function buildIndex(days: CalendarDay[], visible: Set): CalendarIndex { + const byDay = new Map(); + const byUser = new Map>(); + 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 { + const first = (name: string) => name.trim().split(/\s+/)[0] ?? name; + const count = new Map(); + 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) => colorFor(u.id, u.color); + +/** + * Urlaubstage einer Person zu Zeitraeumen zusammenfassen. Wochenenden und + * Feiertage dazwischen unterbrechen einen Urlaub nicht. + */ +export function vacationRanges(entries: Map | 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; +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 06779f5..860606f 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -15,6 +15,7 @@ export const VIEW_ICONS: Record = { dashboard: 'dashboard', buchung: 'clock', projekte: 'folder', + kalender: 'calendar', team: 'team', kunden: 'building', reports: 'chart', diff --git a/src/components/settings/HrworksSettings.tsx b/src/components/settings/HrworksSettings.tsx index 3c87f31..18e8ed5 100644 --- a/src/components/settings/HrworksSettings.tsx +++ b/src/components/settings/HrworksSettings.tsx @@ -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: () => {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: () =>

Links der Schlüssel aus HRworks (absence: oder{' '} sick:), 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{' '} + Kalender legt fest, welche Arten im Kalender stehen, den alle Mitarbeiter sehen – Krankmeldungen nie.

{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:') ? ( + + ) : ( + + )}
))} @@ -193,6 +216,12 @@ export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () => : ''}

)} + {status?.calendar?.last_run && ( +

+ Kalender: {new Date(status.calendar.last_run).toLocaleString('de-DE')} – {status.calendar.vacation_days} Urlaubstage,{' '} + {status.calendar.holidays} Feiertage. +

+ )} ); diff --git a/src/lib/api.ts b/src/lib/api.ts index 2288ce9..c3b7d41 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -175,6 +175,8 @@ export interface HrworksSettings { type_map: Record; holiday_task: string; half_day_hours: number; + /** Abwesenheitsarten ("absence:") 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; 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>; + 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(`/calendar${qs({ from, to })}`), +}; + export interface AiModel { id: string; created: string | null; diff --git a/src/types.ts b/src/types.ts index b561599..9d83cf5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = { 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']; } }