Compare commits

...
6 Commits
Author SHA1 Message Date
schakal00783andClaude Opus 5.5 876ef24c75 feat(reports): Kundenreport nach Woche, Monat, Quartal, Jahr oder Zeitraum
Zeitraumwahl im Kundenreport (Standard Gesamt). Stunden, Team, Tasks,
Projektstunden und Exporte beziehen sich auf den Zeitraum; Diagramm je
Tag/Woche/Monat nach Laenge. Zeitraumwahl als PeriodPicker ausgelagert,
auch im Zeitreport genutzt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:01:23 +02:00
schakal00783andClaude Opus 5.5 5275f7c03d fix(buchung): Plus-Button mobil unten mittig
Der schwebende Plus-Button sass rechts unten und verdeckte den
Kopier-Knopf der Buchungskarten. Jetzt mittig, dazu Abstand am
Seitenende, damit die letzte Karte ueber den Button scrollen kann.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:55:50 +02:00
schakal00783andClaude Opus 5.5 ef709d423c feat(buchung): Buchung per Monatskalender auf mehrere Tage kopieren
Das Kopier-Icon (Liste, Wochenansicht) und "Kopieren" im Bearbeiten-Dialog
oeffnen jetzt einen kleinen Monatskalender. Dort werden beliebige Tage
an- und abgewaehlt, auch ueber Monate hinweg, und in einem Schritt gebucht.

- Tage ausserhalb der Projektlaufzeit bzw. ohne Budgettopf sind gesperrt.
- Urlaub/Feiertage sind markiert, bereits gebuchte Stunden je Tag sichtbar.
- "Werktage" waehlt Mo-Fr des Monats ohne Urlaub und Feiertag.
- Buchungen laufen nacheinander; fehlgeschlagene Tage bleiben ausgewaehlt.
- Der Vorlage-Modus im Buchungsdialog entfaellt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:51:18 +02:00
schakal00783andClaude Opus 5.5 2b9b7a6ae3 feat(buchung): Buchungen kopieren
Duplizieren-Button im Bearbeiten-Dialog und Kopier-Icon je Buchung in
Liste und Wochenansicht. Die Kopie oeffnet den Dialog als neue Buchung
fuer heute mit Projekt, Task, Notiz und Zeit der Vorlage. Inaktive
Projekte oder Tasks fallen auf die Standardauswahl zurueck.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:45:44 +02:00
schakal00783andClaude Opus 5.5 b6ba7860bf fix(kalender): Abwesenheiten jahresweise abfragen, Feiertagsnamen
- HRworks /absences erlaubt hoechstens ein Jahr je Aufruf; der Kalender
  reicht weiter und fragt jetzt in Abschnitten von 365 Tagen.
- HRworks liefert zu Feiertagen keinen Namen. Der Name kommt jetzt aus
  dem Datum (feste Feiertage, Ostern-abhaengige, Buss- und Bettag).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:41:49 +02:00
schakal00783andClaude Opus 5.5 c0eb00faee 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>
2026-09-25 11:37:26 +02:00
33 changed files with 1704 additions and 170 deletions
+51 -8
View File
@@ -18,11 +18,11 @@ Erster Login: Benutzer `admin`, Passwort aus `ADMIN_PASSWORD` in `server/.env` (
| Rolle | Startseite | Menü |
| --- | --- | --- |
| Mitarbeiter (`user`) | Buchung | Buchung, Projekte (nur zugewiesene, ohne Beträge) |
| Mitarbeiter (`user`) | Buchung | Buchung, Projekte (nur zugewiesene, ohne Beträge), Kalender |
| Externe (`external`) | Buchung | wie Mitarbeiter – die Rolle trennt nur, wer zum Haus gehört; `is_external` wird automatisch gesetzt |
| Vorgesetzter (`lead`) | Buchung | Dashboard (nur eigenes Team, rekursiv), Buchung, Projekte, Team, Kunden, Reports |
| Vorgesetzter (`lead`) | Buchung | Dashboard (nur eigenes Team, rekursiv), Buchung, Projekte, Kalender, Team, Kunden, Reports |
| Management | Dashboard | wie Vorgesetzter, sieht alles; Einstellungen nur „SSO-Verwaltung“ |
| Administrator | Einstellungen | Dashboard, Projekte, Team, Kunden, Reports, Einstellungen – keine Buchung, taucht in Auswertungen nicht auf |
| Administrator | Einstellungen | Dashboard, Projekte, Kalender, Team, Kunden, Reports, Einstellungen – keine Buchung, taucht in Auswertungen nicht auf |
Die Startseite gilt nach jeder Anmeldung (Passwort und SSO), auch wenn noch eine alte Seite in der Adresse steht; Neuladen bleibt auf der aktuellen Seite. Das Logo oben links führt ebenfalls zur Startseite.
@@ -88,6 +88,25 @@ Ein Klick auf eine Zeile öffnet dieselbe Detailansicht wie eine Kachel, hier un
mindestens Vorgesetzter ist.
## Kundenreport: Zeitraum
*Reports → Kundenreport* hat neben der Kundenauswahl eine Zeitraumwahl: **Gesamt** (Standard, die ganze
Kundenbeziehung wie bisher), **Woche**, **Monat**, **Quartal**, **Jahr** mit Vor/Zurück-Pfeilen oder ein freier
**Zeitraum** (von–bis). Die Wahl merkt sich der Browser (`pulse_customer_report_period`) und bleibt beim
Kundenwechsel stehen, damit sich Kunden im selben Zeitraum vergleichen lassen.
- Auf den Zeitraum beziehen sich: Gesamtstunden (billable/non-billable), die Stunden je Projekt, die Tabs *Team*
und *Tasks* sowie die Exporte *PDF – Projekte* und *CSV – alle Buchungen*. Der Stundennachweis nimmt den Monat,
in dem der Zeitraum endet.
- Die zweite Kachel zeigt mit Zeitraum den **Umsatz im Zeitraum** (Buchungen inkl. Festpreis-Anteil), darunter
*Erbracht gesamt*. **Umsatz offen**, **Budget gesamt** und die Budgetbalken bleiben Stand heute – sie hängen am
Projekt, nicht am Zeitraum.
- Das Diagramm wählt die Einheit nach der Länge: bis 31 Tage je Tag, bis 184 Tage je Woche (KW), darüber je Monat.
Ohne Zeitraum wie bisher Monate mit Auswahl 6/12/24.
- API: `GET /api/reports/customer/:id?from=YYYY-MM-DD&to=YYYY-MM-DD` – nur beide Werte zusammen schränken ein,
`from > to` gibt 400. Die Antwort enthält `range`, `series_unit` (`day`/`week`/`month`) und `series` mit `key`/`start`.
- Die Zeitraumwahl ist `src/components/reports/PeriodPicker.tsx` und wird auch vom Zeitreport benutzt.
## Auslastung
Die Auslastung vergleicht **gebuchte Stunden bis heute** mit der **Kapazität bis heute**
@@ -140,7 +159,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 +188,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 +199,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 +327,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 |
@@ -312,6 +354,7 @@ Aus Auftrag und Call-Mitschrift.
| Kunden anlegen/bearbeiten, wer hat welche Projekte mit welchen Mitarbeitern, Umsatz erbracht/offen, animiert | ✅ | Kunden |
| Reports wie Harvest (Report.png), interaktiv, Export Excel/PDF/CSV | ✅ | Reports → Zeitreport |
| Report für einzelne Kunden / Projekt wie Report-Kunde.png | ✅ | Reports → Kunden-/Projektreport |
| Kundenreport nach Woche, Monat, Jahr oder freiem Zeitraum | ✅ zusätzlich Quartal und Gesamt | Reports → Kundenreport |
| Einstellungen (nur Admin): SSO-Credentials, Userverwaltung, Kopf/Fuß der Exporte | ✅ inkl. PDF-/Excel-Vorschau | Einstellungen |
| Arbeitsstunden pro Woche (40) | ✅ | Einstellungen → Allgemein |
| Projektbezeichnungen nach Muster | ✅ | Einstellungen → Allgemein |
+5 -3
View File
@@ -1,12 +1,14 @@
[Unit]
Description=Taeglicher Abgleich der Abwesenheiten aus HRworks
Description=Abgleich der Abwesenheiten aus HRworks (5:30 und 12:00 Uhr)
[Timer]
# Frueh am Morgen, bevor jemand seine Zeiten ansieht. RandomizedDelay
# Frueh am Morgen, bevor jemand seine Zeiten ansieht, und mittags noch
# einmal - dann stehen am Vormittag genehmigte Urlaube im Kalender. RandomizedDelay
# verteilt die Last, Persistent holt einen verpassten Lauf nach (Neustart).
# Ortszeit im Kalenderausdruck - der Server laeuft auf UTC, der Lauf soll
# aber morgens in Berlin liegen, auch nach der Zeitumstellung.
# aber zur Berliner Uhrzeit liegen, auch nach der Zeitumstellung.
OnCalendar=*-*-* 05:30:00 Europe/Berlin
OnCalendar=*-*-* 12:00:00 Europe/Berlin
RandomizedDelaySec=300
Persistent=true
Unit=perinco-pulse-hrworks.service
+37 -7
View File
@@ -1,5 +1,6 @@
/*
* Taeglicher Abgleich der Abwesenheiten aus HRworks.
* Abgleich der Abwesenheiten aus HRworks (morgens und mittags): erst die
* Buchungen bis heute, dann der Kalender mit den Urlauben der Zukunft.
*
* Laeuft als eigener Prozess ueber einen systemd-Timer, nicht im Webdienst:
* ein haengender Fremdaufruf soll die Anwendung nicht blockieren, und der
@@ -8,13 +9,21 @@
* Aufruf:
* node src/jobs/hrworks-sync.js echter Lauf
* node src/jobs/hrworks-sync.js --dry-run nur berichten
* node src/jobs/hrworks-sync.js --calendar-from=2026-01-01
* Kalender einmalig weiter zurueck fuellen
*/
import 'dotenv/config';
import { initDatabase, pool } from '../db.js';
import { getSettings } from '../lib/settings.js';
import { syncHrworks } from '../lib/hrworksSync.js';
import { syncCalendar } from '../lib/calendarSync.js';
const dryRun = process.argv.includes('--dry-run');
const calendarFrom = process.argv.find((a) => a.startsWith('--calendar-from='))?.split('=')[1];
if (calendarFrom && !/^\d{4}-\d{2}-\d{2}$/.test(calendarFrom)) {
console.error(`[Pulse/Kalender] --calendar-from erwartet JJJJ-MM-TT, nicht "${calendarFrom}".`);
process.exit(1);
}
try {
await initDatabase();
@@ -23,6 +32,33 @@ try {
console.log('[Pulse/HRworks] Abgleich ist in den Einstellungen abgeschaltet - nichts zu tun.');
process.exit(0);
}
let failed = false;
try {
await syncBookings();
} catch (err) {
failed = true;
console.error('[Pulse/HRworks] Abgleich der Buchungen fehlgeschlagen:', err.message);
}
// Der Kalender laeuft auch, wenn die Buchungen scheitern (z. B. Projekt archiviert).
try {
const cal = await syncCalendar({ dryRun, from: calendarFrom });
console.log(
`[Pulse/Kalender]${dryRun ? ' (Probelauf)' : ''} ${cal.from} bis ${cal.to}: ` +
`${cal.vacation_days} Urlaubstage, ${cal.holidays} Feiertage.`
);
} catch (err) {
failed = true;
console.error('[Pulse/Kalender] Abgleich fehlgeschlagen:', err.message);
}
process.exit(failed ? 1 : 0);
} catch (err) {
console.error('[Pulse/HRworks] Abgleich fehlgeschlagen:', err.message);
process.exit(1);
} finally {
await pool.end().catch(() => undefined);
}
async function syncBookings() {
const report = await syncHrworks({ dryRun });
console.log(
`[Pulse/HRworks]${dryRun ? ' (Probelauf)' : ''} ${report.from} bis ${report.to}: ` +
@@ -39,10 +75,4 @@ try {
for (const problem of report.failed) {
console.warn(`[Pulse/HRworks] ${problem.ref}: ${problem.error}`);
}
process.exit(0);
} catch (err) {
console.error('[Pulse/HRworks] Abgleich fehlgeschlagen:', err.message);
process.exit(1);
} finally {
await pool.end().catch(() => undefined);
}
+116
View File
@@ -0,0 +1,116 @@
import { pool, withTransaction } from '../db.js';
import { getSettings, saveSetting } from './settings.js';
import { addDays, germanHolidayName, 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;
/** HRworks: "The maximum date interval for this action is one year." */
const ABSENCES_MAX_DAYS = 365;
/**
* 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 || germanHolidayName(day.date) || '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);
// /absences erlaubt hoechstens ein Jahr je Aufruf.
const meta = new Map();
for (const [start, end] of chunks(addDays(von, -LOOKBACK_DAYS), bis, ABSENCES_MAX_DAYS)) {
for (const a of await hr.absences(start, end)) {
meta.set(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;
}
+62
View File
@@ -114,3 +114,65 @@ export function periodLabel(granularity, from) {
if (granularity === 'quarter') return `Q${Math.floor((Number(m) - 1) / 3) + 1} ${y.slice(2)}`;
return y;
}
/** Ostersonntag (Gauss/Meeus, gregorianisch) als 'YYYY-MM-DD'. */
export function easterSunday(year) {
const a = year % 19;
const b = Math.floor(year / 100);
const c = year % 100;
const d = Math.floor(b / 4);
const e = b % 4;
const f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3);
const h = (19 * a + b - d - g + 15) % 30;
const i = Math.floor(c / 4);
const k = c % 4;
const l = (32 + 2 * e + 2 * i - h - k) % 7;
const m = Math.floor((a + 11 * h + 22 * l) / 451);
const month = Math.floor((h + l - 7 * m + 114) / 31);
const day = ((h + l - 7 * m + 114) % 31) + 1;
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
}
/**
* Name eines deutschen Feiertags zum Datum. HRworks sagt nur, DASS ein Tag
* Feiertag ist (welcher, haengt am Standort der Person); der Name kommt von
* hier. Unbekannt: null.
*/
export function germanHolidayName(value) {
const year = Number(value.slice(0, 4));
const fixed = {
'01-01': 'Neujahr',
'01-06': 'Heilige Drei Könige',
'03-08': 'Frauentag',
'05-01': 'Tag der Arbeit',
'08-08': 'Friedensfest',
'08-15': 'Mariä Himmelfahrt',
'09-20': 'Weltkindertag',
'10-03': 'Tag der Deutschen Einheit',
'10-31': 'Reformationstag',
'11-01': 'Allerheiligen',
'12-24': 'Heiligabend',
'12-25': '1. Weihnachtstag',
'12-26': '2. Weihnachtstag',
'12-31': 'Silvester',
};
if (fixed[value.slice(5)]) return fixed[value.slice(5)];
const easter = easterSunday(year);
const offset = daysBetween(easter, value);
const moving = {
'-2': 'Karfreitag',
0: 'Ostersonntag',
1: 'Ostermontag',
39: 'Christi Himmelfahrt',
49: 'Pfingstsonntag',
50: 'Pfingstmontag',
60: 'Fronleichnam',
};
if (moving[offset]) return moving[offset];
// Buss- und Bettag: Mittwoch vor dem 23. November.
const nov23 = parseDate(`${year}-11-23`);
const back = ((nov23.getUTCDay() - 3 + 7) % 7) || 7;
if (value === addDays(`${year}-11-23`, -back)) return 'Buß- und Bettag';
return null;
}
+11
View File
@@ -162,6 +162,9 @@ export class Hrworks {
sick_leaves: (events.sickLeaves || []).map((e) => e?.id).filter(Boolean),
// Feiertage tragen keine id, nur das Vorhandensein zaehlt.
holiday: Array.isArray(events.holidays) && events.holidays.length > 0,
// Fuer den Kalender; wie der Eintrag genau aussieht, ist nicht
// dokumentiert - fehlt der Name, heisst er dort schlicht "Feiertag".
holiday_name: holidayName(events.holidays),
});
}
}
@@ -169,6 +172,14 @@ export class Hrworks {
}
}
export function holidayName(holidays) {
for (const h of Array.isArray(holidays) ? holidays : []) {
const name = typeof h === 'string' ? h : h?.name || h?.description || h?.title;
if (typeof name === 'string' && name.trim()) return name.trim().slice(0, 160);
}
return null;
}
/** { person: [{ absences: [...] }] } -> [{ person, ...absence }] */
function flatten(data, key) {
const out = [];
+3 -3
View File
@@ -56,12 +56,12 @@ export function matchUsers(persons, users) {
return { matched, unmatched };
}
/** Tage in Abschnitte von hoechstens CHUNK_DAYS zerlegen. */
export function chunks(from, to) {
/** Tage in Abschnitte von hoechstens `size` Tagen zerlegen. */
export function chunks(from, to, size = CHUNK_DAYS) {
const out = [];
let start = from;
while (start <= to) {
let end = addDays(start, CHUNK_DAYS - 1);
let end = addDays(start, size - 1);
if (end > to) end = to;
out.push([start, end]);
start = addDays(end, 1);
+11
View File
@@ -64,6 +64,14 @@ export const DEFAULTS = {
'sick:KWW': 'Krankheit',
'sick:KEK': 'Kind krank',
},
/*
* Welche Abwesenheitsarten im Kalender stehen, den alle sehen. Nur
* Urlaub - Elternzeit, Mutterschutz & Co. sagen mehr ueber eine Person,
* als die Kollegen wissen muessen. Krankheit ist nie waehlbar.
*/
calendar_types: ['absence:1', 'absence:10'],
// So weit reicht der Kalender in die Zukunft.
calendar_days_ahead: 400,
// Task fuer Feiertage; leer = Feiertage nicht buchen.
holiday_task: 'Feiertag',
// Halber Tag: feste Stundenzahl (0 = aus HRworks uebernehmen).
@@ -71,6 +79,9 @@ export const DEFAULTS = {
last_run: null,
last_result: null,
},
// Letzter Kalenderabgleich - eigener Schluessel, damit sich die beiden
// Laeufe ihre Ergebnisse nicht gegenseitig ueberschreiben.
calendar_status: { last_run: null, vacation_days: 0, holidays: 0 },
/*
* KI-Assistent im Kopf der Anwendung. Der Schluessel liegt verschluesselt,
* genau wie das SSO-Secret; das Modell ist einstellbar, weil OpenAI die
+50
View File
@@ -0,0 +1,50 @@
import express from 'express';
import { pool } from '../db.js';
import { getSettings } from '../lib/settings.js';
import { daysBetween, parseDate } from '../lib/dates.js';
/*
* Kalender: Urlaube und Feiertage aller Mitarbeiter - fuer jeden Angemeldeten
* (Absprache). Die Daten stellt der HRworks-Abgleich bereit
* (lib/calendarSync.js); hier wird nur gelesen. Krankmeldungen stehen in der
* Tabelle gar nicht erst.
*/
const router = express.Router();
/** Hoechstens so viele Tage je Abfrage - eine Monatsansicht braucht 42. */
const MAX_DAYS = 400;
router.get('/', async (req, res, next) => {
try {
const { from, to } = req.query;
if (!parseDate(from) || !parseDate(to) || to < from) {
return res.status(400).json({ error: 'Zeitraum ungültig (from/to als JJJJ-MM-TT).' });
}
if (daysBetween(from, to) > MAX_DAYS) return res.status(400).json({ error: `Höchstens ${MAX_DAYS} Tage je Abfrage.` });
// Nur, was der Kalender braucht - keine Profilbilder, keine Mailadressen.
const [users] = await pool.query(
`SELECT id, name, color, manager_id, is_external
FROM pulse_users WHERE is_active = 1 AND role <> 'admin' ORDER BY name`
);
const [days] = await pool.query(
`SELECT c.user_id, c.day, c.kind, c.label, c.half
FROM pulse_calendar_days c
JOIN pulse_users u ON u.id = c.user_id AND u.is_active = 1 AND u.role <> 'admin'
WHERE c.day BETWEEN ? AND ?
ORDER BY c.day, c.user_id`,
[from, to]
);
const { calendar_status } = await getSettings();
res.json({
users: users.map((u) => ({ ...u, is_external: Boolean(u.is_external) })),
days: days.map((d) => ({ ...d, half: Boolean(d.half) })),
last_run: calendar_status.last_run,
});
} catch (err) {
next(err);
}
});
export default router;
+14 -6
View File
@@ -3,6 +3,7 @@ import { pool } from '../db.js';
import { getSettings } from '../lib/settings.js';
import { Hrworks } from '../lib/hrworks.js';
import { syncHrworks } from '../lib/hrworksSync.js';
import { syncCalendar } from '../lib/calendarSync.js';
/*
* Abwesenheiten aus HRworks - Bedienung und Kontrolle.
@@ -16,7 +17,7 @@ const router = express.Router();
/** Was ist eingerichtet, was hat der letzte Lauf gebracht? */
router.get('/status', async (req, res, next) => {
try {
const { hrworks } = await getSettings();
const { hrworks, calendar_status } = await getSettings();
const [[project]] = await pool.query('SELECT id, code, name, type, start_date FROM pulse_projects WHERE code = ? LIMIT 1', [
hrworks.project_code,
]);
@@ -35,6 +36,8 @@ router.get('/status', async (req, res, next) => {
type_map: hrworks.type_map,
last_run: hrworks.last_run,
last_result: hrworks.last_result,
calendar_types: hrworks.calendar_types,
calendar: calendar_status,
entries: Number(n),
hours: Math.round((Number(range.minutes) / 60) * 100) / 100,
first_date: range.von,
@@ -77,11 +80,16 @@ router.post('/test', async (req, res, next) => {
*/
router.post('/sync', async (req, res, next) => {
try {
const bericht = await syncHrworks({
dryRun: req.body?.apply !== true,
from: req.body?.from,
to: req.body?.to,
});
const dryRun = req.body?.apply !== true;
const bericht = await syncHrworks({ dryRun, from: req.body?.from, to: req.body?.to });
// Der Kalender zieht beim Uebernehmen gleich mit - er hat sein eigenes Fenster.
if (!dryRun) {
try {
bericht.calendar = await syncCalendar({ dryRun: false });
} catch (err) {
bericht.calendar_error = err.message;
}
}
res.json(bericht);
} catch (err) {
if (err.status) return res.status(400).json({ error: err.message });
+59 -23
View File
@@ -4,7 +4,7 @@ import { teamOf, userFilter, visibleUserIds } from '../lib/access.js';
import { getSettings } from '../lib/settings.js';
import { loadEntries } from '../lib/entries.js';
import { loadProjects } from '../lib/projects.js';
import { addDays, mondayOf, parseDate, today, isoWeek } from '../lib/dates.js';
import { addDays, daysBetween, mondayOf, parseDate, today, isoWeek } from '../lib/dates.js';
import { capacityMinutes } from './dashboard.js';
const router = express.Router();
@@ -196,6 +196,53 @@ function buildProjectDetail(project, entries) {
};
}
/**
* Zeitreihe fuer den Kundenreport. Ohne Zeitraum: Monate ueber die ganze
* Kundenbeziehung (hoechstens 24). Mit Zeitraum: die Einheit richtet sich nach
* der Laenge - bis 31 Tage je Tag, bis ein halbes Jahr je Woche, sonst je Monat.
*/
function customerSeries(entries, from, to) {
const sum = (key, start, own) => ({
key,
start,
minutes: own.reduce((s, e) => s + e.minutes, 0),
billable_minutes: own.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0),
amount: r2(own.reduce((s, e) => s + e.amount + e.fixed_amount, 0)),
});
const monthly = (first, last, limit) => {
const points = [];
const cursor = new Date(`${first}-01T00:00:00Z`);
while (cursor.toISOString().slice(0, 7) <= last && points.length < limit) {
const month = cursor.toISOString().slice(0, 7);
points.push(sum(month, `${month}-01`, entries.filter((e) => e.entry_date.startsWith(month))));
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
return points;
};
if (!from) {
const months = [...new Set(entries.map((e) => e.entry_date.slice(0, 7)))].sort();
const last = today().slice(0, 7);
return { unit: 'month', points: monthly(months[0] || last, last, 36).slice(-24) };
}
const days = daysBetween(from, to) + 1;
if (days <= 31) {
const points = [];
for (let d = from; d <= to; d = addDays(d, 1)) points.push(sum(d, d, entries.filter((e) => e.entry_date === d)));
return { unit: 'day', points };
}
if (days <= 184) {
const points = [];
for (let monday = mondayOf(from); monday <= to; monday = addDays(monday, 7)) {
const sunday = addDays(monday, 6);
points.push(sum(monday, monday, entries.filter((e) => e.entry_date >= monday && e.entry_date <= sunday)));
}
return { unit: 'week', points };
}
return { unit: 'month', points: monthly(from.slice(0, 7), to.slice(0, 7), 120) };
}
/** Kundenreport: alle Projekte eines Kunden mit Budget, Verlauf und Team. */
router.get('/customer/:id', async (req, res, next) => {
try {
@@ -204,31 +251,20 @@ router.get('/customer/:id', async (req, res, next) => {
if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden.' });
const projects = await loadProjects(settings, { where: 'p.customer_id = ?', params: [customer.id] });
const userIds = await visibleUserIds(req.user);
const entries = await loadEntries(settings, { customerId: customer.id, userIds, from: req.query.from, to: req.query.to });
// Zeitraum nur vollstaendig: von und bis, sonst die ganze Kundenbeziehung.
const ranged = parseDate(req.query.from) && parseDate(req.query.to);
const from = ranged ? req.query.from : null;
const to = ranged ? req.query.to : null;
if (ranged && from > to) return res.status(400).json({ error: 'Zeitraum ungültig.' });
const entries = await loadEntries(settings, { customerId: customer.id, userIds, from, to });
const series = customerSeries(entries, from, to);
// Monatsreihe ueber die ganze Kundenbeziehung, hoechstens 24 Monate.
const months = [...new Set(entries.map((e) => e.entry_date.slice(0, 7)))].sort();
const first = months[0] || today().slice(0, 7);
const series = [];
const cursor = new Date(`${first}-01T00:00:00Z`);
const last = today().slice(0, 7);
while (cursor.toISOString().slice(0, 7) <= last && series.length < 36) {
const month = cursor.toISOString().slice(0, 7);
const own = entries.filter((e) => e.entry_date.startsWith(month));
series.push({
month,
minutes: own.reduce((s, e) => s + e.minutes, 0),
billable_minutes: own.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0),
amount: r2(own.reduce((s, e) => s + e.amount + e.fixed_amount, 0)),
});
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
const minutes = entries.reduce((s, e) => s + e.minutes, 0);
const minutes = entries.reduce((s, e) => s + e.minutes, 0);
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
res.json({
customer,
range: { from, to },
series_unit: series.unit,
totals: {
minutes,
billable_minutes: billable,
@@ -238,7 +274,7 @@ router.get('/customer/:id', async (req, res, next) => {
open: r2(projects.reduce((s, p) => s + p.open, 0)),
budget_value: r2(projects.reduce((s, p) => s + p.budget_value, 0)),
},
series: series.slice(-24),
series: series.points,
projects: projects.map((p) => {
const own = entries.filter((e) => e.project_id === p.id);
return { ...buildProjectDetail(p, own), weekly: undefined };
+6
View File
@@ -118,6 +118,12 @@ router.put('/', requireRole('admin'), async (req, res, next) => {
}
next_.type_map = map;
}
// Kalender: nur Abwesenheiten, nie Krankmeldungen - den sehen alle.
if (Array.isArray(body.hrworks.calendar_types)) {
next_.calendar_types = [
...new Set(body.hrworks.calendar_types.map((t) => String(t)).filter((t) => /^absence:[A-Za-z0-9_-]{1,20}$/.test(t))),
];
}
// Leer = unveraendert; der geheime Teil wird nie ausgeliefert.
if (body.hrworks.secret_access_key) next_.secret_access_key = text(body.hrworks.secret_access_key, 500).trim();
if (body.hrworks.clear_secret) next_.secret_access_key = '';
+16
View File
@@ -164,3 +164,19 @@ CREATE TABLE IF NOT EXISTS pulse_settings (
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (k)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- Kalender: genehmigte Urlaube und Feiertage aus HRworks, ein Eintrag je
-- Person und Tag - auch in der Zukunft. Getrennt von den Buchungen, die erst
-- am jeweiligen Tag entstehen. Krankmeldungen stehen hier bewusst nie: alle
-- Benutzer sehen den Kalender.
CREATE TABLE IF NOT EXISTS pulse_calendar_days (
user_id INT UNSIGNED NOT NULL,
day DATE NOT NULL,
kind ENUM('vacation','holiday') NOT NULL,
label VARCHAR(160) NOT NULL,
half TINYINT(1) NOT NULL DEFAULT 0,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, day),
KEY idx_pulse_calendar_day (day),
CONSTRAINT fk_pulse_calendar_user FOREIGN KEY (user_id) REFERENCES pulse_users (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+3
View File
@@ -16,6 +16,7 @@ import directoryRoutes from './routes/directory.js';
import importRoutes from './routes/imports.js';
import hrworksRoutes from './routes/hrworks.js';
import assistantRoutes from './routes/assistant.js';
import calendarRoutes from './routes/calendar.js';
const app = express();
const PORT = Number(process.env.PORT || 5010);
@@ -50,6 +51,8 @@ app.use('/api/entries', requireAuth, entryRoutes);
app.use('/api/dashboard', requireAuth, requireRole('lead'), dashboardRoutes);
app.use('/api/reports', requireAuth, requireRole('lead'), reportRoutes);
app.use('/api/exports', requireAuth, exportRoutes);
// Urlaube und Feiertage - fuer alle Angemeldeten.
app.use('/api/calendar', requireAuth, calendarRoutes);
app.use('/api/settings', requireAuth, settingsRoutes);
app.use('/api/directory', requireAuth, requireRole('management'), directoryRoutes);
// Uebernahme aus Fremdsystemen - nur Administratoren.
+63
View File
@@ -0,0 +1,63 @@
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';
import { easterSunday, germanHolidayName } from '../src/lib/dates.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, date: '2026-07-14' }), meta, types).label, 'Feiertag');
assert.equal(kalenderEintrag(tag({ holiday: true, date: '2026-10-03' }), meta, types).label, 'Tag der Deutschen Einheit');
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);
});
test('Feiertagsnamen aus dem Datum', () => {
assert.equal(easterSunday(2026), '2026-04-05');
assert.equal(easterSunday(2027), '2027-03-28');
assert.equal(germanHolidayName('2026-04-03'), 'Karfreitag');
assert.equal(germanHolidayName('2026-05-14'), 'Christi Himmelfahrt');
assert.equal(germanHolidayName('2026-05-25'), 'Pfingstmontag');
assert.equal(germanHolidayName('2026-06-04'), 'Fronleichnam');
assert.equal(germanHolidayName('2026-11-18'), 'Buß- und Bettag');
assert.equal(germanHolidayName('2026-12-25'), '1. Weihnachtstag');
assert.equal(germanHolidayName('2026-07-14'), null);
});
+6
View File
@@ -66,3 +66,9 @@ test('nur genehmigte Abwesenheiten, bei Krankheit auch offene Bescheinigungen',
assert.ok(!SICK_STATES.includes('cancelled'));
assert.ok(!ABSENCE_STATES.includes('requested'));
});
test('Abschnitte mit eigener Groesse (Abwesenheiten: hoechstens ein Jahr)', () => {
const teile = chunks('2025-10-01', '2027-11-01', 365);
assert.equal(teile.length, 3);
for (const [von, bis] of teile) assert.ok((new Date(bis) - new Date(von)) / 86400000 + 1 <= 365);
});
+3
View File
@@ -9,6 +9,7 @@ import { Dashboard } from './components/dashboard/Dashboard';
import { Booking } from './components/booking/Booking';
import { Projects } from './components/projects/Projects';
import { Team } from './components/team/Team';
import { Calendar } from './components/calendar/Calendar';
import { Customers } from './components/customers/Customers';
import { Reports } from './components/reports/Reports';
import { Settings } from './components/settings/Settings';
@@ -21,6 +22,7 @@ const SUBTITLES: Record<ViewKey, string> = {
dashboard: 'Gebuchte Zeiten, Auslastung und letzte Buchungen',
buchung: 'Zeiten erfassen, bearbeiten und exportieren',
projekte: 'Projekte, Budgettöpfe und Tasks',
kalender: 'Urlaube und Feiertage aller Mitarbeiter',
team: 'Hierarchie, Benutzer und externe Mitarbeiter',
kunden: 'Kundenstamm, Projekte und Umsatz',
reports: 'Auswertungen nach Kunde, Projekt, Task und Mitarbeiter',
@@ -68,6 +70,7 @@ const App: React.FC = () => {
{view === 'dashboard' && <Dashboard />}
{view === 'buchung' && <Booking />}
{view === 'projekte' && <Projects />}
{view === 'kalender' && <Calendar />}
{view === 'team' && <Team />}
{view === 'kunden' && <Customers />}
{view === 'reports' && <Reports />}
+29 -4
View File
@@ -3,6 +3,7 @@ import { Avatar, Button, Card, ErrorBox, Segmented, Spinner, inputClass } from '
import { Icon } from '../ui/Icon';
import { Donut } from '../ui/charts';
import { EntryModal } from './EntryModal';
import { CopyDialog } from './CopyDialog';
import { ExportDialog } from './ExportDialog';
import { WeekView } from './WeekView';
import { MonthView } from './MonthView';
@@ -54,6 +55,7 @@ export const Booking: React.FC = () => {
const [layout, setLayout] = usePersistentState<BookingLayout>('pulse_booking_layout', 'calendar');
const [anchor, setAnchor] = useState(todayIso());
const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null } | null>(null);
const [copyEntry, setCopyEntry] = useState<TimeEntry | null>(null);
const [exportOpen, setExportOpen] = useState(false);
const [viewUserId, setViewUserId] = useState<number>(user!.id);
const isLead = atLeast(user, 'lead');
@@ -100,6 +102,13 @@ export const Booking: React.FC = () => {
setModal({ date: date ?? (today >= range.from && today <= range.to ? today : range.from) });
};
/** Kopieren: Tage im Monatskalender waehlen, auf die die Buchung kopiert wird. */
const copy = (entry: TimeEntry) => {
if (readOnly) return;
setModal(null);
setCopyEntry(entry);
};
/*
* Im Export stehen nur die eigenen Projekte zur Wahl - auch fuer
* Vorgesetzte. Wer einen Nachweis fuer einen Kunden erstellt, filtert nach
@@ -131,7 +140,7 @@ export const Booking: React.FC = () => {
);
return (
<div className="mx-auto max-w-[1500px]">
<div className={`mx-auto max-w-[1500px] ${isMobile && !readOnly ? 'pb-16' : ''}`}>
{/* 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">
@@ -228,12 +237,13 @@ export const Booking: React.FC = () => {
<EntryList
entries={list}
onEdit={readOnly ? undefined : (entry) => setModal({ date: entry.entry_date, entry })}
onCopy={readOnly ? undefined : copy}
emptyText={`Keine Buchungen ${mode === 'week' ? 'in dieser Woche' : mode === 'month' ? 'in diesem Monat' : 'in diesem Jahr'}.`}
/>
</Card>
)}
{layout === 'calendar' && mode === 'week' && (
<WeekView from={range.from} entries={list} readOnly={readOnly} dailyTarget={(weekly / 5) * 60} onAdd={openNew} onEdit={(entry) => !readOnly && setModal({ date: entry.entry_date, entry })} />
<WeekView from={range.from} entries={list} readOnly={readOnly} dailyTarget={(weekly / 5) * 60} onAdd={openNew} onEdit={(entry) => !readOnly && setModal({ date: entry.entry_date, entry })} onCopy={copy} />
)}
{layout === 'calendar' && mode === 'month' && (
<MonthView
@@ -266,14 +276,29 @@ export const Booking: React.FC = () => {
<button
onClick={() => openNew()}
aria-label="Zeit buchen"
className="animate-glow fixed bottom-24 right-5 z-40 flex h-16 w-16 items-center justify-center rounded-full bg-brand-500 text-white shadow-lift transition active:scale-95"
className="animate-glow fixed bottom-24 left-1/2 z-40 flex -translate-x-1/2 h-16 w-16 items-center justify-center rounded-full bg-brand-500 text-white shadow-lift transition active:scale-95"
>
<Icon name="plus" className="h-8 w-8" strokeWidth={2.6} />
</button>
)}
{modal && projects.data && (
<EntryModal projects={projects.data} date={modal.date} entry={modal.entry} onClose={() => setModal(null)} onSaved={() => { entries.reload(); projects.reload(); }} />
<EntryModal
projects={projects.data}
date={modal.date}
entry={modal.entry}
onClose={() => setModal(null)}
onSaved={() => { entries.reload(); projects.reload(); }}
onDuplicate={copy}
/>
)}
{copyEntry && projects.data && (
<CopyDialog
entry={copyEntry}
projects={projects.data}
onClose={() => setCopyEntry(null)}
onSaved={() => { entries.reload(); projects.reload(); }}
/>
)}
{exportOpen && (
<ExportDialog
+279
View File
@@ -0,0 +1,279 @@
import React, { useMemo, useState } from 'react';
import { Modal } from '../ui/Modal';
import { Button } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { useAuth } from '../../lib/auth';
import { calendarApi, entriesApi } from '../../lib/api';
import { useLoader } from '../../lib/hooks';
import {
MONTHS,
WEEKDAYS_SHORT,
addDays,
addMonths,
colorFor,
formatDate,
formatDuration,
fromIso,
mondayOf,
monthEnd,
monthStart,
todayIso,
weekdayIndex,
} from '../../lib/format';
import { useToast } from '../Toast';
import { checkPot } from './budget';
import type { Project, TimeEntry } from '../../types';
interface Props {
entry: TimeEntry;
projects: Project[];
onClose: () => void;
onSaved: () => void;
}
/**
* Buchung auf mehrere Tage kopieren: kleiner Monatskalender, Tage per Klick
* an- und abwaehlen, dann alle auf einmal buchen. Projekt, Task, Notiz und
* Zeit kommen aus der Vorlage.
*/
export const CopyDialog: React.FC<Props> = ({ entry, projects, onClose, onSaved }) => {
const { user, settings } = useAuth();
const { addToast } = useToast();
const [month, setMonth] = useState(() => monthStart(todayIso()));
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const today = todayIso();
const start = mondayOf(month);
const end = monthEnd(month);
const weeks = Math.ceil(((fromIso(end).getTime() - fromIso(start).getTime()) / 86400000 + 1) / 7);
const cells = Array.from({ length: weeks * 7 }, (_, i) => addDays(start, i));
const gridTo = cells[cells.length - 1];
// Eigene Buchungen und Abwesenheiten im sichtbaren Ausschnitt - nur als Orientierung.
const booked = useLoader(() => entriesApi.list(start, gridTo, user!.id), [start, gridTo]);
const calendar = useLoader(() => calendarApi.get(start, gridTo), [start, gridTo]);
const minutesByDay = useMemo(() => {
const map = new Map<string, number>();
for (const e of booked.data ?? []) map.set(e.entry_date, (map.get(e.entry_date) ?? 0) + e.minutes);
return map;
}, [booked.data]);
const absenceByDay = useMemo(() => {
const map = new Map<string, string>();
for (const d of calendar.data?.days ?? []) if (d.user_id === user!.id) map.set(d.day, d.half ? `${d.label} (halber Tag)` : d.label);
return map;
}, [calendar.data, user]);
const project = projects.find((p) => p.id === entry.project_id && p.is_active) ?? null;
const task = project?.tasks.find((t) => t.id === entry.task_id) ?? null;
const taskGone = entry.task_id != null && (!task || !task.is_active);
/** Laufzeit und Topf pro Tag - das Budget selbst prueft das Backend beim Buchen. */
const blocked = (date: string) => {
if (!project) return 'Projekt ist nicht mehr aktiv.';
const check = checkPot(project, date, 0, settings.hours_per_day, settings.budget_warn_percent, settings.budget_warn_days);
return check.ok ? null : check.message;
};
const toggle = (date: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(date)) next.delete(date);
else next.add(date);
return next;
});
/** Alle Werktage des Monats ohne Urlaub und Feiertag. */
const selectWorkdays = () =>
setSelected((prev) => {
const next = new Set(prev);
for (const date of cells) {
if (date.slice(0, 7) !== month.slice(0, 7) || weekdayIndex(date) >= 5) continue;
if (absenceByDay.has(date) || blocked(date)) continue;
next.add(date);
}
return next;
});
const clearMonth = () =>
setSelected((prev) => new Set([...prev].filter((d) => d.slice(0, 7) !== month.slice(0, 7))));
const dates = [...selected].sort();
const totalMinutes = dates.length * entry.minutes;
const save = async () => {
if (!project || dates.length === 0) return;
setBusy(true);
const done: string[] = [];
const failed: { date: string; message: string }[] = [];
const warnings = new Set<string>();
// Nacheinander, damit das Backend den Topf nach jeder Buchung neu rechnet.
for (const date of dates) {
try {
const result = await entriesApi.create({
project_id: project.id,
task_id: taskGone ? null : entry.task_id,
entry_date: date,
minutes: entry.minutes,
note: entry.note ?? '',
});
done.push(date);
if (result.warning) warnings.add(result.warning);
} catch (err) {
failed.push({ date, message: err instanceof Error ? err.message : 'Speichern fehlgeschlagen.' });
}
}
setBusy(false);
if (done.length) {
addToast(`${done.length} × ${formatDuration(entry.minutes)} h auf ${project.display_name} gebucht.`, 'success');
onSaved();
}
warnings.forEach((w) => addToast(w, 'info'));
if (failed.length === 0) return onClose();
// Fehlgeschlagene Tage bleiben ausgewaehlt, damit man sie gezielt nachbuchen oder abwaehlen kann.
setSelected(new Set(failed.map((f) => f.date)));
for (const f of failed) addToast(`${formatDate(f.date)}: ${f.message}`, 'error');
booked.reload();
};
const color = colorFor(entry.customer_id, entry.customer_color);
return (
<Modal
title="Buchung kopieren"
subtitle="Tage auswählen, auf die kopiert werden soll"
onClose={onClose}
size="md"
footer={
<div className="flex items-center justify-between gap-3">
<p className="tabular text-xs font-bold text-ink-500">
{dates.length ? `${dates.length} ${dates.length === 1 ? 'Tag' : 'Tage'} · ${formatDuration(totalMinutes)} h` : 'Keine Tage gewählt'}
</p>
<div className="flex gap-2">
<Button variant="secondary" onClick={onClose}>
Abbrechen
</Button>
<Button icon="copy" busy={busy} onClick={save} disabled={!project || dates.length === 0}>
Buchen
</Button>
</div>
</div>
}
>
<div className="space-y-4">
{/* Vorlage */}
<div className="relative overflow-hidden rounded-xl border border-ink-200 bg-ink-50/60 p-3 pl-4">
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="break-words text-sm font-extrabold text-ink-900">{entry.project_display}</p>
{entry.task_name && <p className="break-words text-xs font-semibold text-ink-500">{entry.task_name}</p>}
{entry.note && <p className="mt-0.5 break-words text-xs text-ink-400">{entry.note}</p>}
</div>
<span className="tabular shrink-0 text-lg font-extrabold text-ink-800">{formatDuration(entry.minutes)}</span>
</div>
</div>
{!project && (
<p className="rounded-xl border border-red-200 bg-red-50 p-3 text-sm font-semibold text-red-700">
Das Projekt ist nicht mehr aktiv oder dir nicht mehr zugewiesen – Kopieren ist nicht möglich.
</p>
)}
{project && taskGone && (
<p className="rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm font-semibold text-amber-700">
Der Task „{entry.task_name}“ ist nicht mehr aktiv – die Kopien werden ohne Task gebucht.
</p>
)}
{/* Monatskalender */}
<div>
<div className="mb-2 flex items-center justify-between">
<button
onClick={() => setMonth(addMonths(month, -1))}
className="rounded-lg p-2 text-ink-500 transition hover:bg-ink-100 hover:text-ink-800"
aria-label="Vorheriger Monat"
>
<Icon name="chevronLeft" className="h-4 w-4" />
</button>
<p className="text-sm font-extrabold text-ink-900">
{MONTHS[Number(month.slice(5, 7)) - 1]} {month.slice(0, 4)}
</p>
<button
onClick={() => setMonth(addMonths(month, 1))}
className="rounded-lg p-2 text-ink-500 transition hover:bg-ink-100 hover:text-ink-800"
aria-label="Nächster Monat"
>
<Icon name="chevronRight" className="h-4 w-4" />
</button>
</div>
<div className="mb-1 grid grid-cols-7 gap-1">
{WEEKDAYS_SHORT.map((d) => (
<p key={d} className="py-1 text-center text-[10px] font-extrabold uppercase tracking-wider text-ink-400">
{d}
</p>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{cells.map((date, i) => {
const inMonth = date.slice(0, 7) === month.slice(0, 7);
const reason = blocked(date);
const absence = absenceByDay.get(date);
const minutes = minutesByDay.get(date) ?? 0;
const active = selected.has(date);
const weekend = i % 7 >= 5;
const title = [formatDate(date, 'long'), reason, absence, minutes ? `${formatDuration(minutes)} h gebucht` : null].filter(Boolean).join('\n');
return (
<button
key={date}
onClick={() => toggle(date)}
disabled={!!reason || busy}
aria-pressed={active}
title={title}
className={`relative flex h-12 flex-col items-center justify-center rounded-xl border text-sm font-extrabold transition disabled:cursor-not-allowed ${
active
? 'border-brand-500 bg-brand-500 text-white shadow-card'
: reason
? 'border-transparent text-ink-300 line-through'
: absence
? 'border-amber-200 bg-amber-50 text-amber-700 hover:border-brand-300'
: date === today
? 'border-brand-300 text-brand-700 hover:bg-brand-50'
: weekend
? 'border-ink-100 bg-ink-50/60 text-ink-500 hover:border-brand-300'
: 'border-ink-200 text-ink-700 hover:border-brand-300 hover:bg-brand-50'
} ${inMonth ? '' : 'opacity-40'}`}
>
{fromIso(date).getDate()}
<span className={`tabular text-[9px] font-bold leading-none ${active ? 'text-white/80' : 'text-ink-400'}`}>
{minutes ? formatDuration(minutes) : date === entry.entry_date ? 'Vorlage' : ' '}
</span>
</button>
);
})}
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<p className="flex items-center gap-1.5 text-[11px] text-ink-400">
<span className="h-2.5 w-2.5 rounded-sm border border-amber-200 bg-amber-50" /> Urlaub/Feiertag · Zahl = bereits gebucht
</p>
<div className="flex gap-1">
<Button variant="ghost" size="sm" onClick={selectWorkdays} disabled={!project || busy}>
Werktage
</Button>
<Button variant="ghost" size="sm" onClick={clearMonth} disabled={busy}>
Leeren
</Button>
</div>
</div>
</div>
{dates.length > 0 && (
<p className="break-words text-xs text-ink-500">
<span className="font-bold text-ink-700">Gewählt: </span>
{dates.map((d) => formatDate(d)).join(', ')}
</p>
)}
</div>
</Modal>
);
};
+24 -5
View File
@@ -17,8 +17,10 @@ export const EntryList: React.FC<{
entries: TimeEntry[];
showUser?: boolean;
onEdit?: (entry: TimeEntry) => void;
/** Kopier-Knopf je Zeile: legt die Buchung als neue Buchung an. */
onCopy?: (entry: TimeEntry) => void;
emptyText?: string;
}> = ({ entries, showUser = false, onEdit, emptyText = 'Keine Buchungen im Zeitraum.' }) => {
}> = ({ entries, showUser = false, onEdit, onCopy, emptyText = 'Keine Buchungen im Zeitraum.' }) => {
const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: 'entry_date', dir: 1 });
const sorted = useMemo(() => {
@@ -79,7 +81,8 @@ export const EntryList: React.FC<{
{head('project_display', 'Projekt', 'left', 'w-[24%]')}
{head('task_name', 'Task', 'left')}
{head('billable', 'Billable Zeit', 'right', 'w-[104px]')}
{head('non_billable', 'Non-billable Zeit', 'right', 'w-[124px] pr-4')}
{head('non_billable', 'Non-billable Zeit', 'right', `w-[124px] ${onCopy ? '' : 'pr-4'}`)}
{onCopy && <th className="w-[48px] pr-3" aria-label="Kopieren" />}
</tr>
</thead>
<tbody>
@@ -93,7 +96,7 @@ export const EntryList: React.FC<{
<tr
key={e.id}
onClick={() => onEdit?.(e)}
className={`animate-rise border-b border-ink-100 last:border-0 ${onEdit ? 'cursor-pointer hover:bg-brand-50/50' : 'hover:bg-ink-50'} ${
className={`animate-rise group border-b border-ink-100 last:border-0 ${onEdit ? 'cursor-pointer hover:bg-brand-50/50' : 'hover:bg-ink-50'} ${
first && i > 0 ? 'border-t border-t-ink-200' : ''
}`}
style={{ '--i': Math.min(i, 15) } as React.CSSProperties}
@@ -134,9 +137,24 @@ export const EntryList: React.FC<{
<td className={`tabular whitespace-nowrap px-3 py-2.5 text-right align-top font-extrabold ${bill ? 'text-ink-900' : 'text-ink-300'}`}>
{bill ? formatHours(bill) : '–'}
</td>
<td className={`tabular whitespace-nowrap py-2.5 pl-3 pr-4 text-right align-top font-extrabold ${non ? 'text-ink-700' : 'text-ink-300'}`}>
<td className={`tabular whitespace-nowrap py-2.5 pl-3 ${onCopy ? 'pr-3' : 'pr-4'} text-right align-top font-extrabold ${non ? 'text-ink-700' : 'text-ink-300'}`}>
{non ? formatHours(non) : '–'}
</td>
{onCopy && (
<td className="py-1.5 pr-3 text-right align-top">
<button
onClick={(event) => {
event.stopPropagation();
onCopy(e);
}}
title="Auf andere Tage kopieren"
aria-label="Buchung kopieren"
className="inline-flex h-7 w-7 items-center justify-center rounded-lg text-ink-400 transition hover:bg-brand-100 hover:text-brand-700 pointer-fine:opacity-0 pointer-fine:group-hover:opacity-100 pointer-fine:focus-visible:opacity-100"
>
<Icon name="copy" className="h-4 w-4" />
</button>
</td>
)}
</tr>
);
})}
@@ -148,7 +166,8 @@ export const EntryList: React.FC<{
</td>
<td className="tabular px-3 py-3 text-right">{formatHours(sum.billable + sum.non)} h gesamt</td>
<td className="tabular px-3 py-3 text-right">{formatHours(sum.billable)}</td>
<td className="tabular py-3 pl-3 pr-4 text-right">{formatHours(sum.non)}</td>
<td className={`tabular py-3 pl-3 ${onCopy ? 'pr-3' : 'pr-4'} text-right`}>{formatHours(sum.non)}</td>
{onCopy && <td />}
</tr>
</tfoot>
</table>
+12 -4
View File
@@ -15,6 +15,7 @@ interface Props {
entry?: TimeEntry | null;
onClose: () => void;
onSaved: () => void;
onDuplicate?: (entry: TimeEntry) => void;
}
/** Reihenfolge wie in der Auswahlliste: nach Kunde gruppiert, innerhalb in API-Reihenfolge. */
@@ -51,7 +52,7 @@ function firstTask(project: Project | undefined): number | '' {
* Buchungsdialog: oben Projekt, darunter Task, darunter Freitext mit der Zeit
* rechts daneben. Die Zeit versteht "1:23" und "1,5".
*/
export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry, onClose, onSaved }) => {
export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry, onClose, onSaved, onDuplicate }) => {
const { settings } = useAuth();
const { addToast } = useToast();
const bookable = useMemo(() => projects.filter((p) => p.is_active || p.id === entry?.project_id), [projects, entry]);
@@ -179,9 +180,16 @@ export const EntryModal: React.FC<Props> = ({ projects, date: initialDate, entry
footer={
<div className="flex items-center justify-between gap-3">
{entry ? (
<Button variant="ghost" icon="trash" className="text-red-600 hover:bg-red-50" onClick={() => setConfirmDelete(true)}>
Löschen
</Button>
<div className="flex gap-1">
<Button variant="ghost" icon="trash" className="text-red-600 hover:bg-red-50" onClick={() => setConfirmDelete(true)} aria-label="Löschen">
<span className="hidden sm:inline">Löschen</span>
</Button>
{onDuplicate && (
<Button variant="ghost" icon="copy" onClick={() => onDuplicate(entry)} title="Auf andere Tage kopieren" aria-label="Kopieren">
<span className="hidden sm:inline">Kopieren</span>
</Button>
)}
</div>
) : (
<span className="hidden text-[11px] text-ink-400 sm:block">Enter im Zeitfeld bucht · sonst Strg + Enter</span>
)}
+43 -22
View File
@@ -11,13 +11,14 @@ interface Props {
dailyTarget: number;
onAdd: (date: string) => void;
onEdit: (entry: TimeEntry) => void;
onCopy: (entry: TimeEntry) => void;
}
/**
* Wochenansicht wie ein moderner Stundenzettel: sieben Spalten, jede Buchung
* als Karte mit Kundenfarbe. Auf dem Handy ein Tagesstreifen mit Liste.
*/
export const WeekView: React.FC<Props> = ({ from, entries, readOnly, dailyTarget, onAdd, onEdit }) => {
export const WeekView: React.FC<Props> = ({ from, entries, readOnly, dailyTarget, onAdd, onEdit, onCopy }) => {
const isMobile = useIsMobile();
const today = todayIso();
const days = Array.from({ length: 7 }, (_, i) => addDays(from, i));
@@ -58,7 +59,7 @@ export const WeekView: React.FC<Props> = ({ from, entries, readOnly, dailyTarget
</div>
<div key={selected} className="space-y-2">
{list.map((entry, i) => (
<EntryCard key={entry.id} entry={entry} index={i} onClick={() => onEdit(entry)} readOnly={readOnly} large />
<EntryCard key={entry.id} entry={entry} index={i} onClick={() => onEdit(entry)} onCopy={() => onCopy(entry)} readOnly={readOnly} large />
))}
{list.length === 0 && (
<button
@@ -102,7 +103,7 @@ export const WeekView: React.FC<Props> = ({ from, entries, readOnly, dailyTarget
</div>
<div className="flex flex-1 flex-col gap-2 p-2">
{list.map((entry, j) => (
<EntryCard key={entry.id} entry={entry} index={j} onClick={() => onEdit(entry)} readOnly={readOnly} />
<EntryCard key={entry.id} entry={entry} index={j} onClick={() => onEdit(entry)} onCopy={() => onCopy(entry)} readOnly={readOnly} />
))}
{!readOnly && (
<button
@@ -131,32 +132,52 @@ const DayMeter: React.FC<{ minutes: number; target: number }> = ({ minutes, targ
);
};
export const EntryCard: React.FC<{ entry: TimeEntry; index: number; onClick: () => void; readOnly: boolean; large?: boolean }> = ({
export const EntryCard: React.FC<{ entry: TimeEntry; index: number; onClick: () => void; onCopy?: () => void; readOnly: boolean; large?: boolean }> = ({
entry,
index,
onClick,
onCopy,
readOnly,
large,
}) => {
const color = colorFor(entry.customer_id, entry.customer_color);
const canCopy = !readOnly && !!onCopy;
return (
<button
onClick={onClick}
disabled={readOnly && !large}
className={`animate-rise relative w-full overflow-hidden rounded-xl border border-ink-200 bg-surface text-left transition ${
readOnly ? 'cursor-default' : 'hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift'
} ${large ? 'p-4 pl-5' : 'p-2.5 pl-3.5'}`}
style={{ '--i': index } as React.CSSProperties}
title={`${entry.project_display}${entry.task_name ? ` · ${entry.task_name}` : ''}${entry.note ? `\n${entry.note}` : ''}`}
>
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
<div className="flex items-start justify-between gap-2">
<p className={`min-w-0 font-extrabold text-ink-900 ${large ? 'text-sm' : 'line-clamp-2 text-xs leading-snug'}`}>{entry.project_display}</p>
<span className={`tabular shrink-0 font-extrabold text-ink-800 ${large ? 'text-lg' : 'text-xs'}`}>{formatDuration(entry.minutes)}</span>
</div>
{entry.task_name && <p className={`truncate font-semibold text-ink-500 ${large ? 'text-xs' : 'text-[11px]'}`}>{entry.task_name}</p>}
{entry.note && <p className={`mt-0.5 text-ink-400 ${large ? 'text-xs' : 'line-clamp-2 text-[11px] leading-snug'}`}>{entry.note}</p>}
{!entry.billable && <span className="mt-1 inline-block rounded-full bg-ink-100 px-1.5 text-[9px] font-bold uppercase tracking-wide text-ink-500">non-billable</span>}
</button>
// Kopier-Knopf als Geschwister der Karte - ein Button im Button ist kein gueltiges HTML.
<div className="group/card relative">
<button
onClick={onClick}
disabled={readOnly && !large}
className={`animate-rise relative w-full overflow-hidden rounded-xl border border-ink-200 bg-surface text-left transition ${
readOnly ? 'cursor-default' : 'hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift'
} ${large ? 'p-4 pl-5' : 'p-2.5 pl-3.5'}`}
style={{ '--i': index } as React.CSSProperties}
title={`${entry.project_display}${entry.task_name ? ` · ${entry.task_name}` : ''}${entry.note ? `
${entry.note}` : ''}`}
>
<span aria-hidden className="absolute inset-y-0 left-0 w-1.5" style={{ background: color }} />
<div className="flex items-start justify-between gap-2">
<p className={`min-w-0 font-extrabold text-ink-900 ${large ? 'text-sm' : 'line-clamp-2 text-xs leading-snug'}`}>{entry.project_display}</p>
<span className={`tabular shrink-0 font-extrabold text-ink-800 ${large ? 'text-lg' : 'text-xs'}`}>{formatDuration(entry.minutes)}</span>
</div>
<div className={canCopy && large ? 'pr-10' : ''}>
{entry.task_name && <p className={`truncate font-semibold text-ink-500 ${large ? 'text-xs' : 'text-[11px]'}`}>{entry.task_name}</p>}
{entry.note && <p className={`mt-0.5 text-ink-400 ${large ? 'text-xs' : 'line-clamp-2 text-[11px] leading-snug'}`}>{entry.note}</p>}
{!entry.billable && <span className="mt-1 inline-block rounded-full bg-ink-100 px-1.5 text-[9px] font-bold uppercase tracking-wide text-ink-500">non-billable</span>}
</div>
</button>
{canCopy && (
<button
onClick={onCopy}
title="Auf andere Tage kopieren"
aria-label="Buchung kopieren"
className={`absolute flex items-center justify-center rounded-lg border border-ink-200 bg-surface text-ink-500 shadow-card transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 pointer-fine:opacity-0 pointer-fine:group-hover/card:opacity-100 pointer-fine:focus-visible:opacity-100 ${
large ? 'bottom-3 right-3 h-9 w-9' : 'bottom-1.5 right-1.5 h-6 w-6'
}`}
>
<Icon name="copy" className={large ? 'h-4 w-4' : 'h-3.5 w-3.5'} />
</button>
)}
</div>
);
};
+298
View File
@@ -0,0 +1,298 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Avatar, Card, EmptyState, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { MonthGrid } from './MonthGrid';
import { Timeline } from './Timeline';
import { calendarApi, type CalendarUser } from '../../lib/api';
import { useLoader, usePersistentState } from '../../lib/hooks';
import { MONTHS, addDays, addMonths, formatDate, fromIso, mondayOf, monthEnd, monthStart, todayIso } from '../../lib/format';
import { subordinates } from '../team/hierarchy';
import { buildIndex, holidaysOn, shortNames, userColor, vacationRanges, vacationsOn } from './calendarData';
type Mode = 'month' | 'timeline';
/** Tage als "5.10." bzw. "5.–9.10." / "28.9.–2.10." */
function rangeLabel(from: string, to: string) {
const a = fromIso(from);
const b = fromIso(to);
if (from === to) return `${a.getDate()}.${a.getMonth() + 1}.`;
if (a.getMonth() === b.getMonth()) return `${a.getDate()}.–${b.getDate()}.${b.getMonth() + 1}.`;
return `${a.getDate()}.${a.getMonth() + 1}.–${b.getDate()}.${b.getMonth() + 1}.`;
}
const dayCount = (n: number) => `${n.toLocaleString('de-DE')} ${n === 1 ? 'Tag' : 'Tage'}`;
/**
* Kalender fuer alle: genehmigte Urlaube und Feiertage aus HRworks, auch in
* der Zukunft. Monatsansicht als Standard, dazu eine Zeitleiste je Person.
*/
export const Calendar: React.FC = () => {
const today = todayIso();
// Bewusst nicht gemerkt: der Kalender oeffnet immer in der Monatsansicht.
const [mode, setMode] = useState<Mode>('month');
const [month, setMonth] = useState(monthStart(today));
const [selected, setSelected] = useState<string | null>(today);
const [team, setTeam] = usePersistentState<string>('pulse_calendar_team', 'all');
const [search, setSearch] = useState('');
const [onlyAway, setOnlyAway] = usePersistentState('pulse_calendar_only_away', false);
const gridStart = mondayOf(month);
const end = monthEnd(month);
const weeks = Math.ceil(((fromIso(end).getTime() - fromIso(gridStart).getTime()) / 86400000 + 1) / 7);
const gridEnd = addDays(gridStart, weeks * 7 - 1);
const data = useLoader(() => calendarApi.get(gridStart, gridEnd), [gridStart, gridEnd]);
const shift = (delta: number) => {
const next = addMonths(month, delta);
setMonth(next);
setSelected(today.slice(0, 7) === next.slice(0, 7) ? today : null);
};
const goToday = () => {
setMonth(monthStart(today));
setSelected(today);
};
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const target = e.target as HTMLElement | null;
if (target && ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName)) return;
if (e.key === 'ArrowLeft') shift(-1);
if (e.key === 'ArrowRight') shift(1);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
});
const users = data.data?.users ?? [];
// Teams: jeder, dem jemand unterstellt ist, samt allen darunter.
const leads = useMemo(() => {
const ids = new Set(users.map((u) => u.manager_id).filter((id): id is number => id != null));
return users.filter((u) => ids.has(u.id)).sort((a, b) => a.name.localeCompare(b.name, 'de'));
}, [users]);
const visibleUsers = useMemo(() => {
let list: CalendarUser[] = users;
if (team.startsWith('lead:')) {
const leadId = Number(team.slice(5));
const lead = users.find((u) => u.id === leadId);
// Unbekanntes Team (Vorgesetzter weg): alle zeigen statt einer leeren Seite.
if (lead) list = [lead, ...subordinates(users, leadId)];
}
const q = search.trim().toLowerCase();
if (q) list = list.filter((u) => u.name.toLowerCase().includes(q));
return [...list].sort((a, b) => a.name.localeCompare(b.name, 'de'));
}, [users, team, search]);
const index = useMemo(
() => buildIndex(data.data?.days ?? [], new Set(visibleUsers.map((u) => u.id))),
[data.data, visibleUsers]
);
const userMap = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]);
const names = useMemo(() => shortNames(users), [users]);
// Wer ist in diesem Monat weg, und wann?
const monthly = useMemo(
() =>
visibleUsers
.map((u) => ({ user: u, ranges: vacationRanges(index.byUser.get(u.id), month, end) }))
.filter((x) => x.ranges.length > 0),
[visibleUsers, index, month, end]
);
const timelineUsers = onlyAway ? visibleUsers.filter((u) => monthly.some((m) => m.user.id === u.id)) : visibleUsers;
const selectedAway = selected
? vacationsOn(index, selected)
.map((d) => ({ entry: d, user: userMap.get(d.user_id)! }))
.filter((x) => x.user)
.sort((a, b) => a.user.name.localeCompare(b.user.name, 'de'))
: [];
const selectedHolidays = selected ? holidaysOn(index, selected) : [];
const monthHolidays = useMemo(() => {
const out: { day: string; labels: string[] }[] = [];
for (let d = month; d <= end; d = addDays(d, 1)) {
const labels = holidaysOn(index, d);
if (labels.length) out.push({ day: d, labels });
}
return out;
}, [index, month, end]);
const label = `${MONTHS[fromIso(month).getMonth()]} ${fromIso(month).getFullYear()}`;
return (
<div className="mx-auto max-w-[1500px]">
{/* Werkzeugleiste */}
<div className="mb-4 flex flex-wrap items-center gap-2 sm:gap-3">
<div className="flex items-center rounded-xl border border-ink-200 bg-surface shadow-card">
<button onClick={() => shift(-1)} className="rounded-l-xl p-2.5 text-ink-500 hover:bg-ink-50 hover:text-ink-900" aria-label="Vorheriger Monat">
<Icon name="chevronLeft" className="h-4 w-4" strokeWidth={2.2} />
</button>
<button onClick={goToday} className="border-x border-ink-200 px-3 py-2 text-xs font-bold text-ink-600 hover:bg-ink-50">
Heute
</button>
<button onClick={() => shift(1)} className="rounded-r-xl p-2.5 text-ink-500 hover:bg-ink-50 hover:text-ink-900" aria-label="Nächster Monat">
<Icon name="chevronRight" className="h-4 w-4" strokeWidth={2.2} />
</button>
</div>
<h2 className="tabular min-w-0 flex-1 truncate text-base text-ink-900 sm:flex-none sm:text-lg">{label}</h2>
<div className="order-last flex w-full flex-wrap items-center gap-2 sm:order-none sm:ml-auto sm:w-auto sm:flex-nowrap">
<Segmented
value={mode}
onChange={setMode}
className="flex-1 sm:flex-none"
options={[
{ value: 'month', label: 'Monat', icon: 'calendar', title: 'Monatsansicht' },
{ value: 'timeline', label: 'Zeitleiste', icon: 'list', title: 'Eine Zeile je Mitarbeiter' },
]}
/>
<select
className={`${inputClass} w-auto min-w-0 max-w-[200px] flex-1 py-2 text-xs font-bold sm:flex-none`}
value={team.startsWith('lead:') && !leads.some((l) => `lead:${l.id}` === team) ? 'all' : team}
onChange={(e) => setTeam(e.target.value)}
title="Team filtern"
>
<option value="all">Alle Mitarbeiter</option>
{leads.map((l) => (
<option key={l.id} value={`lead:${l.id}`}>
Team {l.name}
</option>
))}
</select>
<div className="relative min-w-0 flex-1 sm:w-44 sm:flex-none">
<Icon name="search" className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-400" />
<input
className={`${inputClass} py-2 pl-8 text-xs`}
placeholder="Name suchen"
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label="Name suchen"
/>
</div>
</div>
</div>
{data.error && <ErrorBox message={data.error} onRetry={data.reload} />}
{data.loading && !data.data ? (
<Spinner />
) : data.data && !data.data.last_run && data.data.days.length === 0 ? (
<EmptyState
icon="calendar"
title="Noch keine Urlaube übernommen"
text="Der Kalender füllt sich mit dem nächsten Abgleich aus HRworks (morgens und mittags)."
/>
) : (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
<div key={`${mode}-${month}`} className="animate-fade min-w-0 space-y-3">
{mode === 'month' ? (
<MonthGrid
month={month}
gridStart={gridStart}
weeks={weeks}
index={index}
users={userMap}
names={names}
selected={selected}
onSelect={setSelected}
/>
) : (
<>
<label className="flex w-fit cursor-pointer items-center gap-2 text-xs font-bold text-ink-600">
<input type="checkbox" className="accent-[var(--color-brand-500)]" checked={onlyAway} onChange={(e) => setOnlyAway(e.target.checked)} />
Nur Mitarbeiter mit Urlaub in diesem Monat
</label>
{timelineUsers.length ? (
<Timeline month={month} index={index} users={timelineUsers} selected={selected} onSelect={setSelected} />
) : (
<EmptyState icon="calendar" title="Niemand im Urlaub" text="In diesem Monat ist für die Auswahl kein Urlaub eingetragen." />
)}
</>
)}
<Legend lastRun={data.data?.last_run ?? null} />
</div>
<div className="min-w-0 space-y-4">
{selected && (
<Card className="animate-rise p-4">
<h3 className="text-sm text-ink-900">{formatDate(selected, 'long')}</h3>
{selectedHolidays.length > 0 && (
<p className="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-amber-50 px-2 py-1 text-xs font-bold text-amber-700">
<Icon name="sun" className="h-3.5 w-3.5" />
{selectedHolidays.join(' · ')}
</p>
)}
{selectedAway.length ? (
<ul className="mt-3 space-y-2">
{selectedAway.map(({ entry, user }) => (
<li key={user.id} className="flex items-center gap-2.5">
<Avatar id={user.id} name={user.name} color={user.color} size={28} external={user.is_external} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink-800">{user.name}</span>
<span className="block text-xs text-ink-400">
{entry.label}
{entry.half ? ' · halber Tag' : ''}
</span>
</span>
</li>
))}
</ul>
) : (
<p className="mt-2 text-xs text-ink-400">Niemand im Urlaub.</p>
)}
</Card>
)}
<Card className="animate-rise p-4" style={{ '--i': 1 } as React.CSSProperties}>
<h3 className="text-sm text-ink-900">Urlaub im {MONTHS[fromIso(month).getMonth()]}</h3>
{monthly.length ? (
<ul className="mt-3 space-y-2.5">
{monthly.map(({ user, ranges }) => (
<li key={user.id} className="flex items-start gap-2.5">
<span className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: userColor(user) }} />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-bold text-ink-800">{user.name}</span>
<span className="block text-xs text-ink-500">
{ranges.map((r) => `${rangeLabel(r.from, r.to)} (${dayCount(r.days)})`).join(', ')}
</span>
</span>
</li>
))}
</ul>
) : (
<p className="mt-2 text-xs text-ink-400">In diesem Monat ist kein Urlaub eingetragen.</p>
)}
{monthHolidays.length > 0 && (
<div className="mt-4 border-t border-ink-100 pt-3">
<p className="mb-1.5 text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Feiertage</p>
<ul className="space-y-1 text-xs text-ink-600">
{monthHolidays.map((h) => (
<li key={h.day}>
<b className="tabular text-amber-700">{formatDate(h.day, 'day')}</b> {h.labels.join(' · ')}
</li>
))}
</ul>
</div>
)}
</Card>
</div>
</div>
)}
</div>
);
};
const Legend: React.FC<{ lastRun: string | null }> = ({ lastRun }) => (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 px-1 text-[11px] font-semibold text-ink-400">
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-4 rounded-sm bg-brand-500" /> Urlaub (genehmigt)
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-4 rounded-sm bg-brand-500 opacity-50" /> halber Tag
</span>
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-4 rounded-sm border border-amber-200 bg-amber-50" /> Feiertag
</span>
{lastRun && <span className="sm:ml-auto">Stand HRworks: {new Date(lastRun).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })}</span>}
</div>
);
+110
View File
@@ -0,0 +1,110 @@
import React from 'react';
import { WEEKDAYS_SHORT, addDays, fromIso, todayIso } from '../../lib/format';
import { useIsMobile } from '../../lib/hooks';
import type { CalendarUser } from '../../lib/api';
import { holidaysOn, userColor, vacationsOn, type CalendarIndex } from './calendarData';
/** Monatsraster: je Tag die Abwesenden, Feiertage in Bernstein. */
export const MonthGrid: React.FC<{
month: string;
gridStart: string;
weeks: number;
index: CalendarIndex;
users: Map<number, CalendarUser>;
names: Map<number, string>;
selected: string | null;
onSelect: (day: string) => void;
}> = ({ month, gridStart, weeks, index, users, names, selected, onSelect }) => {
const isMobile = useIsMobile();
const today = todayIso();
const cells = Array.from({ length: weeks * 7 }, (_, i) => addDays(gridStart, i));
const maxChips = isMobile ? 4 : 3;
return (
<div className="rounded-2xl border border-ink-200 bg-surface p-2 shadow-card sm:p-3">
<div className="mb-1 grid grid-cols-7 gap-1 sm:gap-2">
{WEEKDAYS_SHORT.map((d) => (
<p key={d} className="py-1 text-center text-[11px] font-extrabold uppercase tracking-wider text-ink-400">
{d}
</p>
))}
</div>
<div className="grid grid-cols-7 gap-1 sm:gap-2">
{cells.map((date, i) => {
const inMonth = date.slice(0, 7) === month.slice(0, 7);
const weekend = i % 7 >= 5;
const holidays = holidaysOn(index, date);
const away = vacationsOn(index, date)
.map((d) => ({ entry: d, user: users.get(d.user_id) }))
.filter((x): x is { entry: typeof x.entry; user: CalendarUser } => Boolean(x.user))
.sort((a, b) => a.user.name.localeCompare(b.user.name, 'de'));
const isSelected = selected === date;
const title = [
...holidays,
...away.map(({ entry, user }) => `${user.name}${entry.half ? ' (halber Tag)' : ''}`),
].join('\n');
return (
<button
key={date}
onClick={() => onSelect(date)}
title={title || undefined}
aria-pressed={isSelected}
className={`animate-rise relative flex min-h-[62px] min-w-0 flex-col overflow-hidden rounded-xl border p-1.5 text-left transition hover:border-brand-300 hover:shadow-card sm:min-h-[112px] sm:p-2 ${
!inMonth ? 'border-transparent opacity-35' : ''
} ${
isSelected
? 'border-brand-500 ring-2 ring-brand-200'
: date === today
? 'border-brand-400'
: holidays.length
? 'border-amber-200 bg-amber-50/70'
: weekend
? 'border-ink-100 bg-ink-50/60'
: 'border-ink-200'
}`}
style={{ '--i': Math.floor(i / 7) } as React.CSSProperties}
>
<span className="flex items-center justify-between gap-1">
<span className={`text-xs font-extrabold sm:text-sm ${date === today ? 'text-brand-700' : holidays.length ? 'text-amber-700' : 'text-ink-700'}`}>
{fromIso(date).getDate()}
</span>
{away.length > 0 && isMobile && <span className="text-[10px] font-extrabold text-ink-500">{away.length}</span>}
</span>
{holidays.length > 0 && !isMobile && (
<span className="mt-0.5 truncate text-[10px] font-bold leading-tight text-amber-700">{holidays.join(' · ')}</span>
)}
{isMobile ? (
away.length > 0 && (
<span className="mt-auto flex flex-wrap gap-0.5">
{away.slice(0, maxChips).map(({ entry, user }) => (
<span
key={user.id}
className="h-2 w-2 rounded-full"
style={{ background: userColor(user), opacity: entry.half ? 0.5 : 1 }}
/>
))}
</span>
)
) : (
<span className="mt-1 flex min-w-0 flex-col gap-0.5">
{away.slice(0, maxChips).map(({ entry, user }) => (
<span
key={user.id}
className="flex min-w-0 items-center gap-1 rounded-md px-1 py-px text-[11px] font-bold text-ink-800"
style={{ background: `color-mix(in srgb, ${userColor(user)} 16%, transparent)` }}
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: userColor(user) }} />
<span className="truncate">{names.get(user.id)}</span>
{entry.half && <span className="ml-auto shrink-0 text-[10px] text-ink-500">½</span>}
</span>
))}
{away.length > maxChips && <span className="px-1 text-[10px] font-bold text-ink-400">+{away.length - maxChips} weitere</span>}
</span>
)}
</button>
);
})}
</div>
</div>
);
};
+97
View File
@@ -0,0 +1,97 @@
import React from 'react';
import { WEEKDAYS_SHORT, addDays, fromIso, monthEnd, todayIso, weekdayIndex } from '../../lib/format';
import { Avatar } from '../ui/controls';
import type { CalendarUser } from '../../lib/api';
import { holidaysOn, userColor, type CalendarIndex } from './calendarData';
/**
* Zeitleiste: Personen in Zeilen, Tage des Monats in Spalten. Zeigt auf einen
* Blick, wo sich Urlaube ueberschneiden. Breiter als das Handy - die Tabelle
* scrollt seitlich, die Namensspalte bleibt stehen.
*/
export const Timeline: React.FC<{
month: string;
index: CalendarIndex;
users: CalendarUser[];
selected: string | null;
onSelect: (day: string) => void;
}> = ({ month, index, users, selected, onSelect }) => {
const today = todayIso();
const days: string[] = [];
for (let d = month; d <= monthEnd(month); d = addDays(d, 1)) days.push(d);
return (
<div className="scrollbar-slim overflow-x-auto rounded-2xl border border-ink-200 bg-surface shadow-card">
<table className="w-full border-separate border-spacing-0 text-xs">
<thead>
<tr>
<th className="sticky left-0 z-20 min-w-[128px] border-b border-ink-200 bg-surface px-3 py-2 text-left text-[11px] font-extrabold uppercase tracking-wider text-ink-400 sm:min-w-[180px]">
Mitarbeiter
</th>
{days.map((day) => {
const weekend = weekdayIndex(day) >= 5;
const holidays = holidaysOn(index, day);
return (
<th
key={day}
title={holidays.join(', ') || undefined}
onClick={() => onSelect(day)}
className={`min-w-[28px] cursor-pointer border-b border-ink-200 px-0 py-1.5 text-center font-extrabold ${
day === selected ? 'bg-brand-100 text-brand-700' : holidays.length ? 'bg-amber-50 text-amber-700' : weekend ? 'bg-ink-50 text-ink-400' : 'text-ink-600'
}`}
>
<span className="block text-[9px] font-bold uppercase text-ink-400">{WEEKDAYS_SHORT[weekdayIndex(day)].slice(0, 2)}</span>
<span className={day === today ? 'rounded-full bg-brand-500 px-1 text-white' : ''}>{fromIso(day).getDate()}</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{users.map((user) => {
const entries = index.byUser.get(user.id);
const color = userColor(user);
return (
<tr key={user.id} className="group">
<td className="sticky left-0 z-10 border-b border-ink-100 bg-surface px-3 py-1.5 group-hover:bg-ink-50">
<span className="flex min-w-0 items-center gap-2">
<Avatar id={user.id} name={user.name} color={user.color} size={22} external={user.is_external} />
<span className="max-w-[92px] truncate font-bold text-ink-800 sm:max-w-[140px]">{user.name}</span>
</span>
</td>
{days.map((day) => {
const entry = entries?.get(day);
const weekend = weekdayIndex(day) >= 5;
const vacation = entry?.kind === 'vacation';
// Aneinanderstossende Urlaubstage als ein Balken zeichnen.
const prev = entries?.get(addDays(day, -1))?.kind === 'vacation';
const next = entries?.get(addDays(day, 1))?.kind === 'vacation';
return (
<td
key={day}
title={entry ? `${user.name}: ${entry.label}${entry.half ? ' (halber Tag)' : ''}` : undefined}
onClick={() => onSelect(day)}
className={`h-9 cursor-pointer border-b border-ink-100 px-0 py-1 ${
day === selected ? 'bg-brand-50' : entry?.kind === 'holiday' ? 'bg-amber-50' : weekend ? 'bg-ink-50/70' : ''
}`}
>
{vacation && (
<span
className={`block h-5 ${prev ? '' : 'ml-0.5 rounded-l-md'} ${next ? '' : 'mr-0.5 rounded-r-md'}`}
style={{
background: entry.half ? `linear-gradient(to top, ${color} 50%, color-mix(in srgb, ${color} 25%, transparent) 50%)` : color,
}}
/>
)}
{entry?.kind === 'holiday' && <span className="mx-auto block h-1.5 w-1.5 rounded-full bg-amber-400" />}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
);
};
+76
View File
@@ -0,0 +1,76 @@
import type { CalendarDay, CalendarUser } from '../../lib/api';
import { addDays, colorFor, weekdayIndex } from '../../lib/format';
/** Schnellzugriff auf die Kalendereintraege: je Tag und je Person. */
export interface CalendarIndex {
byDay: Map<string, CalendarDay[]>;
byUser: Map<number, Map<string, CalendarDay>>;
}
export function buildIndex(days: CalendarDay[], visible: Set<number>): CalendarIndex {
const byDay = new Map<string, CalendarDay[]>();
const byUser = new Map<number, Map<string, CalendarDay>>();
for (const d of days) {
if (!visible.has(d.user_id)) continue;
if (!byDay.has(d.day)) byDay.set(d.day, []);
byDay.get(d.day)!.push(d);
if (!byUser.has(d.user_id)) byUser.set(d.user_id, new Map());
byUser.get(d.user_id)!.set(d.day, d);
}
return { byDay, byUser };
}
/** Feiertage eines Tages (bei mehreren Standorten koennen es verschiedene sein). */
export function holidaysOn(index: CalendarIndex, day: string): string[] {
return [...new Set((index.byDay.get(day) ?? []).filter((d) => d.kind === 'holiday').map((d) => d.label))];
}
export function vacationsOn(index: CalendarIndex, day: string): CalendarDay[] {
return (index.byDay.get(day) ?? []).filter((d) => d.kind === 'vacation');
}
/**
* Kurzname fuer enge Stellen: der Vorname, bei Doppelten mit Initiale des
* Nachnamens ("Luisa S.").
*/
export function shortNames(users: CalendarUser[]): Map<number, string> {
const first = (name: string) => name.trim().split(/\s+/)[0] ?? name;
const count = new Map<string, number>();
for (const u of users) count.set(first(u.name), (count.get(first(u.name)) ?? 0) + 1);
return new Map(
users.map((u) => {
const parts = u.name.trim().split(/\s+/);
const f = first(u.name);
return [u.id, (count.get(f) ?? 0) > 1 && parts.length > 1 ? `${f} ${parts[parts.length - 1][0]}.` : f];
})
);
}
export const userColor = (u: Pick<CalendarUser, 'id' | 'color'>) => colorFor(u.id, u.color);
/**
* Urlaubstage einer Person zu Zeitraeumen zusammenfassen. Wochenenden und
* Feiertage dazwischen unterbrechen einen Urlaub nicht.
*/
export function vacationRanges(entries: Map<string, CalendarDay> | undefined, from: string, to: string) {
const ranges: { from: string; to: string; days: number; label: string }[] = [];
if (!entries) return ranges;
let current: (typeof ranges)[number] | null = null;
for (let day = from; day <= to; day = addDays(day, 1)) {
const entry = entries.get(day);
if (entry?.kind === 'vacation') {
if (current && current.label === entry.label) {
current.to = day;
} else {
current = { from: day, to: day, days: 0, label: entry.label };
ranges.push(current);
}
current.days += entry.half ? 0.5 : 1;
continue;
}
// Freie Tage ueberbruecken, alles andere beendet den Zeitraum.
const free = weekdayIndex(day) >= 5 || entry?.kind === 'holiday';
if (!free) current = null;
}
return ranges;
}
+1
View File
@@ -15,6 +15,7 @@ export const VIEW_ICONS: Record<ViewKey, IconName> = {
dashboard: 'dashboard',
buchung: 'clock',
projekte: 'folder',
kalender: 'calendar',
team: 'team',
kunden: 'building',
reports: 'chart',
+57 -22
View File
@@ -7,8 +7,9 @@ import { ExportMenu } from './ExportMenu';
import { ReportKpi, ReportRow } from './ProjectReport';
import { useAuth } from '../../lib/auth';
import { customersApi, qs, reportsApi, type CustomerReport as CustomerReportData } from '../../lib/api';
import { useLoader } from '../../lib/hooks';
import { MONTHS, colorFor, formatEuro, formatHours, formatNumber, todayIso } from '../../lib/format';
import { useLoader, usePersistentState } from '../../lib/hooks';
import { PeriodPicker, periodRange, type Period } from './PeriodPicker';
import { MONTHS, colorFor, formatDate, formatEuro, formatHours, formatNumber, isoWeek, monthStart, todayIso } from '../../lib/format';
import { useRouter } from '../../lib/router';
import { TYPE_TONE } from '../projects/Projects';
import { PROJECT_TYPE_LABELS } from '../../types';
@@ -16,7 +17,16 @@ import { PROJECT_TYPE_LABELS } from '../../types';
export const CustomerReport: React.FC<{ id: number | null }> = ({ id }) => {
const { navigate } = useRouter();
const customers = useLoader(() => customersApi.list(), []);
const report = useLoader(() => (id ? reportsApi.customer(id) : Promise.resolve(null)), [id]);
// Zeitraum bleibt beim Kundenwechsel erhalten - so lassen sich Kunden im
// selben Monat vergleichen. 'all' zeigt die ganze Kundenbeziehung.
const [period, setPeriod] = usePersistentState<Period | 'all'>('pulse_customer_report_period', 'all');
const [anchor, setAnchor] = useState(todayIso());
const [custom, setCustom] = useState({ from: monthStart(todayIso()), to: todayIso() });
const range = period === 'all' ? null : periodRange(period, anchor, custom);
const report = useLoader(
() => (id ? reportsApi.customer(id, range) : Promise.resolve(null)),
[id, range?.from, range?.to]
);
const [status, setStatus] = useState<'active' | 'inactive' | 'all'>('active');
// Der gewaehlte Kunde bleibt in der Liste, auch wenn der Filter ihn sonst
@@ -47,6 +57,7 @@ export const CustomerReport: React.FC<{ id: number | null }> = ({ id }) => {
{ value: 'all', label: 'Alle' },
]}
/>
<PeriodPicker period={period} onPeriod={setPeriod} anchor={anchor} onAnchor={setAnchor} custom={custom} onCustom={setCustom} allLabel="Gesamt" />
</div>
{!id ? (
<EmptyState icon="building" title="Kunde auswählen" text="Alle Projekte eines Kunden mit Budget, Umsatz, Verlauf und Team." />
@@ -55,13 +66,15 @@ export const CustomerReport: React.FC<{ id: number | null }> = ({ id }) => {
) : !report.data ? (
<Spinner label="Lade Kundenreport…" />
) : (
<Body key={id} data={report.data} />
<div className={`transition-opacity ${report.loading ? 'opacity-60' : ''}`}>
<Body key={id} data={report.data} rangeLabel={range?.label ?? null} />
</div>
)}
</div>
);
};
const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
const Body: React.FC<{ data: CustomerReportData; rangeLabel: string | null }> = ({ data, rangeLabel }) => {
const { settings } = useAuth();
const { navigate } = useRouter();
const [tab, setTab] = useState<'projects' | 'team' | 'tasks'>('projects');
@@ -69,14 +82,26 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
const c = data.customer;
const t = data.totals;
const color = colorFor(c.id, c.color);
const month = todayIso().slice(0, 7);
const series = useMemo(() => data.series.slice(-months), [data.series, months]);
const ranged = Boolean(data.range.from && data.range.to);
// Stundennachweis bezieht sich auf einen Monat: Monat des Zeitraumendes,
// sonst der laufende.
const month = (data.range.to ?? todayIso()).slice(0, 7);
const from = data.range.from ?? `${month.slice(0, 4)}-01-01`;
const to = data.range.to ?? todayIso();
const series = useMemo(() => (ranged ? data.series : data.series.slice(-months)), [data.series, months, ranged]);
const pointLabel = (start: string) => {
if (data.series_unit === 'day') return formatDate(start, 'day');
if (data.series_unit === 'week') return `KW ${isoWeek(start)}`;
return `${MONTHS[Number(start.slice(5, 7)) - 1].slice(0, 3)} ${start.slice(2, 4)}`;
};
const chartTitle = { day: 'Stunden je Tag', week: 'Stunden je Woche', month: 'Stunden je Monat' }[data.series_unit];
const rangeName = ranged ? 'Zeitraum' : 'Jahr';
const exportOptions = [
{ label: 'PDF – Stundennachweis (Monat)', hint: 'Vorlage für den Kunden, alle Berater', path: `/exports/timesheet${qs({ month, format: 'pdf', user_id: 'all', customer_id: c.id })}`, filename: `${c.short_name}-${month}.pdf` },
{ label: 'Excel – Stundennachweis mit Beträgen', hint: 'Für die Rechnungsstellung', path: `/exports/timesheet${qs({ month, format: 'xlsx', user_id: 'all', customer_id: c.id, amounts: 1 })}`, filename: `${c.short_name}-${month}.xlsx` },
{ label: 'PDF – Projekte (Jahr)', path: `/exports/report${qs({ from: `${month.slice(0, 4)}-01-01`, to: todayIso(), customer_id: c.id, group: 'projects', format: 'pdf', include_fixed: 1 })}`, filename: `${c.short_name}-projekte.pdf` },
{ label: 'CSV – alle Buchungen (Jahr)', path: `/exports/report${qs({ from: `${month.slice(0, 4)}-01-01`, to: todayIso(), customer_id: c.id, group: 'detail', format: 'csv', include_fixed: 1 })}`, filename: `${c.short_name}.csv` },
{ label: `PDF – Projekte (${rangeName})`, path: `/exports/report${qs({ from, to, customer_id: c.id, group: 'projects', format: 'pdf', include_fixed: 1 })}`, filename: `${c.short_name}-projekte.pdf` },
{ label: `CSV – alle Buchungen (${rangeName})`, path: `/exports/report${qs({ from, to, customer_id: c.id, group: 'detail', format: 'csv', include_fixed: 1 })}`, filename: `${c.short_name}.csv` },
];
return (
@@ -88,6 +113,7 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
<div className="min-w-0 flex-1">
<h2 className="truncate text-2xl text-ink-900 sm:text-3xl">{c.name}</h2>
<p className="text-sm text-ink-500">
{rangeLabel && <b className="font-bold text-ink-700">{rangeLabel} · </b>}
{data.projects.length} Projekte · {data.team.length} Mitarbeiter
{c.contact_person && ` · Ansprechpartner: ${c.contact_person}`}
</p>
@@ -101,10 +127,17 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
<ReportRow label="Billable" value={formatHours(t.billable_minutes)} />
<ReportRow label="Non-billable" value={formatHours(t.non_billable_minutes)} />
</ReportKpi>
<ReportKpi index={2} title="Umsatz erbracht">
<CountUp value={t.earned} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<ReportRow label="davon aus Buchungen" value={formatEuro(t.amount, 2)} />
</ReportKpi>
{ranged ? (
<ReportKpi index={2} title="Umsatz im Zeitraum">
<CountUp value={t.amount} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<ReportRow label="Erbracht gesamt" value={formatEuro(t.earned, 2)} />
</ReportKpi>
) : (
<ReportKpi index={2} title="Umsatz erbracht">
<CountUp value={t.earned} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<ReportRow label="davon aus Buchungen" value={formatEuro(t.amount, 2)} />
</ReportKpi>
)}
<ReportKpi index={3} title="Umsatz offen">
<CountUp value={t.open} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
<ReportRow label="in laufenden Töpfen" value={`${data.projects.reduce((s, p) => s + p.project.pots.filter((x) => x.status === 'active').length, 0)}`} />
@@ -118,25 +151,27 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
<Card className="animate-rise p-4 sm:p-5" style={{ '--i': 5 } as React.CSSProperties}>
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-base text-ink-900">Stunden je Monat</h3>
<h3 className="text-base text-ink-900">{chartTitle}</h3>
<Legend className="mt-1" items={[{ color: BILLABLE, label: 'Billable' }, { color: NON_BILLABLE, label: 'Non-billable' }]} />
</div>
<select className="rounded-xl border border-ink-200 bg-surface px-3 py-1.5 text-xs font-bold text-ink-600" value={months} onChange={(e) => setMonths(Number(e.target.value))}>
<option value={6}>6 Monate</option>
<option value={12}>12 Monate</option>
<option value={24}>24 Monate</option>
</select>
{!ranged && (
<select className="rounded-xl border border-ink-200 bg-surface px-3 py-1.5 text-xs font-bold text-ink-600" value={months} onChange={(e) => setMonths(Number(e.target.value))}>
<option value={6}>6 Monate</option>
<option value={12}>12 Monate</option>
<option value={24}>24 Monate</option>
</select>
)}
</div>
{series.length ? (
{series.some((m) => m.minutes > 0) ? (
<StackedBars
scale={60}
points={series.map((m) => ({ key: m.month, label: `${MONTHS[Number(m.month.slice(5)) - 1].slice(0, 3)} ${m.month.slice(2, 4)}`, a: m.billable_minutes, b: m.minutes - m.billable_minutes }))}
points={series.map((m) => ({ key: m.key, label: pointLabel(m.start), a: m.billable_minutes, b: m.minutes - m.billable_minutes }))}
format={(v) => `${formatHours(v, 1)} h`}
axisFormat={(v) => formatNumber(v / 60, 0)}
height={220}
/>
) : (
<p className="py-10 text-center text-xs text-ink-400">Noch keine Buchungen.</p>
<p className="py-10 text-center text-xs text-ink-400">{ranged ? 'Keine Buchungen im Zeitraum.' : 'Noch keine Buchungen.'}</p>
)}
</Card>
+86
View File
@@ -0,0 +1,86 @@
import React from 'react';
import { inputClass } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { MONTHS, addDays, addMonths, formatDate, isoWeek, mondayOf, monthEnd, monthStart } from '../../lib/format';
export type Period = 'week' | 'month' | 'quarter' | 'year' | 'custom';
export function periodRange(period: Period, anchor: string, custom: { from: string; to: string }) {
if (period === 'week') {
const from = mondayOf(anchor);
return { from, to: addDays(from, 6), label: `KW ${isoWeek(from)} · ${formatDate(from)} – ${formatDate(addDays(from, 6))}` };
}
if (period === 'month') return { from: monthStart(anchor), to: monthEnd(anchor), label: `${MONTHS[Number(anchor.slice(5, 7)) - 1]} ${anchor.slice(0, 4)}` };
if (period === 'quarter') {
const q = Math.floor((Number(anchor.slice(5, 7)) - 1) / 3);
const from = `${anchor.slice(0, 4)}-${String(q * 3 + 1).padStart(2, '0')}-01`;
return { from, to: monthEnd(addMonths(from, 2)), label: `Q${q + 1} ${anchor.slice(0, 4)}` };
}
if (period === 'year') return { from: `${anchor.slice(0, 4)}-01-01`, to: `${anchor.slice(0, 4)}-12-31`, label: anchor.slice(0, 4) };
return { ...custom, label: `${formatDate(custom.from)} – ${formatDate(custom.to)}` };
}
/** Verschiebt den Bezugstag um eine Periode vor oder zurueck. */
export function shiftAnchor(period: Period, anchor: string, step: number) {
if (period === 'week') return addDays(anchor, 7 * step);
if (period === 'month') return addMonths(anchor, step);
if (period === 'quarter') return addMonths(anchor, 3 * step);
if (period === 'year') return `${Number(anchor.slice(0, 4)) + step}-01-01`;
return anchor;
}
/**
* Auswahl Woche/Monat/Quartal/Jahr/Zeitraum mit Blaettern. `allLabel` blendet
* zusaetzlich eine Option ohne Einschraenkung ein (Wert 'all').
*/
export function PeriodPicker<P extends Period | 'all'>({
period,
onPeriod,
anchor,
onAnchor,
custom,
onCustom,
allLabel,
}: {
period: P;
onPeriod: (p: P) => void;
anchor: string;
onAnchor: (a: string) => void;
custom: { from: string; to: string };
onCustom: (c: { from: string; to: string }) => void;
allLabel?: string;
}) {
const label = period === 'all' ? null : periodRange(period as Period, anchor, custom).label;
return (
<>
<select className={`${inputClass} !w-auto`} value={period} onChange={(e) => onPeriod(e.target.value as P)}>
{allLabel && <option value="all">{allLabel}</option>}
<option value="week">Woche</option>
<option value="month">Monat</option>
<option value="quarter">Quartal</option>
<option value="year">Jahr</option>
<option value="custom">Zeitraum</option>
</select>
{period === 'all' ? null : period === 'custom' ? (
<div className="flex items-center gap-2">
<input type="date" className={`${inputClass} !w-auto`} value={custom.from} max={custom.to} onChange={(e) => e.target.value && onCustom({ ...custom, from: e.target.value })} />
<span className="text-ink-400">–</span>
<input type="date" className={`${inputClass} !w-auto`} value={custom.to} min={custom.from} onChange={(e) => e.target.value && onCustom({ ...custom, to: e.target.value })} />
</div>
) : (
<div className="flex items-center rounded-xl border border-ink-200 bg-surface">
<button onClick={() => onAnchor(shiftAnchor(period as Period, anchor, -1))} className="rounded-l-xl p-2.5 text-ink-500 hover:bg-ink-50" aria-label="Zurück">
<Icon name="chevronLeft" className="h-4 w-4" strokeWidth={2.2} />
</button>
<span className="tabular flex items-center gap-2 border-x border-ink-200 px-3 py-2 text-xs font-bold text-ink-700">
<Icon name="calendar" className="h-4 w-4 text-ink-400" />
{label}
</span>
<button onClick={() => onAnchor(shiftAnchor(period as Period, anchor, 1))} className="rounded-r-xl p-2.5 text-ink-500 hover:bg-ink-50" aria-label="Weiter">
<Icon name="chevronRight" className="h-4 w-4" strokeWidth={2.2} />
</button>
</div>
)}
</>
);
}
+3 -52
View File
@@ -1,38 +1,22 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Avatar, Card, ErrorBox, IconButton, Spinner, inputClass } from '../ui/controls';
import { Modal } from '../ui/Modal';
import { Icon } from '../ui/Icon';
import { CountUp, Donut, BILLABLE, NON_BILLABLE } from '../ui/charts';
import { ReportTable } from './ReportTable';
import { EntryList } from '../booking/EntryList';
import { ExportMenu } from './ExportMenu';
import { CustomerReport } from './CustomerReport';
import { ProjectReport } from './ProjectReport';
import { PeriodPicker, periodRange, type Period } from './PeriodPicker';
import { useAuth } from '../../lib/auth';
import { customersApi, entriesApi, qs, reportsApi, usersApi, type GroupRow } from '../../lib/api';
import { useLoader, usePersistentState } from '../../lib/hooks';
import { MONTHS, addDays, addMonths, formatDate, formatEuro, formatHours, isoWeek, mondayOf, monthEnd, monthStart, todayIso } from '../../lib/format';
import { formatEuro, formatHours, monthStart, todayIso } from '../../lib/format';
import { useRouter } from '../../lib/router';
import { atLeast, type Customer, type DirectoryUser } from '../../types';
type Period = 'week' | 'month' | 'quarter' | 'year' | 'custom';
type Tab = 'clients' | 'projects' | 'tasks' | 'users';
function periodRange(period: Period, anchor: string, custom: { from: string; to: string }) {
if (period === 'week') {
const from = mondayOf(anchor);
return { from, to: addDays(from, 6), label: `KW ${isoWeek(from)} · ${formatDate(from)} – ${formatDate(addDays(from, 6))}` };
}
if (period === 'month') return { from: monthStart(anchor), to: monthEnd(anchor), label: `${MONTHS[Number(anchor.slice(5, 7)) - 1]} ${anchor.slice(0, 4)}` };
if (period === 'quarter') {
const q = Math.floor((Number(anchor.slice(5, 7)) - 1) / 3);
const from = `${anchor.slice(0, 4)}-${String(q * 3 + 1).padStart(2, '0')}-01`;
return { from, to: monthEnd(addMonths(from, 2)), label: `Q${q + 1} ${anchor.slice(0, 4)}` };
}
if (period === 'year') return { from: `${anchor.slice(0, 4)}-01-01`, to: `${anchor.slice(0, 4)}-12-31`, label: anchor.slice(0, 4) };
return { ...custom, label: `${formatDate(custom.from)} – ${formatDate(custom.to)}` };
}
export const Reports: React.FC = () => {
const { route, navigate } = useRouter();
const [kind, id] = route.params;
@@ -105,13 +89,6 @@ const TimeReport: React.FC = () => {
[range.from, range.to, customerId, team, includeFixed, hideNonBillable]
);
const shift = (step: number) => {
if (period === 'week') setAnchor(addDays(anchor, 7 * step));
if (period === 'month') setAnchor(addMonths(anchor, step));
if (period === 'quarter') setAnchor(addMonths(anchor, 3 * step));
if (period === 'year') setAnchor(`${Number(anchor.slice(0, 4)) + step}-01-01`);
};
const exportParams = { from: range.from, to: range.to, customer_id: customerId || undefined, team: team || undefined, include_fixed: includeFixed ? 1 : undefined, hide_non_billable: hideNonBillable ? 1 : undefined };
const groupName = { clients: 'Kunden', projects: 'Projekte', tasks: 'Tasks', users: 'Mitarbeiter' }[tab];
const exportOptions = useMemo(
@@ -165,33 +142,7 @@ const TimeReport: React.FC = () => {
</optgroup>
)}
</select>
<select className={`${inputClass} !w-auto`} value={period} onChange={(e) => setPeriod(e.target.value as Period)}>
<option value="week">Woche</option>
<option value="month">Monat</option>
<option value="quarter">Quartal</option>
<option value="year">Jahr</option>
<option value="custom">Zeitraum</option>
</select>
{period === 'custom' ? (
<div className="flex items-center gap-2">
<input type="date" className={`${inputClass} !w-auto`} value={custom.from} onChange={(e) => setCustom({ ...custom, from: e.target.value })} />
<span className="text-ink-400">–</span>
<input type="date" className={`${inputClass} !w-auto`} value={custom.to} onChange={(e) => setCustom({ ...custom, to: e.target.value })} />
</div>
) : (
<div className="flex items-center rounded-xl border border-ink-200 bg-surface">
<button onClick={() => shift(-1)} className="rounded-l-xl p-2.5 text-ink-500 hover:bg-ink-50" aria-label="Zurück">
<Icon name="chevronLeft" className="h-4 w-4" strokeWidth={2.2} />
</button>
<span className="tabular flex items-center gap-2 border-x border-ink-200 px-3 py-2 text-xs font-bold text-ink-700">
<Icon name="calendar" className="h-4 w-4 text-ink-400" />
{range.label}
</span>
<button onClick={() => shift(1)} className="rounded-r-xl p-2.5 text-ink-500 hover:bg-ink-50" aria-label="Weiter">
<Icon name="chevronRight" className="h-4 w-4" strokeWidth={2.2} />
</button>
</div>
)}
<PeriodPicker period={period} onPeriod={setPeriod} anchor={anchor} onAnchor={setAnchor} custom={custom} onCustom={setCustom} />
</div>
{report.error && <ErrorBox message={report.error} onRetry={report.reload} />}
+33 -4
View File
@@ -9,7 +9,8 @@ import { useToast } from '../Toast';
* Abwesenheiten aus HRworks.
*
* Urlaub, Krankheit, Elternzeit und Feiertage stehen in HRworks; Pulse holt sie
* einmal taeglich und bucht sie auf ein nicht abrechenbares Projekt. Hier
* zweimal taeglich, bucht sie auf ein nicht abrechenbares Projekt und fuellt
* den Kalender (nur Urlaub und Feiertage). Hier
* stehen die Zugangsdaten, die Zuordnung der Arten auf Tasks und der Knopf fuer
* einen Probelauf.
*/
@@ -44,10 +45,12 @@ export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () =>
setReport(r);
addToast(
was === 'apply'
? `Übernommen: ${r.created} neu, ${r.updated ?? 0} geändert, ${r.removed ?? 0} entfernt.`
? `Übernommen: ${r.created} neu, ${r.updated ?? 0} geändert, ${r.removed ?? 0} entfernt.` +
(r.calendar ? ` Kalender: ${r.calendar.vacation_days} Urlaubstage.` : '')
: `Probelauf: ${r.planned_create} neu, ${r.planned_update} geändert, ${r.planned_delete} zu entfernen.`,
'success'
);
if (r.calendar_error) addToast(`Kalender: ${r.calendar_error}`, 'error');
hrworksApi.status().then(setStatus).catch(() => undefined);
}
} catch (err) {
@@ -82,7 +85,7 @@ export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () =>
<Icon name={settings.hrworks.enabled ? 'check' : 'info'} className="mt-0.5 h-5 w-5 shrink-0" />
<span>
{settings.hrworks.enabled
? 'Der Abgleich läuft täglich um 5:30 Uhr und holt die letzten Tage nach.'
? 'Der Abgleich läuft täglich um 5:30 und 12:00 Uhr: Buchungen bis heute, dazu der Kalender mit den genehmigten Urlauben der Zukunft.'
: settings.hrworks.configured
? 'Zugangsdaten hinterlegt, der Abgleich ist noch nicht eingeschaltet.'
: 'Noch keine Zugangsdaten. Schlüsselpaar in HRworks unter Grundlagen → Integrationen → HRworks-API erzeugen.'}
@@ -148,7 +151,8 @@ export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () =>
<p className="mb-3 text-xs leading-snug text-ink-400">
Links der Schlüssel aus HRworks (<code className="rounded bg-ink-100 px-1">absence:</code> oder{' '}
<code className="rounded bg-ink-100 px-1">sick:</code>), rechts der Taskname in Pulse. Fehlt ein Eintrag, sucht Pulse
einen Task mit dem HRworks-Namen der Art – findet es keinen, meldet der Probelauf ihn als fehlend.
einen Task mit dem HRworks-Namen der Art – findet es keinen, meldet der Probelauf ihn als fehlend. Der Haken{' '}
<b>Kalender</b> legt fest, welche Arten im Kalender stehen, den alle Mitarbeiter sehen – Krankmeldungen nie.
</p>
<div className="grid gap-2 sm:grid-cols-2">
{eintraege.map(([key, value]) => (
@@ -159,6 +163,25 @@ export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () =>
value={value}
onChange={(e) => set({ type_map: { ...form.type_map, [key]: e.target.value } })}
/>
{key.startsWith('absence:') ? (
<label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-[11px] font-bold text-ink-500" title="Im Kalender für alle zeigen">
<input
type="checkbox"
className="accent-[var(--color-brand-500)]"
checked={(form.calendar_types ?? []).includes(key)}
onChange={(e) =>
set({
calendar_types: e.target.checked
? [...(form.calendar_types ?? []), key]
: (form.calendar_types ?? []).filter((t) => t !== key),
})
}
/>
Kalender
</label>
) : (
<span className="w-[66px] shrink-0" />
)}
</div>
))}
</div>
@@ -193,6 +216,12 @@ export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () =>
: ''}
</p>
)}
{status?.calendar?.last_run && (
<p className="mt-1 text-xs text-ink-400">
Kalender: {new Date(status.calendar.last_run).toLocaleString('de-DE')} – {status.calendar.vacation_days} Urlaubstage,{' '}
{status.calendar.holidays} Feiertage.
</p>
)}
</Card>
</div>
);
+34 -2
View File
@@ -175,6 +175,8 @@ export interface HrworksSettings {
type_map: Record<string, string>;
holiday_task: string;
half_day_hours: number;
/** Abwesenheitsarten ("absence:<key>") im Kalender, den alle sehen. */
calendar_types: string[];
last_run: string | null;
last_result: { created: number; updated: number; removed: number; unchanged: number; hours: number; failed: number } | null;
}
@@ -190,6 +192,8 @@ export interface HrworksStatus {
type_map: Record<string, string>;
last_run: string | null;
last_result: HrworksSettings['last_result'];
calendar_types: string[];
calendar: { last_run: string | null; vacation_days: number; holidays: number };
entries: number;
hours: number;
first_date: string | null;
@@ -212,6 +216,8 @@ export interface HrworksReport {
unmatched_persons: { person_id: string; name: string; email: string | null }[];
missing_tasks: string[];
per_person: Record<string, Record<string, { hours: number; days: number }>>;
calendar?: { from: string; to: string; vacation_days: number; holidays: number };
calendar_error?: string;
}
export const hrworksApi = {
@@ -227,6 +233,30 @@ export const hrworksApi = {
removeAll: () => send<{ deleted: number }>('DELETE', '/hrworks/entries'),
};
export interface CalendarUser {
id: number;
name: string;
color: string | null;
manager_id: number | null;
is_external: boolean;
}
export interface CalendarDay {
user_id: number;
day: string;
kind: 'vacation' | 'holiday';
label: string;
half: boolean;
}
export interface CalendarData {
users: CalendarUser[];
days: CalendarDay[];
last_run: string | null;
}
export const calendarApi = {
get: (from: string, to: string) => get<CalendarData>(`/calendar${qs({ from, to })}`),
};
export interface AiModel {
id: string;
created: string | null;
@@ -459,6 +489,8 @@ export interface ProjectDetail {
export interface CustomerReport {
customer: Customer;
range: { from: string | null; to: string | null };
series_unit: 'day' | 'week' | 'month';
totals: {
minutes: number;
billable_minutes: number;
@@ -468,7 +500,7 @@ export interface CustomerReport {
open: number;
budget_value: number;
};
series: Array<{ month: string; minutes: number; billable_minutes: number; amount: number }>;
series: Array<{ key: string; start: string; minutes: number; billable_minutes: number; amount: number }>;
projects: Array<Omit<ProjectDetail, 'weekly'>>;
team: GroupRow[];
tasks: GroupRow[];
@@ -484,5 +516,5 @@ export const reportsApi = {
})}`
),
project: (id: number) => get<ProjectDetail>(`/reports/project/${id}`),
customer: (id: number) => get<CustomerReport>(`/reports/customer/${id}`),
customer: (id: number, range?: { from: string; to: string } | null) => get<CustomerReport>(`/reports/customer/${id}${qs({ from: range?.from, to: range?.to })}`),
};
+6 -5
View File
@@ -229,12 +229,13 @@ export interface PublicSettings {
project_name_pattern: string;
}
export type ViewKey = 'dashboard' | 'buchung' | 'projekte' | 'team' | 'kunden' | 'reports' | 'einstellungen';
export type ViewKey = 'dashboard' | 'buchung' | 'projekte' | 'kalender' | 'team' | 'kunden' | 'reports' | 'einstellungen';
export const VIEW_LABELS: Record<ViewKey, string> = {
dashboard: 'Dashboard',
buchung: 'Buchung',
projekte: 'Projekte',
kalender: 'Kalender',
team: 'Team',
kunden: 'Kunden',
reports: 'Reports',
@@ -246,15 +247,15 @@ export function viewsFor(role: Role): ViewKey[] {
switch (role) {
case 'user':
case 'external':
return ['buchung', 'projekte'];
return ['buchung', 'projekte', 'kalender'];
case 'lead':
return ['dashboard', 'buchung', 'projekte', 'team', 'kunden', 'reports'];
return ['dashboard', 'buchung', 'projekte', 'kalender', 'team', 'kunden', 'reports'];
case 'management':
// Einstellungen zeigen fuer Management nur die SSO-Verwaltung.
return ['dashboard', 'buchung', 'projekte', 'team', 'kunden', 'reports', 'einstellungen'];
return ['dashboard', 'buchung', 'projekte', 'kalender', 'team', 'kunden', 'reports', 'einstellungen'];
case 'admin':
// Administratoren sind keine Mitarbeiter und buchen keine Zeiten.
return ['dashboard', 'projekte', 'team', 'kunden', 'reports', 'einstellungen'];
return ['dashboard', 'projekte', 'kalender', 'team', 'kunden', 'reports', 'einstellungen'];
}
}