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
+31 -8
View File
@@ -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` = `<Abwesenheitsnummer>@<Ta
Ein zweiter Lauf ändert deshalb nichts; was in HRworks storniert oder verschoben wurde,
verschwindet im nächsten Lauf. `DELETE /api/hrworks/entries` entfernt genau diese Buchungen.
**Täglich** läuft `server/src/jobs/hrworks-sync.js` über den systemd-Timer
`perinco-pulse-hrworks.timer` (5:30 Uhr Berliner Zeit, `Persistent=true` holt verpasste Läufe
nach). Ausgang im Journal: `journalctl -u perinco-pulse-hrworks`. Die Unit-Dateien liegen in
**Zweimal täglich** läuft `server/src/jobs/hrworks-sync.js` über den systemd-Timer
`perinco-pulse-hrworks.timer` (5:30 und 12:00 Uhr Berliner Zeit, `Persistent=true` holt verpasste Läufe
nach) – erst die Buchungen, dann der Kalender (siehe unten). Ausgang im Journal: `journalctl -u perinco-pulse-hrworks`. Die Unit-Dateien liegen in
`deploy/`; sie werden nach `/etc/systemd/system/` kopiert und einmal mit
`systemctl enable --now perinco-pulse-hrworks.timer` eingeschaltet.
@@ -180,6 +180,28 @@ nach). Ausgang im Journal: `journalctl -u perinco-pulse-hrworks`. Die Unit-Datei
Billable und Festpreis Sinn. Am Abwesenheitsprojekt wäre er nur ein Ablaufdatum, das irgendwann
Buchungen blockiert; es hat deshalb keinen.
### Kalender
Menüpunkt **Kalender** für alle Rollen: genehmigte Urlaube aller Mitarbeiter – auch die der Zukunft –
und Feiertage. Ansichten **Monat** (Standard, jedes Öffnen) und **Zeitleiste** (eine Zeile je Person,
Überschneidungen auf einen Blick); Filter nach Team (Vorgesetzter samt allen darunter) und Name. Klick auf
einen Tag zeigt rechts, wer weg ist; darunter die Urlaube des Monats als Zeiträume.
Die Buchungen entstehen weiter erst am jeweiligen Tag. Der Kalender hat deshalb eine eigene Tabelle
`pulse_calendar_days` (ein Eintrag je Person und Tag: `vacation` oder `holiday`, halber Tag), gefüllt von
`server/src/lib/calendarSync.js` im selben Timer-Lauf. Fenster: `window_days` zurück bis
`calendar_days_ahead` (400) Tage voraus; jeder Lauf ersetzt das Fenster komplett, ältere Tage bleiben als
Rückblick. Einmalig weiter zurück: `node src/jobs/hrworks-sync.js --calendar-from=2026-01-01`.
„Jetzt übernehmen“ in den Einstellungen füllt den Kalender gleich mit.
| Regel | Umsetzung |
| --- | --- |
| Status | nur `validatedOk` – beantragte Urlaube erscheinen nicht |
| Arten | nur `calendar_types` (Voreinstellung Urlaub `absence:1` und Bildungsurlaub `absence:10`), Haken **Kalender** in *Zuordnung der Arten* |
| Krankheit | wird für den Kalender gar nicht abgefragt; ein Urlaubstag bleibt stehen, auch wenn eine Krankmeldung daran hängt – sonst verriete die Lücke die Krankheit |
| Feiertage | aus HRworks je Person (Standort), mit Namen, sofern HRworks einen liefert |
| API | `GET /api/calendar?from=&to=` (jeder Angemeldete, höchstens 400 Tage): Name, Farbe, Vorgesetzter – keine Mails, keine Profilbilder |
## KI-Assistent
**Einrichtung** (nur Administrator, *Einstellungen → KI-Assistent*): OpenAI-Schlüssel eintragen
@@ -286,7 +308,8 @@ Aus Auftrag und Call-Mitschrift.
| Klick auf einen Mitarbeiter im Zeitreport zeigt dessen Buchungen des Zeitraums als Liste | ✅ | Reports → Zeitreport → Mitarbeiter |
| Kunden: zusätzliche Listenansicht | ✅ | Kunden → Karten/Liste |
| Eigenes Profilbild hochladen | ✅ | Mein Konto, Team → Bearbeiten |
| Abwesenheiten automatisch aus HRworks | ✅ täglich 5:30 Uhr | Einstellungen → Abwesenheiten (HRworks) |
| Abwesenheiten automatisch aus HRworks | ✅ täglich 5:30 und 12:00 Uhr | Einstellungen → Abwesenheiten (HRworks) |
| Kalender mit allen genehmigten Urlauben (auch zukünftig) und Feiertagen, für alle | ✅ Monat / Zeitleiste, Filter Team + Name | Kalender |
| Bestellnummer je Budgettopf, erscheint im Stundennachweis | ✅ | Projekt → Budgettopf |
| Rolle „Externe" mit den Rechten eines Mitarbeiters | ✅ | Team → Bearbeiten |
| Export: Kunde und Projekt nur aus den eigenen Zuweisungen | ✅ | Buchung → Export |
+5 -3
View File
@@ -1,12 +1,14 @@
[Unit]
Description=Taeglicher Abgleich der Abwesenheiten aus HRworks
Description=Abgleich der Abwesenheiten aus HRworks (5:30 und 12:00 Uhr)
[Timer]
# Frueh am Morgen, bevor jemand seine Zeiten ansieht. RandomizedDelay
# Frueh am Morgen, bevor jemand seine Zeiten ansieht, und mittags noch
# einmal - dann stehen am Vormittag genehmigte Urlaube im Kalender. RandomizedDelay
# verteilt die Last, Persistent holt einen verpassten Lauf nach (Neustart).
# Ortszeit im Kalenderausdruck - der Server laeuft auf UTC, der Lauf soll
# aber morgens in Berlin liegen, auch nach der Zeitumstellung.
# aber zur Berliner Uhrzeit liegen, auch nach der Zeitumstellung.
OnCalendar=*-*-* 05:30:00 Europe/Berlin
OnCalendar=*-*-* 12:00:00 Europe/Berlin
RandomizedDelaySec=300
Persistent=true
Unit=perinco-pulse-hrworks.service
+37 -7
View File
@@ -1,5 +1,6 @@
/*
* Taeglicher Abgleich der Abwesenheiten aus HRworks.
* Abgleich der Abwesenheiten aus HRworks (morgens und mittags): erst die
* Buchungen bis heute, dann der Kalender mit den Urlauben der Zukunft.
*
* Laeuft als eigener Prozess ueber einen systemd-Timer, nicht im Webdienst:
* ein haengender Fremdaufruf soll die Anwendung nicht blockieren, und der
@@ -8,13 +9,21 @@
* Aufruf:
* node src/jobs/hrworks-sync.js echter Lauf
* node src/jobs/hrworks-sync.js --dry-run nur berichten
* node src/jobs/hrworks-sync.js --calendar-from=2026-01-01
* Kalender einmalig weiter zurueck fuellen
*/
import 'dotenv/config';
import { initDatabase, pool } from '../db.js';
import { getSettings } from '../lib/settings.js';
import { syncHrworks } from '../lib/hrworksSync.js';
import { syncCalendar } from '../lib/calendarSync.js';
const dryRun = process.argv.includes('--dry-run');
const calendarFrom = process.argv.find((a) => 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);
}
+109
View File
@@ -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;
}
+11
View File
@@ -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 = [];
+11
View File
@@ -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
+50
View File
@@ -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;
+14 -6
View File
@@ -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 });
+6
View File
@@ -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 = '';
+16
View File
@@ -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;
+3
View File
@@ -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.
+49
View File
@@ -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);
});
+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'];
}
}