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:
co-authored by
Claude Opus 5.5
parent
6ae5a01bda
commit
c0eb00faee
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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'];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user