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 */}
+
+
+ shift(-1)} className="rounded-l-xl p-2.5 text-ink-500 hover:bg-ink-50 hover:text-ink-900" aria-label="Vorheriger Monat">
+
+
+
+ Heute
+
+ 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">
+
+
+
+
{label}
+
+
+
+
`lead:${l.id}` === team) ? 'all' : team}
+ onChange={(e) => setTeam(e.target.value)}
+ title="Team filtern"
+ >
+ Alle Mitarbeiter
+ {leads.map((l) => (
+
+ Team {l.name}
+
+ ))}
+
+
+
+ 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' ? (
+
+ ) : (
+ <>
+
+ setOnlyAway(e.target.checked)} />
+ Nur Mitarbeiter mit Urlaub in diesem Monat
+
+ {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 (
+ 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}
+ >
+
+
+ {fromIso(date).getDate()}
+
+ {away.length > 0 && isMobile && {away.length} }
+
+ {holidays.length > 0 && !isMobile && (
+ {holidays.join(' · ')}
+ )}
+ {isMobile ? (
+ away.length > 0 && (
+
+ {away.slice(0, maxChips).map(({ entry, user }) => (
+
+ ))}
+
+ )
+ ) : (
+
+ {away.slice(0, maxChips).map(({ entry, user }) => (
+
+
+ {names.get(user.id)}
+ {entry.half && ½ }
+
+ ))}
+ {away.length > maxChips && +{away.length - maxChips} weitere }
+
+ )}
+
+ );
+ })}
+
+
+ );
+};
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 (
+
+
+
+
+
+ Mitarbeiter
+
+ {days.map((day) => {
+ const weekend = weekdayIndex(day) >= 5;
+ const holidays = holidaysOn(index, day);
+ return (
+ 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()}
+
+ );
+ })}
+
+
+
+ {users.map((user) => {
+ const entries = index.byUser.get(user.id);
+ const color = userColor(user);
+ return (
+
+
+
+
+ {user.name}
+
+
+ {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 (
+ 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:') ? (
+
+
+ set({
+ calendar_types: e.target.checked
+ ? [...(form.calendar_types ?? []), key]
+ : (form.calendar_types ?? []).filter((t) => t !== key),
+ })
+ }
+ />
+ Kalender
+
+ ) : (
+
+ )}
))}
@@ -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'];
}
}