feat(hrworks): Abwesenheiten taeglich aus HRworks uebernehmen
Urlaub, Krankheit, Elternzeit und Feiertage stehen in HRworks. Pulse holt sie einmal taeglich und bucht sie auf das nicht abrechenbare Projekt "Abwesenheiten". Nach HRworks geht nichts zurueck, es wird nur gelesen. Die Stunden rechnet HRworks selbst: available-working-hours liefert je Person und Tag das Soll und die verfuegbaren Stunden, die Differenz sind die Abwesenheitsstunden. Damit stimmen Teilzeit und halbe Tage ohne eigenen Kalender. relatedEvents nennt den Auslöser; Vorrang hat der Feiertag, dann Krankheit, dann Abwesenheit. Abgesprochene Regeln: nur genehmigte Abwesenheiten (validatedOk), bei Krankmeldungen auch offene Bescheinigungen; nur bis heute buchen, der Rest waechst taeglich nach; halbe Tage feste 4 Stunden; jeder aktive Mitarbeiter wird dem Projekt zugewiesen, auch neue. Jede Buchung traegt hrworks_ref = "<Nummer>@<Tag>" (unique). Ein zweiter Lauf aendert nichts, Stornos und Verschiebungen verschwinden im naechsten Lauf, DELETE /api/hrworks/entries entfernt genau diese Buchungen. Nicht abrechenbare Projekte brauchen keinen Budgettopf mehr. Ein Topf traegt Volumen, Laufzeit und Satz - das hat nur bei Billable und Festpreis Sinn. Am Abwesenheitsprojekt war er nur ein Ablaufdatum, das irgendwann Buchungen blockiert haette. Taeglicher Lauf ueber systemd (perinco-pulse-hrworks.timer, 5:30 Uhr Berliner Zeit, Persistent). Bedienung unter Einstellungen -> Abwesenheiten (HRworks) mit Verbindungstest und Probelauf; der geheime Schluessel liegt AES-verschluesselt in der Datenbank. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
994f39daff
commit
532b15ce99
@@ -14,3 +14,9 @@ Anforderungen.txt
|
||||
/Logo*.png
|
||||
/Logo*.jpg
|
||||
/Pulse-Logo*.png
|
||||
|
||||
# Zugangsdaten, die nie ins Repository gehoeren.
|
||||
/hrworks_api_keys.txt
|
||||
/hrworks*.txt
|
||||
*api_keys*.txt
|
||||
*secret*.txt
|
||||
|
||||
@@ -121,6 +121,48 @@ mit Pulse arbeitet.
|
||||
Angezeigt wird es überall dort, wo bisher die Initialen standen: Seitenleiste, Kopfzeile,
|
||||
Team, Dashboard, Reports, Projekt- und Kundenansicht. Ohne Bild bleiben die Initialen.
|
||||
|
||||
## Abwesenheiten aus HRworks
|
||||
|
||||
Urlaub, Krankheit, Elternzeit und Feiertage stehen in HRworks. Pulse holt sie einmal 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.
|
||||
|
||||
**Einrichtung** (*Einstellungen → Abwesenheiten (HRworks)*, nur Administrator): Schlüsselpaar in
|
||||
HRworks unter *Grundlagen → Integrationen → HRworks-API → Schlüsselpaare* erzeugen und hier
|
||||
eintragen. Der geheime Teil wird wie das SSO-Secret AES-verschlüsselt gespeichert und nie wieder
|
||||
ausgeliefert. „Verbindung testen" prüft die Zugangsdaten, „Probelauf" zeigt ohne zu schreiben,
|
||||
welche Buchungen entstehen würden.
|
||||
|
||||
**Wie die Stunden entstehen:** `GET /persons/available-working-hours?interval=days` liefert je
|
||||
Person und Tag `regularWorkingHours` (Soll) und `workingHours` (nach Abzug der Abwesenheit).
|
||||
Die **Differenz** sind die Abwesenheitsstunden – damit stimmen Teilzeit und halbe Tage ohne
|
||||
eigenen Kalender. `relatedEvents` nennt den Auslöser; Vorrang hat der Feiertag, dann Krankheit,
|
||||
dann Abwesenheit. Halbe Tage werden auf einen festen Wert gesetzt (Voreinstellung 4 h).
|
||||
|
||||
| Regel | Umsetzung |
|
||||
| --- | --- |
|
||||
| Übernommen werden | Abwesenheiten mit Status `validatedOk`; Krankmeldungen auch mit offener Bescheinigung (`reported`, `certificatePending`, `electronicCertificate*`) |
|
||||
| Zeitraum | nur bis heute – genehmigter Urlaub in der Zukunft wächst täglich nach |
|
||||
| Fenster | die letzten 60 Tage (einstellbar); dort werden Änderungen und Stornos nachgezogen |
|
||||
| Zuordnung | HRworks-Benutzername → Pulse-Benutzer über die Mailadresse, sonst über den Namen; Unzuordenbares wird gemeldet, nie stillschweigend verworfen |
|
||||
| Mitglieder | jeder aktive Mitarbeiter wird dem Projekt zugewiesen, auch neue |
|
||||
| Arten → Tasks | `type_map` in den Einstellungen, z. B. `absence:1 → Urlaub`, `sick:KEK → Kind krank` |
|
||||
|
||||
Jede Buchung trägt `pulse_time_entries.hrworks_ref` = `<Abwesenheitsnummer>@<Tag>` (unique).
|
||||
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
|
||||
`deploy/`; sie werden nach `/etc/systemd/system/` kopiert und einmal mit
|
||||
`systemctl enable --now perinco-pulse-hrworks.timer` eingeschaltet.
|
||||
|
||||
**Nicht abrechenbare Projekte brauchen keinen Budgettopf** mehr (`NEEDS_POT` in
|
||||
`server/src/routes/projects.js`). Ein Topf trägt Volumen, Laufzeit und Satz – das hat nur bei
|
||||
Billable und Festpreis Sinn. Am Abwesenheitsprojekt wäre er nur ein Ablaufdatum, das irgendwann
|
||||
Buchungen blockiert; es hat deshalb keinen.
|
||||
|
||||
## SSO / Active Directory
|
||||
|
||||
**Einrichtung** (nur Administrator, *Einstellungen → SSO / Entra ID*): Microsoft Entra ID per OpenID Connect (Authorization Code, Client Secret AES-verschlüsselt in der DB). App-Registrierung mit Redirect-URI `https://pulse.perinco.de/api/auth/sso/callback`, delegiert `openid profile email`, Anwendung `User.Read.All` mit Administratorzustimmung.
|
||||
@@ -203,6 +245,7 @@ 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) |
|
||||
| Projekte: zusätzliche Listenansicht | ✅ | Projekte → Karten/Liste |
|
||||
| Grünes Plus → Projekt, Task, optionaler Freitext, Zeit rechts daneben, `0:00`, `1:23` und `1,5` | ✅ | Buchungsdialog |
|
||||
| Nur zugewiesene Projekte, Task optional | ✅ | Buchungsdialog |
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
[Unit]
|
||||
Description=perinco Pulse - Abwesenheiten aus HRworks abgleichen
|
||||
After=network-online.target mariadb.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=pulse
|
||||
Group=pulse
|
||||
WorkingDirectory=/var/www/pulse/server
|
||||
EnvironmentFile=/var/www/pulse/server/.env
|
||||
# Wie der Webdienst: "heute" und Monatsgrenzen gelten in Berlin.
|
||||
Environment=TZ=Europe/Berlin
|
||||
Environment=NODE_ENV=production
|
||||
ExecStart=/usr/bin/node src/jobs/hrworks-sync.js
|
||||
# Ein hoher Wert waere hier falsch: laeuft HRworks nicht, soll der Lauf
|
||||
# abbrechen und es morgen erneut versuchen, statt haengen zu bleiben.
|
||||
TimeoutStartSec=600
|
||||
MemoryMax=512M
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/www/pulse/server
|
||||
@@ -0,0 +1,15 @@
|
||||
[Unit]
|
||||
Description=Taeglicher Abgleich der Abwesenheiten aus HRworks
|
||||
|
||||
[Timer]
|
||||
# Frueh am Morgen, bevor jemand seine Zeiten ansieht. 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.
|
||||
OnCalendar=*-*-* 05:30:00 Europe/Berlin
|
||||
RandomizedDelaySec=300
|
||||
Persistent=true
|
||||
Unit=perinco-pulse-hrworks.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@@ -43,6 +43,7 @@ export async function initDatabase() {
|
||||
await migrateAvatarColumn();
|
||||
await migrateTaskRate();
|
||||
await migrateHarvestId();
|
||||
await migrateHrworksRef();
|
||||
await ensureAdminUser();
|
||||
await ensureInternalCustomer();
|
||||
}
|
||||
@@ -118,6 +119,19 @@ async function migrateHarvestId() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Herkunftsschluessel fuer Abwesenheiten aus HRworks (Nummer plus Tag). */
|
||||
async function migrateHrworksRef() {
|
||||
const [columns] = await pool.query(
|
||||
`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pulse_time_entries' AND COLUMN_NAME = 'hrworks_ref'`
|
||||
);
|
||||
if (!columns.length) {
|
||||
await pool.query('ALTER TABLE pulse_time_entries ADD COLUMN hrworks_ref VARCHAR(80) NULL AFTER harvest_id');
|
||||
await pool.query('ALTER TABLE pulse_time_entries ADD UNIQUE KEY uq_pulse_entries_hrworks (hrworks_ref)');
|
||||
console.log('[Pulse] Spalte pulse_time_entries.hrworks_ref ergaenzt.');
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAdminUser() {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users');
|
||||
if (rows[0].n > 0) return;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* Taeglicher Abgleich der Abwesenheiten aus HRworks.
|
||||
*
|
||||
* Laeuft als eigener Prozess ueber einen systemd-Timer, nicht im Webdienst:
|
||||
* ein haengender Fremdaufruf soll die Anwendung nicht blockieren, und der
|
||||
* Ausgang landet so im Journal (journalctl -u perinco-pulse-hrworks).
|
||||
*
|
||||
* Aufruf:
|
||||
* node src/jobs/hrworks-sync.js echter Lauf
|
||||
* node src/jobs/hrworks-sync.js --dry-run nur berichten
|
||||
*/
|
||||
import 'dotenv/config';
|
||||
import { initDatabase, pool } from '../db.js';
|
||||
import { getSettings } from '../lib/settings.js';
|
||||
import { syncHrworks } from '../lib/hrworksSync.js';
|
||||
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
try {
|
||||
await initDatabase();
|
||||
const { hrworks } = await getSettings();
|
||||
if (!hrworks.enabled) {
|
||||
console.log('[Pulse/HRworks] Abgleich ist in den Einstellungen abgeschaltet - nichts zu tun.');
|
||||
process.exit(0);
|
||||
}
|
||||
const report = await syncHrworks({ dryRun });
|
||||
console.log(
|
||||
`[Pulse/HRworks]${dryRun ? ' (Probelauf)' : ''} ${report.from} bis ${report.to}: ` +
|
||||
`${report.created} neu, ${report.updated ?? report.planned_update} geaendert, ` +
|
||||
`${report.removed ?? report.planned_delete} entfernt, ${report.unchanged} unveraendert, ` +
|
||||
`${report.hours} Stunden.`
|
||||
);
|
||||
for (const person of report.unmatched_persons) {
|
||||
console.warn(`[Pulse/HRworks] Kein Pulse-Konto fuer ${person.person_id} (${person.name}, ${person.email || 'ohne Mail'}).`);
|
||||
}
|
||||
for (const task of report.missing_tasks) {
|
||||
console.warn(`[Pulse/HRworks] Task fehlt im Projekt: ${task}`);
|
||||
}
|
||||
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,192 @@
|
||||
/*
|
||||
* Zugriff auf die HRworks-API v2 (https://api.hrworks.de/v2).
|
||||
*
|
||||
* Anmeldung: accessKey und secretAccessKey gegen /authentication tauschen, das
|
||||
* ergibt ein JWT mit 15 Minuten Laufzeit. Das Token wird hier gehalten und
|
||||
* kurz vor Ablauf erneuert - ein Sync macht hunderte Aufrufe, und fuer jeden
|
||||
* neu anzumelden waere unnoetig.
|
||||
*
|
||||
* Es wird ausschliesslich gelesen. Pulse schreibt nichts nach HRworks zurueck.
|
||||
*/
|
||||
|
||||
const BASE = 'https://api.hrworks.de/v2';
|
||||
const USER_AGENT = 'perinco Pulse (Abwesenheiten-Abgleich)';
|
||||
/** Token vorsorglich eine Minute vor Ablauf erneuern. */
|
||||
const RENEW_BEFORE_MS = 60_000;
|
||||
|
||||
export class HrworksError extends Error {
|
||||
constructor(message, status) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ein Mandant, an dem die Zugangsdaten haengen. */
|
||||
export class Hrworks {
|
||||
constructor({ access_key, secret_access_key }) {
|
||||
this.accessKey = access_key;
|
||||
this.secret = secret_access_key;
|
||||
this.token = null;
|
||||
this.expires = 0;
|
||||
}
|
||||
|
||||
async authenticate() {
|
||||
if (this.token && Date.now() < this.expires - RENEW_BEFORE_MS) return this.token;
|
||||
if (!this.accessKey || !this.secret) throw new HrworksError('HRworks: Zugangsdaten fehlen.', 400);
|
||||
const res = await fetch(`${BASE}/authentication`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'User-Agent': USER_AGENT },
|
||||
body: JSON.stringify({ accessKey: this.accessKey, secretAccessKey: this.secret }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new HrworksError(
|
||||
res.status === 403 ? 'HRworks: Zugangsdaten wurden abgelehnt.' : `HRworks-Anmeldung fehlgeschlagen (HTTP ${res.status}). ${text.slice(0, 120)}`,
|
||||
res.status
|
||||
);
|
||||
}
|
||||
const { token } = await res.json();
|
||||
this.token = token;
|
||||
// Ablauf aus dem Token lesen; ohne lesbares exp vorsichtig 10 Minuten annehmen.
|
||||
let exp = 0;
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
|
||||
exp = Number(payload.exp) * 1000;
|
||||
} catch {
|
||||
/* egal */
|
||||
}
|
||||
this.expires = exp || Date.now() + 10 * 60_000;
|
||||
return this.token;
|
||||
}
|
||||
|
||||
/** GET mit Wiederholung bei abgelaufenem Token. */
|
||||
async get(path, params = {}, retry = true) {
|
||||
const token = await this.authenticate();
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === undefined || value === null || value === '') continue;
|
||||
if (Array.isArray(value)) value.forEach((v) => query.append(key, String(v)));
|
||||
else query.append(key, String(value));
|
||||
}
|
||||
const url = `${BASE}${path}${query.size ? `?${query}` : ''}`;
|
||||
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}`, 'User-Agent': USER_AGENT } });
|
||||
if (res.status === 401 && retry) {
|
||||
this.token = null;
|
||||
return this.get(path, params, false);
|
||||
}
|
||||
if (!res.ok) {
|
||||
let message = `HTTP ${res.status}`;
|
||||
try {
|
||||
const body = await res.json();
|
||||
message = body.errorMessage || body.message || message;
|
||||
} catch {
|
||||
/* Text statt JSON */
|
||||
}
|
||||
throw new HrworksError(`HRworks ${path}: ${message}`, res.status);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- Stammdaten -- */
|
||||
|
||||
/** Alle Personen mit Benutzername und Mailadresse. */
|
||||
async persons() {
|
||||
const data = await this.get('/persons/master-data');
|
||||
return (data.persons || []).map((p) => ({
|
||||
person_id: p.personId,
|
||||
personnel_number: p.personnelNumber ?? null,
|
||||
email: (p.email || '').toLowerCase() || null,
|
||||
first_name: p.firstName ?? '',
|
||||
last_name: p.lastName ?? '',
|
||||
name: `${p.firstName ?? ''} ${p.lastName ?? ''}`.trim(),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Abwesenheitsarten: Schluessel -> Name. */
|
||||
async absenceTypes() {
|
||||
const data = await this.get('/absences/absence-types', { onlyActive: false });
|
||||
const list = data.absenceTypes || Object.values(data)[0] || [];
|
||||
return list.map((t) => ({ key: String(t.key), name: t.name, active: t.isActive !== false }));
|
||||
}
|
||||
|
||||
async sickLeaveTypes() {
|
||||
const data = await this.get('/sick-leaves/sick-leave-types', { onlyActive: false });
|
||||
const list = data.sickLeaveTypes || Object.values(data)[0] || [];
|
||||
return list.map((t) => ({ key: String(t.key), name: t.name, active: t.isActive !== false }));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- Abwesenheiten -- */
|
||||
|
||||
/**
|
||||
* Abwesenheiten im Zeitraum, flach je Person.
|
||||
* Antwort von HRworks: { "BenutzerName": [{ beginDate, endDate, absences: [...] }] }
|
||||
*/
|
||||
async absences(from, to) {
|
||||
const data = await this.get('/absences', { beginDate: from, endDate: to });
|
||||
return flatten(data, 'absences');
|
||||
}
|
||||
|
||||
async sickLeaves(from, to) {
|
||||
const data = await this.get('/sick-leaves', { beginDate: from, endDate: to });
|
||||
return flatten(data, 'sickLeaves');
|
||||
}
|
||||
|
||||
/**
|
||||
* Arbeitszeiten je Person und Tag.
|
||||
*
|
||||
* Hier kommt die eigentliche Rechenarbeit her: `regularWorkingHours` ist das
|
||||
* Soll des Tages, `workingHours` das, was nach Abzug von Abwesenheit,
|
||||
* Krankheit und Feiertag uebrig bleibt. Die Differenz sind die
|
||||
* Abwesenheitsstunden - inklusive Teilzeit und halber Tage, ohne dass Pulse
|
||||
* einen eigenen Kalender braucht. `relatedEvents` nennt den Auslöser.
|
||||
*
|
||||
* HRworks erlaubt bei interval=days hoechstens 31 Tage je Aufruf.
|
||||
*/
|
||||
async days(from, to, persons) {
|
||||
const data = await this.get('/persons/available-working-hours', {
|
||||
beginDate: from,
|
||||
endDate: to,
|
||||
interval: 'days',
|
||||
persons,
|
||||
});
|
||||
const out = [];
|
||||
for (const [person, entries] of Object.entries(data)) {
|
||||
for (const day of entries || []) {
|
||||
const events = day.relatedEvents || {};
|
||||
out.push({
|
||||
person,
|
||||
date: day.beginDate,
|
||||
regular_hours: Number(day.regularWorkingHours || 0),
|
||||
available_hours: Number(day.workingHours || 0),
|
||||
absences: (events.absences || []).map((e) => e?.id).filter(Boolean),
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/** { person: [{ absences: [...] }] } -> [{ person, ...absence }] */
|
||||
function flatten(data, key) {
|
||||
const out = [];
|
||||
for (const [person, intervals] of Object.entries(data || {})) {
|
||||
for (const interval of intervals || []) {
|
||||
for (const item of interval[key] || []) out.push({ person, ...item });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Status, die als verbindlich gelten (Absprache mit dem Auftraggeber). */
|
||||
export const ABSENCE_STATES = ['validatedOk'];
|
||||
export const SICK_STATES = [
|
||||
'validatedOk',
|
||||
'reported',
|
||||
'certificatePending',
|
||||
'electronicCertificatePending',
|
||||
'electronicCertificateAvailable',
|
||||
'electronicCertificateRequestSent',
|
||||
];
|
||||
@@ -0,0 +1,277 @@
|
||||
import { pool, withTransaction } from '../db.js';
|
||||
import { getSettings, saveSetting } from './settings.js';
|
||||
import { addDays, today } from './dates.js';
|
||||
import { Hrworks, ABSENCE_STATES, SICK_STATES } from './hrworks.js';
|
||||
|
||||
/*
|
||||
* Abwesenheiten aus HRworks nach Pulse.
|
||||
*
|
||||
* Vorgehen: Fuer jede Person und jeden Tag im Fenster liefert HRworks das Soll
|
||||
* und die verfuegbaren Stunden. Die Differenz sind die Abwesenheitsstunden;
|
||||
* `relatedEvents` sagt, was der Auslöser war. Daraus entsteht je Tag genau
|
||||
* eine Buchung mit dem Schluessel "<Abwesenheitsnummer>@<Tag>" bzw.
|
||||
* "holiday@<Tag>@<Person>" - so ist ein zweiter Lauf harmlos, und was in
|
||||
* HRworks storniert oder verschoben wurde, verschwindet hier wieder.
|
||||
*
|
||||
* Nur bis heute: genehmigter Urlaub liegt oft Monate voraus, gebucht wird er
|
||||
* am jeweiligen Tag (Absprache).
|
||||
*
|
||||
* Es wird ausschliesslich gelesen; Pulse schreibt nichts nach HRworks.
|
||||
*/
|
||||
|
||||
/** Ein Aufruf von /persons/available-working-hours deckt hoechstens 31 Tage. */
|
||||
const CHUNK_DAYS = 31;
|
||||
|
||||
/** Vorrang: ein Feiertag zaehlt als Feiertag, auch wenn Urlaub eingetragen ist. */
|
||||
export function ursache(day) {
|
||||
if (day.holiday) return { art: 'holiday', ref: null };
|
||||
if (day.sick_leaves.length) return { art: 'sick', ref: day.sick_leaves[0] };
|
||||
if (day.absences.length) return { art: 'absence', ref: day.absences[0] };
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalize(text) {
|
||||
return String(text || '')
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
/** HRworks-Person -> Pulse-Benutzer: Mailadresse, sonst Name. */
|
||||
export function matchUsers(persons, users) {
|
||||
const byMail = new Map(users.filter((u) => u.email).map((u) => [u.email.toLowerCase(), u]));
|
||||
const byName = new Map();
|
||||
for (const user of users) {
|
||||
const key = normalize(user.name);
|
||||
byName.set(key, byName.has(key) ? null : user); // doppelte Namen: kein Treffer
|
||||
}
|
||||
const matched = new Map();
|
||||
const unmatched = [];
|
||||
for (const person of persons) {
|
||||
const hit = (person.email && byMail.get(person.email)) || byName.get(normalize(person.name)) || null;
|
||||
if (hit) matched.set(person.person_id, hit);
|
||||
else unmatched.push({ person_id: person.person_id, name: person.name, email: person.email });
|
||||
}
|
||||
return { matched, unmatched };
|
||||
}
|
||||
|
||||
/** Tage in Abschnitte von hoechstens CHUNK_DAYS zerlegen. */
|
||||
export function chunks(from, to) {
|
||||
const out = [];
|
||||
let start = from;
|
||||
while (start <= to) {
|
||||
let end = addDays(start, CHUNK_DAYS - 1);
|
||||
if (end > to) end = to;
|
||||
out.push([start, end]);
|
||||
start = addDays(end, 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuehrt den Abgleich durch.
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {boolean} options.dryRun nichts schreiben, nur berichten
|
||||
* @param {string} [options.from] Beginn des Fensters (Standard: heute minus window_days)
|
||||
* @param {string} [options.to] Ende (Standard: heute)
|
||||
*/
|
||||
export async function syncHrworks({ dryRun = true, from, to } = {}) {
|
||||
const settings = await getSettings();
|
||||
const config = settings.hrworks;
|
||||
const bis = to || today();
|
||||
const von = from || addDays(bis, -Number(config.window_days || 60));
|
||||
|
||||
const bericht = {
|
||||
dry_run: dryRun,
|
||||
from: von,
|
||||
to: bis,
|
||||
created: 0,
|
||||
unchanged: 0,
|
||||
removed: 0,
|
||||
failed: [],
|
||||
unmatched_persons: [],
|
||||
missing_tasks: [],
|
||||
hours: 0,
|
||||
// Einzelheiten fuer die Vorschau: je Person und Task Stunden und Tage.
|
||||
per_person: {},
|
||||
started_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const [[project]] = await pool.query('SELECT * FROM pulse_projects WHERE code = ? LIMIT 1', [config.project_code]);
|
||||
if (!project) throw new Error(`Projekt ${config.project_code} nicht gefunden - bitte in den Einstellungen richtigstellen.`);
|
||||
if (!project.is_active) throw new Error(`Projekt ${config.project_code} ist archiviert.`);
|
||||
|
||||
const [taskRows] = await pool.query('SELECT id, name, is_active FROM pulse_tasks WHERE project_id = ?', [project.id]);
|
||||
const tasks = new Map(taskRows.map((t) => [normalize(t.name), t]));
|
||||
|
||||
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);
|
||||
bericht.unmatched_persons = unmatched;
|
||||
|
||||
// Vor dem Projektstart wird nicht gebucht - sonst stehen Zeiten vor der
|
||||
// Laufzeit, die Pulse selbst nie zugelassen haette.
|
||||
const projektStart = String(project.start_date);
|
||||
|
||||
// Wieviel steht an einem Tag schon (ohne unsere eigenen Abwesenheiten)?
|
||||
// Mehr als 24 Stunden am Tag laesst Pulse nirgends zu.
|
||||
const [belegt] = await pool.query(
|
||||
`SELECT user_id, entry_date, SUM(minutes) AS minutes FROM pulse_time_entries
|
||||
WHERE entry_date BETWEEN ? AND ? AND hrworks_ref IS NULL
|
||||
GROUP BY user_id, entry_date`,
|
||||
[von, bis]
|
||||
);
|
||||
const tagesLast = new Map(belegt.map((r) => [`${r.user_id}@${String(r.entry_date)}`, Number(r.minutes)]));
|
||||
|
||||
// Alle Abwesenheiten und Krankmeldungen im Fenster - fuer Art und Status.
|
||||
const [absences, sickLeaves] = await Promise.all([hr.absences(von, bis), hr.sickLeaves(von, bis)]);
|
||||
const meta = new Map();
|
||||
for (const a of absences) {
|
||||
meta.set(a.number, { art: 'absence', type: String(a.type), name: a.name, status: a.statusIdentifier });
|
||||
}
|
||||
for (const s of sickLeaves) {
|
||||
meta.set(s.number, { art: 'sick', type: String(s.type), name: s.name, status: s.statusIdentifier });
|
||||
}
|
||||
|
||||
// Tage einsammeln, in Abschnitten von 31 Tagen.
|
||||
const personIds = [...matched.keys()];
|
||||
const soll = new Map(); // ref -> Buchung
|
||||
for (const [start, end] of chunks(von, bis)) {
|
||||
const days = await hr.days(start, end, personIds);
|
||||
for (const day of days) {
|
||||
const user = matched.get(day.person);
|
||||
if (!user) continue;
|
||||
if (day.date < projektStart) continue;
|
||||
const stunden = Math.round((day.regular_hours - day.available_hours) * 100) / 100;
|
||||
if (stunden <= 0) continue;
|
||||
|
||||
const quelle = ursache(day);
|
||||
if (!quelle) continue;
|
||||
|
||||
let taskName;
|
||||
let ref;
|
||||
if (quelle.art === 'holiday') {
|
||||
if (!config.holiday_task) continue;
|
||||
taskName = config.holiday_task;
|
||||
ref = `holiday@${day.date}@${day.person}`;
|
||||
} else {
|
||||
const info = meta.get(quelle.ref);
|
||||
if (!info) continue; // Abwesenheit liegt ausserhalb des abgefragten Fensters
|
||||
const erlaubt = info.art === 'sick' ? SICK_STATES : ABSENCE_STATES;
|
||||
if (!erlaubt.includes(info.status)) continue;
|
||||
taskName = config.type_map?.[`${info.art}:${info.type}`] || info.name;
|
||||
ref = `${quelle.ref}@${day.date}`;
|
||||
}
|
||||
|
||||
const task = tasks.get(normalize(taskName));
|
||||
if (!task) {
|
||||
if (!bericht.missing_tasks.includes(taskName)) bericht.missing_tasks.push(taskName);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Halbe Tage auf einen festen Wert setzen (Absprache: 4 Stunden).
|
||||
const halbe = Number(config.half_day_hours || 0);
|
||||
const minutes = Math.round((halbe > 0 && stunden < day.regular_hours ? halbe : stunden) * 60);
|
||||
const schon = tagesLast.get(`${user.id}@${day.date}`) || 0;
|
||||
if (schon + minutes > 24 * 60) {
|
||||
bericht.failed.push({
|
||||
ref,
|
||||
error: `An diesem Tag stehen schon ${Math.round((schon / 60) * 100) / 100} h - mit ${minutes / 60} h waeren es mehr als 24.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
soll.set(ref, { user_id: user.id, task_id: task.id, date: day.date, minutes, ref });
|
||||
bericht.hours += minutes / 60;
|
||||
const eintrag = (bericht.per_person[user.name] ||= {});
|
||||
const wert = (eintrag[task.name] ||= { hours: 0, days: 0 });
|
||||
wert.hours = Math.round((wert.hours + minutes / 60) * 100) / 100;
|
||||
wert.days += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Was steht schon in Pulse?
|
||||
const [vorhanden] = await pool.query(
|
||||
'SELECT id, hrworks_ref, user_id, task_id, entry_date, minutes FROM pulse_time_entries WHERE hrworks_ref IS NOT NULL AND entry_date BETWEEN ? AND ?',
|
||||
[von, bis]
|
||||
);
|
||||
const istMap = new Map(vorhanden.map((e) => [e.hrworks_ref, e]));
|
||||
|
||||
const anzulegen = [];
|
||||
const zuAendern = [];
|
||||
for (const [ref, wunsch] of soll) {
|
||||
const ist = istMap.get(ref);
|
||||
if (!ist) anzulegen.push(wunsch);
|
||||
else if (ist.minutes !== wunsch.minutes || ist.task_id !== wunsch.task_id || ist.user_id !== wunsch.user_id) {
|
||||
zuAendern.push({ id: ist.id, ...wunsch });
|
||||
} else bericht.unchanged++;
|
||||
}
|
||||
// Alles, was in HRworks nicht mehr existiert (storniert, verschoben, Status weg).
|
||||
const zuLoeschen = vorhanden.filter((e) => !soll.has(e.hrworks_ref)).map((e) => e.id);
|
||||
|
||||
bericht.hours = Math.round(bericht.hours * 100) / 100;
|
||||
bericht.planned_create = anzulegen.length;
|
||||
bericht.planned_update = zuAendern.length;
|
||||
bericht.planned_delete = zuLoeschen.length;
|
||||
|
||||
if (dryRun) {
|
||||
bericht.finished_at = new Date().toISOString();
|
||||
return bericht;
|
||||
}
|
||||
|
||||
await withTransaction(async (conn) => {
|
||||
if (zuLoeschen.length) {
|
||||
await conn.query(`DELETE FROM pulse_time_entries WHERE id IN (${zuLoeschen.map(() => '?').join(',')})`, zuLoeschen);
|
||||
bericht.removed = zuLoeschen.length;
|
||||
}
|
||||
for (const e of zuAendern) {
|
||||
await conn.query('UPDATE pulse_time_entries SET user_id = ?, task_id = ?, entry_date = ?, minutes = ? WHERE id = ?', [
|
||||
e.user_id,
|
||||
e.task_id,
|
||||
e.date,
|
||||
e.minutes,
|
||||
e.id,
|
||||
]);
|
||||
}
|
||||
// Jeder Mitarbeiter gehoert dem Projekt an, sonst nimmt Pulse die Buchung
|
||||
// nicht an - auch neue Kollegen (Absprache).
|
||||
await conn.query(
|
||||
`INSERT IGNORE INTO pulse_project_members (project_id, user_id)
|
||||
SELECT ?, id FROM pulse_users WHERE is_active = 1 AND role <> 'admin'`,
|
||||
[project.id]
|
||||
);
|
||||
for (const e of anzulegen) {
|
||||
try {
|
||||
await conn.query(
|
||||
`INSERT INTO pulse_time_entries (user_id, project_id, task_id, pot_id, entry_date, minutes, note, billable, hrworks_ref)
|
||||
VALUES (?, ?, ?, NULL, ?, ?, NULL, 0, ?)`,
|
||||
[e.user_id, project.id, e.task_id, e.date, e.minutes, e.ref]
|
||||
);
|
||||
bericht.created++;
|
||||
} catch (err) {
|
||||
bericht.failed.push({ ref: e.ref, error: err.code === 'ER_DUP_ENTRY' ? 'schon vorhanden' : err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
bericht.updated = zuAendern.length;
|
||||
bericht.finished_at = new Date().toISOString();
|
||||
await saveSetting('hrworks', {
|
||||
...config,
|
||||
last_run: bericht.finished_at,
|
||||
last_result: {
|
||||
created: bericht.created,
|
||||
updated: bericht.updated,
|
||||
removed: bericht.removed,
|
||||
unchanged: bericht.unchanged,
|
||||
hours: bericht.hours,
|
||||
failed: bericht.failed.length,
|
||||
},
|
||||
});
|
||||
return bericht;
|
||||
}
|
||||
@@ -30,6 +30,47 @@ export const DEFAULTS = {
|
||||
signature_left: 'Datum, Unterschrift Berater',
|
||||
signature_right: 'Datum, Unterschrift Kunde',
|
||||
},
|
||||
/*
|
||||
* Abwesenheiten aus HRworks. Urlaub und Krankheit werden dort gefuehrt; Pulse
|
||||
* holt sie taeglich und buchtsie auf ein nicht abrechenbares Projekt.
|
||||
*/
|
||||
hrworks: {
|
||||
enabled: false,
|
||||
access_key: '',
|
||||
secret_access_key: '',
|
||||
// Projekt, auf das gebucht wird (Projektcode, nicht die id - der Code
|
||||
// bleibt beim Umziehen der Datenbank gleich).
|
||||
project_code: 'PERINCO-2024-001',
|
||||
// Wie viele Tage rueckwaerts jeder Lauf prueft.
|
||||
window_days: 60,
|
||||
/*
|
||||
* Abwesenheitsart -> Taskname in Pulse. Schluessel ist "absence:<key>" bzw.
|
||||
* "sick:<key>" aus HRworks. Fehlt ein Eintrag, gilt der HRworks-Name des
|
||||
* Typs - dann muss in Pulse ein Task mit genau diesem Namen stehen.
|
||||
*/
|
||||
type_map: {
|
||||
'absence:1': 'Urlaub',
|
||||
'absence:3': 'Elternzeit',
|
||||
'absence:5': 'Mutterschutz',
|
||||
'absence:6': 'Geburt',
|
||||
'absence:7': 'Hochzeit',
|
||||
'absence:8': 'Sabbatical',
|
||||
'absence:9': 'Freistellung',
|
||||
'absence:10': 'Bildungsurlaub',
|
||||
'sick:KMA': 'Krankheit',
|
||||
'sick:KOA': 'Krankheit',
|
||||
'sick:K28': 'Krankheit',
|
||||
'sick:KOL': 'Krankheit',
|
||||
'sick:KWW': 'Krankheit',
|
||||
'sick:KEK': 'Kind krank',
|
||||
},
|
||||
// Task fuer Feiertage; leer = Feiertage nicht buchen.
|
||||
holiday_task: 'Feiertag',
|
||||
// Halber Tag: feste Stundenzahl (0 = aus HRworks uebernehmen).
|
||||
half_day_hours: 4,
|
||||
last_run: null,
|
||||
last_result: null,
|
||||
},
|
||||
sso: {
|
||||
enabled: false,
|
||||
button_label: 'Mit Microsoft anmelden',
|
||||
@@ -62,6 +103,7 @@ export async function getSettings() {
|
||||
}
|
||||
}
|
||||
if (result.sso.client_secret) result.sso.client_secret = decrypt(result.sso.client_secret);
|
||||
if (result.hrworks.secret_access_key) result.hrworks.secret_access_key = decrypt(result.hrworks.secret_access_key);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -69,6 +111,7 @@ export async function saveSetting(key, value) {
|
||||
if (!(key in DEFAULTS)) throw new Error(`Unbekannte Einstellung: ${key}`);
|
||||
let stored = value;
|
||||
if (key === 'sso' && value.client_secret) stored = { ...value, client_secret: encrypt(value.client_secret) };
|
||||
if (key === 'hrworks' && value.secret_access_key) stored = { ...value, secret_access_key: encrypt(value.secret_access_key) };
|
||||
await pool.query(
|
||||
'INSERT INTO pulse_settings (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)',
|
||||
[key, JSON.stringify(stored)]
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import express from 'express';
|
||||
import { pool } from '../db.js';
|
||||
import { getSettings } from '../lib/settings.js';
|
||||
import { Hrworks } from '../lib/hrworks.js';
|
||||
import { syncHrworks } from '../lib/hrworksSync.js';
|
||||
|
||||
/*
|
||||
* Abwesenheiten aus HRworks - Bedienung und Kontrolle.
|
||||
*
|
||||
* Nur Administratoren. Es wird ausschliesslich aus HRworks gelesen; geschrieben
|
||||
* wird in Pulse, und zwar nur auf das eingestellte Abwesenheitsprojekt.
|
||||
*/
|
||||
|
||||
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 [[project]] = await pool.query('SELECT id, code, name, type, start_date FROM pulse_projects WHERE code = ? LIMIT 1', [
|
||||
hrworks.project_code,
|
||||
]);
|
||||
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE hrworks_ref IS NOT NULL');
|
||||
const [[range]] = await pool.query(
|
||||
'SELECT MIN(entry_date) AS von, MAX(entry_date) AS bis, COALESCE(SUM(minutes), 0) AS minutes FROM pulse_time_entries WHERE hrworks_ref IS NOT NULL'
|
||||
);
|
||||
res.json({
|
||||
enabled: hrworks.enabled,
|
||||
configured: Boolean(hrworks.access_key && hrworks.secret_access_key),
|
||||
project: project || null,
|
||||
project_code: hrworks.project_code,
|
||||
window_days: hrworks.window_days,
|
||||
holiday_task: hrworks.holiday_task,
|
||||
half_day_hours: hrworks.half_day_hours,
|
||||
type_map: hrworks.type_map,
|
||||
last_run: hrworks.last_run,
|
||||
last_result: hrworks.last_result,
|
||||
entries: Number(n),
|
||||
hours: Math.round((Number(range.minutes) / 60) * 100) / 100,
|
||||
first_date: range.von,
|
||||
last_date: range.bis,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Zugangsdaten pruefen und zeigen, was HRworks liefert. */
|
||||
router.post('/test', async (req, res, next) => {
|
||||
try {
|
||||
const { hrworks } = await getSettings();
|
||||
const hr = new Hrworks(hrworks);
|
||||
await hr.authenticate();
|
||||
const [persons, absenceTypes, sickTypes] = await Promise.all([
|
||||
hr.persons(),
|
||||
hr.absenceTypes(),
|
||||
hr.sickLeaveTypes(),
|
||||
]);
|
||||
const [users] = await pool.query("SELECT email, name FROM pulse_users WHERE is_active = 1 AND role <> 'admin'");
|
||||
const mails = new Set(users.map((u) => (u.email || '').toLowerCase()));
|
||||
res.json({
|
||||
ok: true,
|
||||
persons: persons.length,
|
||||
persons_with_pulse_account: persons.filter((p) => p.email && mails.has(p.email)).length,
|
||||
absence_types: absenceTypes.map((t) => ({ key: t.key, name: t.name, active: t.active })),
|
||||
sick_leave_types: sickTypes.map((t) => ({ key: t.key, name: t.name, active: t.active })),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.status) return res.status(400).json({ error: err.message });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Abgleich anstossen. Ohne `apply: true` wird nichts geschrieben, sondern nur
|
||||
* berichtet, was passieren wuerde.
|
||||
*/
|
||||
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,
|
||||
});
|
||||
res.json(bericht);
|
||||
} catch (err) {
|
||||
if (err.status) return res.status(400).json({ error: err.message });
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Was hat der Abgleich angelegt? Fuer Kontrolle und Rueckbau. */
|
||||
router.get('/entries', async (req, res, next) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT e.hrworks_ref, e.entry_date, e.minutes, u.name AS user_name, t.name AS task_name
|
||||
FROM pulse_time_entries e
|
||||
JOIN pulse_users u ON u.id = e.user_id
|
||||
LEFT JOIN pulse_tasks t ON t.id = e.task_id
|
||||
WHERE e.hrworks_ref IS NOT NULL ORDER BY e.entry_date, u.name`
|
||||
);
|
||||
res.json({ count: rows.length, entries: rows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/** Rueckbau: entfernt genau die Buchungen aus HRworks, nichts anderes. */
|
||||
router.delete('/entries', async (req, res, next) => {
|
||||
try {
|
||||
const [result] = await pool.query('DELETE FROM pulse_time_entries WHERE hrworks_ref IS NOT NULL');
|
||||
res.json({ deleted: result.affectedRows });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -13,8 +13,13 @@ import {
|
||||
|
||||
const router = express.Router();
|
||||
const TYPES = ['billable', 'non_billable', 'fixed', 'abruf'];
|
||||
/** Abruf rechnet nach Aufwand ab: kein Budgettopf, Satz am Projekt. */
|
||||
const NEEDS_POT = (type) => type !== 'abruf';
|
||||
/*
|
||||
* Ein Budgettopf traegt Volumen, Laufzeit und Satz - das hat nur Sinn, wo
|
||||
* abgerechnet wird. "Abruf" rechnet nach Aufwand ohne Topf, und nicht
|
||||
* abrechenbare Projekte (intern, Abwesenheiten) haben gar kein Budget: dort
|
||||
* waere ein Topf nur ein Ablaufdatum, das irgendwann Buchungen blockiert.
|
||||
*/
|
||||
const NEEDS_POT = (type) => type === 'billable' || type === 'fixed';
|
||||
|
||||
const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
|
||||
const num = (v) => (v === '' || v == null ? null : Number(v));
|
||||
@@ -370,12 +375,16 @@ router.delete('/:id/pots/:potId', requireRole('lead'), async (req, res, next) =>
|
||||
try {
|
||||
const projectId = Number(req.params.id);
|
||||
const potId = Number(req.params.potId);
|
||||
const project = await findProject(projectId);
|
||||
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
|
||||
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE pot_id = ?', [potId]);
|
||||
if (Number(n) > 0) {
|
||||
return res.status(409).json({ error: `Auf dem Topf sind ${n} Buchungen - er kann nicht gelöscht werden.` });
|
||||
}
|
||||
const [[{ pots }]] = await pool.query('SELECT COUNT(*) AS pots FROM pulse_budget_pots WHERE project_id = ?', [projectId]);
|
||||
if (Number(pots) <= 1) return res.status(400).json({ error: 'Ein Projekt braucht mindestens einen Budgettopf.' });
|
||||
if (Number(pots) <= 1 && NEEDS_POT(project.type)) {
|
||||
return res.status(400).json({ error: 'Abrechenbare Projekte brauchen mindestens einen Budgettopf.' });
|
||||
}
|
||||
await pool.query('DELETE FROM pulse_budget_pots WHERE id = ? AND project_id = ?', [potId, projectId]);
|
||||
await respondProject(req, res, projectId);
|
||||
} catch (err) {
|
||||
|
||||
@@ -27,7 +27,17 @@ router.get('/public', async (req, res, next) => {
|
||||
router.get('/', requireRole('admin'), async (req, res, next) => {
|
||||
try {
|
||||
const s = await getSettings();
|
||||
res.json({ ...s, sso: { ...s.sso, client_secret: '', client_secret_set: Boolean(s.sso.client_secret), configured: ssoConfigured(s.sso) } });
|
||||
// Geheimnisse werden nie ausgeliefert, nur die Information, dass sie stehen.
|
||||
res.json({
|
||||
...s,
|
||||
sso: { ...s.sso, client_secret: '', client_secret_set: Boolean(s.sso.client_secret), configured: ssoConfigured(s.sso) },
|
||||
hrworks: {
|
||||
...s.hrworks,
|
||||
secret_access_key: '',
|
||||
secret_set: Boolean(s.hrworks.secret_access_key),
|
||||
configured: Boolean(s.hrworks.access_key && s.hrworks.secret_access_key),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
@@ -83,6 +93,38 @@ router.put('/', requireRole('admin'), async (req, res, next) => {
|
||||
await saveSetting('export', next_);
|
||||
}
|
||||
|
||||
if (body.hrworks) {
|
||||
const next_ = { ...current.hrworks };
|
||||
for (const key of ['access_key', 'project_code', 'holiday_task']) {
|
||||
if (body.hrworks[key] !== undefined) next_[key] = text(body.hrworks[key], 200).trim();
|
||||
}
|
||||
if (body.hrworks.enabled !== undefined) next_.enabled = Boolean(body.hrworks.enabled);
|
||||
if (body.hrworks.window_days !== undefined) {
|
||||
const days = Number(body.hrworks.window_days);
|
||||
if (days >= 1 && days <= 365) next_.window_days = Math.round(days);
|
||||
else errors.push('HRworks: Fenster zwischen 1 und 365 Tagen.');
|
||||
}
|
||||
if (body.hrworks.half_day_hours !== undefined) {
|
||||
const hours = Number(body.hrworks.half_day_hours);
|
||||
if (hours >= 0 && hours <= 12) next_.half_day_hours = hours;
|
||||
else errors.push('HRworks: Stunden für einen halben Tag zwischen 0 und 12.');
|
||||
}
|
||||
if (body.hrworks.type_map && typeof body.hrworks.type_map === 'object') {
|
||||
const map = {};
|
||||
for (const [key, value] of Object.entries(body.hrworks.type_map)) {
|
||||
if (/^(absence|sick):[A-Za-z0-9_-]{1,20}$/.test(key)) map[key] = text(value, 160).trim();
|
||||
}
|
||||
next_.type_map = map;
|
||||
}
|
||||
// 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 = '';
|
||||
if (next_.enabled && !(next_.access_key && next_.secret_access_key)) {
|
||||
errors.push('HRworks kann erst aktiviert werden, wenn Access Key und Secret gesetzt sind.');
|
||||
}
|
||||
if (!errors.length) await saveSetting('hrworks', next_);
|
||||
}
|
||||
|
||||
if (body.sso) {
|
||||
const next_ = { ...current.sso };
|
||||
for (const key of ['tenant_id', 'client_id', 'redirect_uri', 'button_label']) {
|
||||
|
||||
@@ -137,10 +137,14 @@ CREATE TABLE IF NOT EXISTS pulse_time_entries (
|
||||
-- der Schluessel verhindert Dubletten bei einem zweiten Import und erlaubt
|
||||
-- einen gezielten Rueckbau, ohne handgetippte Zeiten anzufassen.
|
||||
harvest_id BIGINT UNSIGNED NULL,
|
||||
-- Herkunft aus HRworks: Abwesenheitsnummer plus Tag, z. B. "30_25@2026-09-08".
|
||||
-- Ein Tag einer Abwesenheit ist genau eine Buchung.
|
||||
hrworks_ref VARCHAR(80) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_pulse_entries_harvest (harvest_id),
|
||||
UNIQUE KEY uq_pulse_entries_hrworks (hrworks_ref),
|
||||
KEY idx_pulse_entries_user_date (user_id, entry_date),
|
||||
KEY idx_pulse_entries_project_date (project_id, entry_date),
|
||||
KEY idx_pulse_entries_pot (pot_id),
|
||||
|
||||
@@ -14,6 +14,7 @@ import exportRoutes from './routes/exports.js';
|
||||
import settingsRoutes from './routes/settings.js';
|
||||
import directoryRoutes from './routes/directory.js';
|
||||
import importRoutes from './routes/imports.js';
|
||||
import hrworksRoutes from './routes/hrworks.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT || 5010);
|
||||
@@ -52,6 +53,8 @@ app.use('/api/settings', requireAuth, settingsRoutes);
|
||||
app.use('/api/directory', requireAuth, requireRole('management'), directoryRoutes);
|
||||
// Uebernahme aus Fremdsystemen - nur Administratoren.
|
||||
app.use('/api/imports', requireAuth, requireRole('admin'), importRoutes);
|
||||
// Abwesenheiten aus HRworks - nur Administratoren.
|
||||
app.use('/api/hrworks', requireAuth, requireRole('admin'), hrworksRoutes);
|
||||
|
||||
app.use('/api', (req, res) => res.status(404).json({ error: 'Endpunkt nicht gefunden.' }));
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chunks, matchUsers, ursache } from '../src/lib/hrworksSync.js';
|
||||
import { ABSENCE_STATES, SICK_STATES } from '../src/lib/hrworks.js';
|
||||
|
||||
test('ein Feiertag gewinnt gegen Urlaub am selben Tag', () => {
|
||||
assert.deepEqual(ursache({ holiday: true, absences: ['30_25'], sick_leaves: [] }), { art: 'holiday', ref: null });
|
||||
assert.deepEqual(ursache({ holiday: false, absences: ['30_25'], sick_leaves: [] }), { art: 'absence', ref: '30_25' });
|
||||
// Krankheit vor Urlaub: wer im Urlaub krank wird, ist krank.
|
||||
assert.deepEqual(ursache({ holiday: false, absences: ['30_25'], sick_leaves: ['31_9'] }), { art: 'sick', ref: '31_9' });
|
||||
assert.equal(ursache({ holiday: false, absences: [], sick_leaves: [] }), null);
|
||||
});
|
||||
|
||||
test('Zuordnung: Mailadresse zuerst, dann Name', () => {
|
||||
const users = [
|
||||
{ id: 1, name: 'Kai Schwickert', email: 'kai.schwickert@icon-experts.de' },
|
||||
{ id: 2, name: 'Nico Necknig', email: 'nico.necknig@perinco.de' },
|
||||
{ id: 3, name: 'Renè Kleinau', email: 'rene.kleinau@perinco.de' },
|
||||
];
|
||||
const persons = [
|
||||
// andere Domain in HRworks - greift ueber den Namen
|
||||
{ person_id: 'KaiS', name: 'Kai Schwickert', email: 'kai.schwickert@perinco.de' },
|
||||
// private Adresse in HRworks - greift ueber den Namen
|
||||
{ person_id: 'NicoN', name: 'Nico Necknig', email: 'nico.necknig@web.de' },
|
||||
// Akzent unterschiedlich geschrieben
|
||||
{ person_id: 'ReneK', name: 'Rene Kleinau', email: 'andere@perinco.de' },
|
||||
{ person_id: 'ServiceA', name: 'Service Account', email: 'sa.hrworks@perinco.de' },
|
||||
];
|
||||
const { matched, unmatched } = matchUsers(persons, users);
|
||||
assert.equal(matched.get('KaiS').id, 1);
|
||||
assert.equal(matched.get('NicoN').id, 2);
|
||||
assert.equal(matched.get('ReneK').id, 3);
|
||||
assert.deepEqual(unmatched.map((u) => u.person_id), ['ServiceA']);
|
||||
});
|
||||
|
||||
test('doppelte Namen ergeben keinen Treffer', () => {
|
||||
const users = [
|
||||
{ id: 5, name: 'Luisa Sauerwald', email: 'lusia.sauerwald@perinco.de' },
|
||||
{ id: 23, name: 'Luisa Sauerwald', email: 'luisa.sauerwald@perinco.de' },
|
||||
];
|
||||
// Mail passt: eindeutig.
|
||||
let r = matchUsers([{ person_id: 'LuisaS', name: 'Luisa Sauerwald', email: 'luisa.sauerwald@perinco.de' }], users);
|
||||
assert.equal(r.matched.get('LuisaS').id, 23);
|
||||
// Mail passt nicht: der Name ist zweimal da, also lieber melden als raten.
|
||||
r = matchUsers([{ person_id: 'LuisaS', name: 'Luisa Sauerwald', email: 'x@y.de' }], users);
|
||||
assert.equal(r.matched.size, 0);
|
||||
assert.equal(r.unmatched.length, 1);
|
||||
});
|
||||
|
||||
test('Zeitraum wird in Abschnitte von hoechstens 31 Tagen zerlegt', () => {
|
||||
assert.deepEqual(chunks('2026-09-01', '2026-09-05'), [['2026-09-01', '2026-09-05']]);
|
||||
const lang = chunks('2026-07-23', '2026-09-21');
|
||||
assert.equal(lang.length, 2);
|
||||
assert.equal(lang[0][0], '2026-07-23');
|
||||
assert.equal(lang[lang.length - 1][1], '2026-09-21');
|
||||
for (const [von, bis] of lang) {
|
||||
const tage = (new Date(bis) - new Date(von)) / 86400000 + 1;
|
||||
assert.ok(tage <= 31, `${von}-${bis} hat ${tage} Tage`);
|
||||
}
|
||||
});
|
||||
|
||||
test('nur genehmigte Abwesenheiten, bei Krankheit auch offene Bescheinigungen', () => {
|
||||
assert.deepEqual(ABSENCE_STATES, ['validatedOk']);
|
||||
assert.ok(SICK_STATES.includes('certificatePending'));
|
||||
assert.ok(SICK_STATES.includes('reported'));
|
||||
assert.ok(!SICK_STATES.includes('cancelled'));
|
||||
assert.ok(!ABSENCE_STATES.includes('requested'));
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Card, Field, Spinner, Toggle, inputClass } from '../ui/controls';
|
||||
import { Icon } from '../ui/Icon';
|
||||
import { hrworksApi, settingsApi, type AdminSettings, type HrworksReport, type HrworksStatus } from '../../lib/api';
|
||||
import { formatDate, formatHours } from '../../lib/format';
|
||||
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
|
||||
* stehen die Zugangsdaten, die Zuordnung der Arten auf Tasks und der Knopf fuer
|
||||
* einen Probelauf.
|
||||
*/
|
||||
export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> = ({ settings, onSaved }) => {
|
||||
const { addToast } = useToast();
|
||||
const [form, setForm] = useState({ ...settings.hrworks, secret_access_key: '' });
|
||||
const [status, setStatus] = useState<HrworksStatus | null>(null);
|
||||
const [report, setReport] = useState<HrworksReport | null>(null);
|
||||
const [busy, setBusy] = useState<'save' | 'test' | 'dry' | 'apply' | null>(null);
|
||||
|
||||
const set = (patch: Partial<typeof form>) => setForm({ ...form, ...patch });
|
||||
useEffect(() => setForm({ ...settings.hrworks, secret_access_key: '' }), [settings.hrworks]);
|
||||
useEffect(() => {
|
||||
hrworksApi.status().then(setStatus).catch(() => undefined);
|
||||
}, [settings.hrworks]);
|
||||
|
||||
const run = async (was: 'save' | 'test' | 'dry' | 'apply') => {
|
||||
setBusy(was);
|
||||
try {
|
||||
if (was === 'save') {
|
||||
await settingsApi.save({ hrworks: { ...form, secret_access_key: form.secret_access_key || undefined } });
|
||||
addToast('Gespeichert.', 'success');
|
||||
onSaved();
|
||||
} else if (was === 'test') {
|
||||
const r = await hrworksApi.test();
|
||||
addToast(
|
||||
`Verbindung steht: ${r.persons} Personen in HRworks, ${r.persons_with_pulse_account} mit Pulse-Konto.`,
|
||||
'success'
|
||||
);
|
||||
} else {
|
||||
const r = await hrworksApi.sync(was === 'apply');
|
||||
setReport(r);
|
||||
addToast(
|
||||
was === 'apply'
|
||||
? `Übernommen: ${r.created} neu, ${r.updated ?? 0} geändert, ${r.removed ?? 0} entfernt.`
|
||||
: `Probelauf: ${r.planned_create} neu, ${r.planned_update} geändert, ${r.planned_delete} zu entfernen.`,
|
||||
'success'
|
||||
);
|
||||
hrworksApi.status().then(setStatus).catch(() => undefined);
|
||||
}
|
||||
} catch (err) {
|
||||
addToast(err instanceof Error ? err.message : 'Fehlgeschlagen.', 'error');
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const eintraege = Object.entries(form.type_map ?? {});
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="animate-rise p-5 sm:p-6">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="text-base text-ink-900">Abwesenheiten aus HRworks</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" icon="refresh" busy={busy === 'test'} onClick={() => run('test')} disabled={!settings.hrworks.configured}>
|
||||
Verbindung testen
|
||||
</Button>
|
||||
<Button icon="check" busy={busy === 'save'} onClick={() => run('save')}>
|
||||
Speichern
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`mb-5 flex items-start gap-3 rounded-xl px-4 py-3 text-sm font-semibold ${
|
||||
settings.hrworks.enabled ? 'bg-brand-50 text-brand-800' : 'bg-ink-50 text-ink-600'
|
||||
}`}
|
||||
>
|
||||
<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.'
|
||||
: settings.hrworks.configured
|
||||
? 'Zugangsdaten hinterlegt, der Abgleich ist noch nicht eingeschaltet.'
|
||||
: 'Noch keine Zugangsdaten. Schlüsselpaar in HRworks unter Grundlagen → Integrationen → HRworks-API erzeugen.'}
|
||||
{status && status.entries > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
Übernommen sind {status.entries} Buchungen mit {formatHours(status.hours * 60)} Stunden
|
||||
{status.first_date && status.last_date ? ` (${formatDate(status.first_date)} – ${formatDate(status.last_date)})` : ''}.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field label="Access Key">
|
||||
<input className={inputClass} value={form.access_key} onChange={(e) => set({ access_key: e.target.value.trim() })} />
|
||||
</Field>
|
||||
<Field
|
||||
label="Secret Access Key"
|
||||
hint={settings.hrworks.secret_set ? 'Hinterlegt (verschlüsselt). Leer lassen = unverändert.' : 'Wird verschlüsselt gespeichert und nie wieder angezeigt.'}
|
||||
>
|
||||
<input
|
||||
className={inputClass}
|
||||
autoComplete="off"
|
||||
value={form.secret_access_key}
|
||||
placeholder={settings.hrworks.secret_set ? '••••••••••••' : ''}
|
||||
onChange={(e) => set({ secret_access_key: e.target.value.trim() })}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Projekt (Code)" hint="Nicht abrechenbares Projekt, auf das gebucht wird.">
|
||||
<input className={inputClass} value={form.project_code} onChange={(e) => set({ project_code: e.target.value.trim() })} />
|
||||
</Field>
|
||||
<Field label="Fenster (Tage)" hint="So viele Tage prüft jeder Lauf rückwärts – Änderungen und Stornos werden so nachgezogen.">
|
||||
<input className={inputClass} type="number" min={1} max={365} value={form.window_days} onChange={(e) => set({ window_days: Number(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="Task für Feiertage" hint="Leer = Feiertage nicht buchen.">
|
||||
<input className={inputClass} value={form.holiday_task} onChange={(e) => set({ holiday_task: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Halber Tag (Stunden)" hint="0 = Stunden aus HRworks übernehmen.">
|
||||
<input className={inputClass} type="number" min={0} max={12} step={0.5} value={form.half_day_hours} onChange={(e) => set({ half_day_hours: Number(e.target.value) })} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Toggle
|
||||
checked={form.enabled}
|
||||
onChange={(v) => set({ enabled: v })}
|
||||
label="Täglichen Abgleich aktivieren"
|
||||
hint="Ohne Haken läuft nichts – auch der nächtliche Lauf nicht."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{status?.project && (
|
||||
<p className="mt-4 text-xs text-ink-400">
|
||||
Zielprojekt: <b className="text-ink-600">{status.project.code}</b> – {status.project.name} ({status.project.type}), ab{' '}
|
||||
{formatDate(status.project.start_date)}. Alle aktiven Mitarbeiter werden beim Abgleich zugewiesen.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="animate-rise p-5 sm:p-6" style={{ '--i': 1 } as React.CSSProperties}>
|
||||
<h3 className="mb-1 text-sm text-ink-900">Zuordnung der Arten</h3>
|
||||
<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.
|
||||
</p>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{eintraege.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<span className="tabular w-28 shrink-0 text-xs font-bold text-ink-500">{key}</span>
|
||||
<input
|
||||
className={`${inputClass} py-2`}
|
||||
value={value}
|
||||
onChange={(e) => set({ type_map: { ...form.type_map, [key]: e.target.value } })}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="animate-rise p-5 sm:p-6" style={{ '--i': 2 } as React.CSSProperties}>
|
||||
<div className="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<h3 className="text-sm text-ink-900">Abgleich jetzt</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" icon="refresh" busy={busy === 'dry'} onClick={() => run('dry')} disabled={!settings.hrworks.configured}>
|
||||
Probelauf
|
||||
</Button>
|
||||
<Button icon="download" busy={busy === 'apply'} onClick={() => run('apply')} disabled={!settings.hrworks.configured}>
|
||||
Jetzt übernehmen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{busy === 'dry' || busy === 'apply' ? (
|
||||
<Spinner label="Hole Daten aus HRworks…" />
|
||||
) : report ? (
|
||||
<Bericht report={report} />
|
||||
) : (
|
||||
<p className="text-xs text-ink-400">
|
||||
Der Probelauf schreibt nichts. Er zeigt, welche Buchungen entstehen, geändert oder entfernt würden.
|
||||
</p>
|
||||
)}
|
||||
{status?.last_run && (
|
||||
<p className="mt-4 border-t border-ink-100 pt-3 text-xs text-ink-400">
|
||||
Letzter Lauf: {new Date(status.last_run).toLocaleString('de-DE')}
|
||||
{status.last_result
|
||||
? ` – ${status.last_result.created} neu, ${status.last_result.updated} geändert, ${status.last_result.removed} entfernt, ${status.last_result.unchanged} unverändert.`
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Bericht: React.FC<{ report: HrworksReport }> = ({ report }) => (
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="font-semibold text-ink-700">
|
||||
{formatDate(report.from)} – {formatDate(report.to)}:{' '}
|
||||
{report.dry_run
|
||||
? `${report.planned_create} neu, ${report.planned_update} geändert, ${report.planned_delete} zu entfernen, ${report.unchanged} unverändert`
|
||||
: `${report.created} neu, ${report.updated ?? 0} geändert, ${report.removed ?? 0} entfernt, ${report.unchanged} unverändert`}{' '}
|
||||
· {formatHours(report.hours * 60)} Stunden
|
||||
</p>
|
||||
{report.missing_tasks.length > 0 && (
|
||||
<p className="rounded-xl bg-amber-50 px-3 py-2 text-xs font-semibold text-amber-700">
|
||||
Diese Tasks fehlen im Projekt: {report.missing_tasks.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{report.unmatched_persons.length > 0 && (
|
||||
<p className="rounded-xl bg-ink-50 px-3 py-2 text-xs text-ink-500">
|
||||
Ohne Pulse-Konto: {report.unmatched_persons.map((p) => `${p.name || p.person_id}`).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{report.failed.length > 0 && (
|
||||
<ul className="space-y-1 rounded-xl bg-red-50 px-3 py-2 text-xs text-red-700">
|
||||
{report.failed.slice(0, 8).map((f) => (
|
||||
<li key={f.ref}>
|
||||
{f.ref}: {f.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{Object.keys(report.per_person).length > 0 && (
|
||||
<div className="scrollbar-slim max-h-72 overflow-y-auto rounded-xl border border-ink-100">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="sticky top-0 bg-ink-50 text-[10px] uppercase tracking-wider text-ink-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left">Mitarbeiter</th>
|
||||
<th className="px-3 py-2 text-left">Art</th>
|
||||
<th className="px-3 py-2 text-right">Tage</th>
|
||||
<th className="px-3 py-2 text-right">Stunden</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(report.per_person).flatMap(([person, arten]) =>
|
||||
Object.entries(arten).map(([art, wert], i) => (
|
||||
<tr key={`${person}-${art}`} className="border-t border-ink-100">
|
||||
<td className="px-3 py-1.5 font-semibold text-ink-700">{i === 0 ? person : ''}</td>
|
||||
<td className="px-3 py-1.5 text-ink-600">{art}</td>
|
||||
<td className="tabular px-3 py-1.5 text-right text-ink-600">{wert.days}</td>
|
||||
<td className="tabular px-3 py-1.5 text-right font-bold text-ink-900">{formatHours(wert.hours * 60)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -3,17 +3,19 @@ import { Button, Card, ErrorBox, Field, Spinner, Toggle, inputClass } from '../u
|
||||
import { Icon, type IconName } from '../ui/Icon';
|
||||
import { Team } from '../team/Team';
|
||||
import { SsoDirectory } from './SsoDirectory';
|
||||
import { HrworksSettings } from './HrworksSettings';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { download, settingsApi, type AdminSettings } from '../../lib/api';
|
||||
import { useLoader } from '../../lib/hooks';
|
||||
import { useRouter } from '../../lib/router';
|
||||
import { useToast } from '../Toast';
|
||||
|
||||
type Tab = 'allgemein' | 'export' | 'sso' | 'sso-verwaltung' | 'benutzer';
|
||||
type Tab = 'allgemein' | 'export' | 'sso' | 'sso-verwaltung' | 'hrworks' | 'benutzer';
|
||||
const TABS: { key: Tab; label: string; icon: IconName; adminOnly: boolean }[] = [
|
||||
{ key: 'allgemein', label: 'Allgemein', icon: 'settings', adminOnly: true },
|
||||
{ key: 'export', label: 'Export: Kopf & Fuß', icon: 'document', adminOnly: true },
|
||||
{ key: 'sso', label: 'SSO / Entra ID', icon: 'key', adminOnly: true },
|
||||
{ key: 'hrworks', label: 'Abwesenheiten (HRworks)', icon: 'calendar', adminOnly: true },
|
||||
// Management sieht in den Einstellungen nur diesen Reiter.
|
||||
{ key: 'sso-verwaltung', label: 'SSO-Verwaltung', icon: 'globe', adminOnly: false },
|
||||
{ key: 'benutzer', label: 'Benutzerverwaltung', icon: 'team', adminOnly: true },
|
||||
@@ -57,6 +59,7 @@ export const Settings: React.FC = () => {
|
||||
{tab === 'allgemein' && <General settings={data} onSaved={(s) => setData(s)} />}
|
||||
{tab === 'export' && <ExportSettings settings={data} onSaved={(s) => setData(s)} />}
|
||||
{tab === 'sso' && <SsoSettings settings={data} onSaved={reload} />}
|
||||
{tab === 'hrworks' && <HrworksSettings settings={data} onSaved={reload} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+66
-1
@@ -162,13 +162,78 @@ export interface SsoSettings {
|
||||
redirect_uri: string;
|
||||
local_login: boolean;
|
||||
}
|
||||
export interface HrworksSettings {
|
||||
enabled: boolean;
|
||||
access_key: string;
|
||||
secret_access_key: string;
|
||||
secret_set?: boolean;
|
||||
configured?: boolean;
|
||||
project_code: string;
|
||||
window_days: number;
|
||||
type_map: Record<string, string>;
|
||||
holiday_task: string;
|
||||
half_day_hours: number;
|
||||
last_run: string | null;
|
||||
last_result: { created: number; updated: number; removed: number; unchanged: number; hours: number; failed: number } | null;
|
||||
}
|
||||
|
||||
export interface HrworksStatus {
|
||||
enabled: boolean;
|
||||
configured: boolean;
|
||||
project: { id: number; code: string; name: string; type: string; start_date: string } | null;
|
||||
project_code: string;
|
||||
window_days: number;
|
||||
holiday_task: string;
|
||||
half_day_hours: number;
|
||||
type_map: Record<string, string>;
|
||||
last_run: string | null;
|
||||
last_result: HrworksSettings['last_result'];
|
||||
entries: number;
|
||||
hours: number;
|
||||
first_date: string | null;
|
||||
last_date: string | null;
|
||||
}
|
||||
|
||||
export interface HrworksReport {
|
||||
dry_run: boolean;
|
||||
from: string;
|
||||
to: string;
|
||||
created: number;
|
||||
updated?: number;
|
||||
removed?: number;
|
||||
unchanged: number;
|
||||
planned_create: number;
|
||||
planned_update: number;
|
||||
planned_delete: number;
|
||||
hours: number;
|
||||
failed: { ref: string; error: string }[];
|
||||
unmatched_persons: { person_id: string; name: string; email: string | null }[];
|
||||
missing_tasks: string[];
|
||||
per_person: Record<string, Record<string, { hours: number; days: number }>>;
|
||||
}
|
||||
|
||||
export const hrworksApi = {
|
||||
status: () => get<HrworksStatus>('/hrworks/status'),
|
||||
test: () =>
|
||||
send<{ ok: true; persons: number; persons_with_pulse_account: number; absence_types: { key: string; name: string }[] }>(
|
||||
'POST',
|
||||
'/hrworks/test',
|
||||
{}
|
||||
),
|
||||
sync: (apply = false) => send<HrworksReport>('POST', '/hrworks/sync', { apply }),
|
||||
entries: () => get<{ count: number; entries: unknown[] }>('/hrworks/entries'),
|
||||
removeAll: () => send<{ deleted: number }>('DELETE', '/hrworks/entries'),
|
||||
};
|
||||
|
||||
export interface AdminSettings extends PublicSettings {
|
||||
export: ExportSettings;
|
||||
sso: SsoSettings;
|
||||
hrworks: HrworksSettings;
|
||||
}
|
||||
export type AdminSettingsInput = Omit<AdminSettings, 'export' | 'sso'> & {
|
||||
export type AdminSettingsInput = Omit<AdminSettings, 'export' | 'sso' | 'hrworks'> & {
|
||||
export: Partial<ExportSettings>;
|
||||
sso: Partial<SsoSettings> & { clear_secret?: boolean };
|
||||
hrworks: Partial<HrworksSettings> & { clear_secret?: boolean };
|
||||
};
|
||||
|
||||
export interface AdUser {
|
||||
|
||||
Reference in New Issue
Block a user