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
+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.