Compare commits
22
Commits
79bfd561bd
..
main
@@ -18,16 +18,23 @@ Erster Login: Benutzer `admin`, Passwort aus `ADMIN_PASSWORD` in `server/.env` (
|
|||||||
|
|
||||||
| Rolle | Startseite | Menü |
|
| 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 |
|
| 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“ |
|
| 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.
|
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.
|
||||||
|
|
||||||
Rechte werden im Menü **und** im Backend (403) geprüft. Jeder bucht nur auf Projekte, denen er zugewiesen ist.
|
Rechte werden im Menü **und** im Backend (403) geprüft. Jeder bucht nur auf Projekte, denen er zugewiesen ist.
|
||||||
|
|
||||||
|
**Sitzungsdauer**: Das Token gilt **7 Tage ab der Anmeldung** (`JWT_EXPIRES_IN` in `server/.env`, Vorgabe im Code
|
||||||
|
ebenfalls `7d`). Es verlängert sich nicht bei Aktivität und es gibt keinen Leerlauf-Timer; abgelaufen meldet der
|
||||||
|
Server `401 „Sitzung abgelaufen“`, der Browser verwirft das Token und zeigt den Anmeldebildschirm. Das Token liegt im
|
||||||
|
`localStorage`, ein Neuladen beendet die Sitzung also nicht. Deaktivierte Konten und geänderte Rollen greifen trotzdem
|
||||||
|
sofort – `requireAuth` lädt den Benutzer bei jeder Anfrage frisch aus der Datenbank. Eine geänderte Frist gilt erst für
|
||||||
|
Tokens, die danach ausgestellt werden.
|
||||||
|
|
||||||
## Fachregeln
|
## Fachregeln
|
||||||
|
|
||||||
- **Zeiteingabe**: `1:23` = 1 h 23 min, `1,5` / `1.5` = 1 h 30 min, außerdem `2`, `:45`, `1h 30m`.
|
- **Zeiteingabe**: `1:23` = 1 h 23 min, `1,5` / `1.5` = 1 h 30 min, außerdem `2`, `:45`, `1h 30m`.
|
||||||
@@ -81,6 +88,25 @@ Ein Klick auf eine Zeile öffnet dieselbe Detailansicht wie eine Kachel, hier un
|
|||||||
mindestens Vorgesetzter ist.
|
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
|
## Auslastung
|
||||||
|
|
||||||
Die Auslastung vergleicht **gebuchte Stunden bis heute** mit der **Kapazität bis heute**
|
Die Auslastung vergleicht **gebuchte Stunden bis heute** mit der **Kapazität bis heute**
|
||||||
@@ -133,7 +159,7 @@ Team, Dashboard, Reports, Projekt- und Kundenansicht. Ohne Bild bleiben die Init
|
|||||||
|
|
||||||
## Abwesenheiten aus HRworks
|
## 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").
|
und bucht sie auf das nicht abrechenbare Projekt `PERINCO-2024-001` („Abwesenheiten").
|
||||||
Geschrieben wird nur in Pulse – nach HRworks geht nichts zurück.
|
Geschrieben wird nur in Pulse – nach HRworks geht nichts zurück.
|
||||||
|
|
||||||
@@ -162,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,
|
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.
|
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
|
**Zweimal 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
|
`perinco-pulse-hrworks.timer` (5:30 und 12:00 Uhr Berliner Zeit, `Persistent=true` holt verpasste Läufe
|
||||||
nach). Ausgang im Journal: `journalctl -u perinco-pulse-hrworks`. Die Unit-Dateien liegen in
|
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
|
`deploy/`; sie werden nach `/etc/systemd/system/` kopiert und einmal mit
|
||||||
`systemctl enable --now perinco-pulse-hrworks.timer` eingeschaltet.
|
`systemctl enable --now perinco-pulse-hrworks.timer` eingeschaltet.
|
||||||
|
|
||||||
@@ -173,6 +199,45 @@ 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
|
Billable und Festpreis Sinn. Am Abwesenheitsprojekt wäre er nur ein Ablaufdatum, das irgendwann
|
||||||
Buchungen blockiert; es hat deshalb keinen.
|
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
|
||||||
|
(AES-verschlüsselt gespeichert, nie wieder ausgeliefert), Modell wählen, einschalten. Der Knopf
|
||||||
|
„Fragen" im Kopf erscheint dann für Management und Administration.
|
||||||
|
|
||||||
|
**Modell** wird aus einer Liste gewählt, nicht mehr eingetippt. Die Liste kommt live von OpenAI
|
||||||
|
(`POST /api/settings/ai/models` → `GET https://api.openai.com/v1/models`), also genau die Modelle,
|
||||||
|
die der Schlüssel darf – neueste zuerst, mit Veröffentlichungsdatum. Embedding-, Bild-, Audio-,
|
||||||
|
Realtime-, Such- und Moderationsmodelle filtert `listModels` in `server/src/lib/ai.js` heraus; sie
|
||||||
|
können keine Textantwort mit Werkzeugen. Ist ein Schlüssel hinterlegt, lädt die Seite die Liste
|
||||||
|
beim Öffnen; *Liste laden* holt sie neu – mit einem gerade eingetippten, noch nicht gespeicherten
|
||||||
|
Schlüssel, falls vorhanden (deshalb POST: der Schlüssel gehört nicht in eine URL). Ein eingestelltes
|
||||||
|
Modell, das OpenAI nicht mehr listet, bleibt in der Auswahl stehen, damit Speichern es nicht
|
||||||
|
stillschweigend ersetzt. Voreingestellt ist `gpt-5.6-luna`; *Verbindung testen* prüft Schlüssel und
|
||||||
|
Modell mit einer echten Anfrage.
|
||||||
|
|
||||||
## SSO / Active Directory
|
## SSO / Active Directory
|
||||||
|
|
||||||
**Einrichtung** (nur Administrator, *Einstellungen → SSO / Entra ID*): Microsoft Entra ID per OpenID Connect (Authorization Code, Client Secret AES-verschlüsselt in der DB). App-Registrierung mit Redirect-URI `https://pulse.perinco.de/api/auth/sso/callback`, delegiert `openid profile email`, Anwendung `User.Read.All` mit Administratorzustimmung.
|
**Einrichtung** (nur Administrator, *Einstellungen → SSO / Entra ID*): Microsoft Entra ID per OpenID Connect (Authorization Code, Client Secret AES-verschlüsselt in der DB). App-Registrierung mit Redirect-URI `https://pulse.perinco.de/api/auth/sso/callback`, delegiert `openid profile email`, Anwendung `User.Read.All` mit Administratorzustimmung.
|
||||||
@@ -188,9 +253,12 @@ Buchungen blockiert; es hat deshalb keinen.
|
|||||||
|
|
||||||
## Themes
|
## Themes
|
||||||
|
|
||||||
Sieben Farbwelten, jede in hell und dunkel, gespeichert am Benutzer
|
Acht Farbwelten, jede in hell und dunkel, gespeichert am Benutzer
|
||||||
(`pulse_users.theme`, `theme_mode`) und vor dem ersten Bild gesetzt (Inline-Skript
|
(`pulse_users.theme`, `theme_mode`) und vor dem ersten Bild gesetzt (Inline-Skript
|
||||||
in `index.html`). Sie kommen 1:1 aus dem perinco CRM; vier bringen Bewegung mit:
|
in `index.html`). Sieben kommen 1:1 aus dem perinco CRM. Das achte, **Mobile**, gibt es nur
|
||||||
|
in Pulse: perinco-Farben, aber fürs Handy rund 16 % dichter (kleineres `--spacing`,
|
||||||
|
kleinere Überschriften, niedrigere Kopfleiste), dunkel mit echtem Schwarz für OLED.
|
||||||
|
Vier Themes bringen Bewegung mit:
|
||||||
|
|
||||||
| Theme | Bewegung | Wo |
|
| Theme | Bewegung | Wo |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -240,6 +308,10 @@ powershell -File deploy/deploy.ps1 -Deps # zusätzlich npm ci im Backend
|
|||||||
|
|
||||||
Die Produktions-`.env` liegt nur auf dem Server (`/var/www/pulse/server/.env`, 600). `SETTINGS_KEY` nicht ändern, sonst ist das gespeicherte SSO-Secret unlesbar.
|
Die Produktions-`.env` liegt nur auf dem Server (`/var/www/pulse/server/.env`, 600). `SETTINGS_KEY` nicht ändern, sonst ist das gespeicherte SSO-Secret unlesbar.
|
||||||
|
|
||||||
|
Werte, die nur dort stehen und ein `deploy.ps1` überdauern: `JWT_SECRET`, `JWT_EXPIRES_IN` (Sitzungsdauer, aktuell
|
||||||
|
`7d`), `SETTINGS_KEY`, `ADMIN_PASSWORD`, Datenbankzugang. Nach einer Änderung an der `.env` den Dienst neu starten
|
||||||
|
(`systemctl restart perinco-pulse-backend`) – das Deployment allein liest sie nicht neu ein.
|
||||||
|
|
||||||
## Anforderungs-Checkliste
|
## Anforderungs-Checkliste
|
||||||
|
|
||||||
Aus Auftrag und Call-Mitschrift.
|
Aus Auftrag und Call-Mitschrift.
|
||||||
@@ -255,7 +327,8 @@ Aus Auftrag und Call-Mitschrift.
|
|||||||
| Klick auf einen Mitarbeiter im Zeitreport zeigt dessen Buchungen des Zeitraums als Liste | ✅ | Reports → Zeitreport → Mitarbeiter |
|
| Klick auf einen Mitarbeiter im Zeitreport zeigt dessen Buchungen des Zeitraums als Liste | ✅ | Reports → Zeitreport → Mitarbeiter |
|
||||||
| Kunden: zusätzliche Listenansicht | ✅ | Kunden → Karten/Liste |
|
| Kunden: zusätzliche Listenansicht | ✅ | Kunden → Karten/Liste |
|
||||||
| Eigenes Profilbild hochladen | ✅ | Mein Konto, Team → Bearbeiten |
|
| 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 |
|
| Bestellnummer je Budgettopf, erscheint im Stundennachweis | ✅ | Projekt → Budgettopf |
|
||||||
| Rolle „Externe" mit den Rechten eines Mitarbeiters | ✅ | Team → Bearbeiten |
|
| Rolle „Externe" mit den Rechten eines Mitarbeiters | ✅ | Team → Bearbeiten |
|
||||||
| Export: Kunde und Projekt nur aus den eigenen Zuweisungen | ✅ | Buchung → Export |
|
| Export: Kunde und Projekt nur aus den eigenen Zuweisungen | ✅ | Buchung → Export |
|
||||||
@@ -281,10 +354,11 @@ Aus Auftrag und Call-Mitschrift.
|
|||||||
| Kunden anlegen/bearbeiten, wer hat welche Projekte mit welchen Mitarbeitern, Umsatz erbracht/offen, animiert | ✅ | Kunden |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Einstellungen (nur Admin): SSO-Credentials, Userverwaltung, Kopf/Fuß der Exporte | ✅ inkl. PDF-/Excel-Vorschau | Einstellungen |
|
||||||
| Arbeitsstunden pro Woche (40) | ✅ | Einstellungen → Allgemein |
|
| Arbeitsstunden pro Woche (40) | ✅ | Einstellungen → Allgemein |
|
||||||
| Projektbezeichnungen nach Muster | ✅ | Einstellungen → Allgemein |
|
| Projektbezeichnungen nach Muster | ✅ | Einstellungen → Allgemein |
|
||||||
| Themes wie CRM (perinco, Pixel Art, Outdoor, Cyberpunk, Rainy, Snow, Aura – jeweils hell/dunkel, am Benutzer gespeichert) | ✅ | Kopfleiste |
|
| Themes wie CRM (perinco, Pixel Art, Outdoor, Cyberpunk, Rainy, Snow, Aura, Mobile – jeweils hell/dunkel, am Benutzer gespeichert) | ✅ | Kopfleiste |
|
||||||
| Lokaler Admin mit vorgegebenem Passwort, später änderbar | ✅ | Mein Konto |
|
| Lokaler Admin mit vorgegebenem Passwort, später änderbar | ✅ | Mein Konto |
|
||||||
| Mobil: eigene Ansicht bei Handy | ✅ untere Navigation, Tagesstreifen, Bottom-Sheets, schwebender Plus-Knopf | |
|
| Mobil: eigene Ansicht bei Handy | ✅ untere Navigation, Tagesstreifen, Bottom-Sheets, schwebender Plus-Knopf | |
|
||||||
| SSO, anfangs normaler Login; AD-User auslesen, Rollen intern | ✅ vorbereitet, noch nicht gegen echten Tenant getestet | Einstellungen → SSO |
|
| SSO, anfangs normaler Login; AD-User auslesen, Rollen intern | ✅ vorbereitet, noch nicht gegen echten Tenant getestet | Einstellungen → SSO |
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Taeglicher Abgleich der Abwesenheiten aus HRworks
|
Description=Abgleich der Abwesenheiten aus HRworks (5:30 und 12:00 Uhr)
|
||||||
|
|
||||||
[Timer]
|
[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).
|
# verteilt die Last, Persistent holt einen verpassten Lauf nach (Neustart).
|
||||||
# Ortszeit im Kalenderausdruck - der Server laeuft auf UTC, der Lauf soll
|
# 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=*-*-* 05:30:00 Europe/Berlin
|
||||||
|
OnCalendar=*-*-* 12:00:00 Europe/Berlin
|
||||||
RandomizedDelaySec=300
|
RandomizedDelaySec=300
|
||||||
Persistent=true
|
Persistent=true
|
||||||
Unit=perinco-pulse-hrworks.service
|
Unit=perinco-pulse-hrworks.service
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@
|
|||||||
|
|
||||||
var palette = localStorage.getItem('pulse_palette');
|
var palette = localStorage.getItem('pulse_palette');
|
||||||
// Whitelist, damit ein manipulierter Eintrag keine Klasse setzt.
|
// Whitelist, damit ein manipulierter Eintrag keine Klasse setzt.
|
||||||
if (['pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow', 'aura'].indexOf(palette) !== -1) {
|
if (['pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow', 'aura', 'mobile'].indexOf(palette) !== -1) {
|
||||||
document.documentElement.classList.add('theme-' + palette);
|
document.documentElement.classList.add('theme-' + palette);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ DB_NAME=perinco_pulse
|
|||||||
# Signiert die Sitzungen. Langer Zufallswert:
|
# Signiert die Sitzungen. Langer Zufallswert:
|
||||||
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
# node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
|
||||||
JWT_SECRET=changeme-langer-zufallswert
|
JWT_SECRET=changeme-langer-zufallswert
|
||||||
JWT_EXPIRES_IN=12h
|
JWT_EXPIRES_IN=7d
|
||||||
|
|
||||||
# Verschluesselt das SSO Client Secret in der Datenbank. Nie aendern, sonst ist
|
# Verschluesselt das SSO Client Secret in der Datenbank. Nie aendern, sonst ist
|
||||||
# das gespeicherte Secret nicht mehr lesbar (dann in den Einstellungen neu setzen).
|
# das gespeicherte Secret nicht mehr lesbar (dann in den Einstellungen neu setzen).
|
||||||
|
|||||||
@@ -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:
|
* Laeuft als eigener Prozess ueber einen systemd-Timer, nicht im Webdienst:
|
||||||
* ein haengender Fremdaufruf soll die Anwendung nicht blockieren, und der
|
* ein haengender Fremdaufruf soll die Anwendung nicht blockieren, und der
|
||||||
@@ -8,13 +9,21 @@
|
|||||||
* Aufruf:
|
* Aufruf:
|
||||||
* node src/jobs/hrworks-sync.js echter Lauf
|
* node src/jobs/hrworks-sync.js echter Lauf
|
||||||
* node src/jobs/hrworks-sync.js --dry-run nur berichten
|
* 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 'dotenv/config';
|
||||||
import { initDatabase, pool } from '../db.js';
|
import { initDatabase, pool } from '../db.js';
|
||||||
import { getSettings } from '../lib/settings.js';
|
import { getSettings } from '../lib/settings.js';
|
||||||
import { syncHrworks } from '../lib/hrworksSync.js';
|
import { syncHrworks } from '../lib/hrworksSync.js';
|
||||||
|
import { syncCalendar } from '../lib/calendarSync.js';
|
||||||
|
|
||||||
const dryRun = process.argv.includes('--dry-run');
|
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 {
|
try {
|
||||||
await initDatabase();
|
await initDatabase();
|
||||||
@@ -23,6 +32,33 @@ try {
|
|||||||
console.log('[Pulse/HRworks] Abgleich ist in den Einstellungen abgeschaltet - nichts zu tun.');
|
console.log('[Pulse/HRworks] Abgleich ist in den Einstellungen abgeschaltet - nichts zu tun.');
|
||||||
process.exit(0);
|
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 });
|
const report = await syncHrworks({ dryRun });
|
||||||
console.log(
|
console.log(
|
||||||
`[Pulse/HRworks]${dryRun ? ' (Probelauf)' : ''} ${report.from} bis ${report.to}: ` +
|
`[Pulse/HRworks]${dryRun ? ' (Probelauf)' : ''} ${report.from} bis ${report.to}: ` +
|
||||||
@@ -39,10 +75,4 @@ try {
|
|||||||
for (const problem of report.failed) {
|
for (const problem of report.failed) {
|
||||||
console.warn(`[Pulse/HRworks] ${problem.ref}: ${problem.error}`);
|
console.warn(`[Pulse/HRworks] ${problem.ref}: ${problem.error}`);
|
||||||
}
|
}
|
||||||
process.exit(0);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Pulse/HRworks] Abgleich fehlgeschlagen:', err.message);
|
|
||||||
process.exit(1);
|
|
||||||
} finally {
|
|
||||||
await pool.end().catch(() => undefined);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
/*
|
||||||
|
* Anbindung an OpenAI fuer den Assistenten im Kopf der Anwendung.
|
||||||
|
*
|
||||||
|
* Bewusst ohne SDK: gebraucht wird ein einziger Endpunkt, und `fetch` ist in
|
||||||
|
* Node eingebaut. Der Schluessel liegt verschluesselt in den Einstellungen und
|
||||||
|
* bleibt auf dem Server - der Browser spricht nie mit OpenAI.
|
||||||
|
*
|
||||||
|
* Benutzt wird /v1/responses, nicht /v1/chat/completions: die neueren Modelle
|
||||||
|
* (gpt-5.6-luna und Geschwister) nehmen Werkzeuge dort nur an, wenn man das
|
||||||
|
* Nachdenken abschaltet. Ueber die Responses-API geht beides zusammen.
|
||||||
|
*
|
||||||
|
* `store: false` - die Frage und die Daten aus Pulse bleiben nicht bei OpenAI
|
||||||
|
* liegen. Der Verlauf eines Gespraechs wird deshalb bei jeder Runde komplett
|
||||||
|
* mitgeschickt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ENDPOINT = 'https://api.openai.com/v1/responses';
|
||||||
|
const MODELS_ENDPOINT = 'https://api.openai.com/v1/models';
|
||||||
|
const TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
|
export const AI_DEFAULT_MODEL = 'gpt-5.6-luna';
|
||||||
|
|
||||||
|
export class AiError extends Error {
|
||||||
|
constructor(message, status = 502) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aiConfigured(ai) {
|
||||||
|
return Boolean(ai?.api_key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Aufruf. `input` ist der ganze bisherige Verlauf inklusive Werkzeugergebnisse. */
|
||||||
|
export async function askModel(ai, { instructions, input, tools, maxOutputTokens = 2000, effort = 'low' }) {
|
||||||
|
if (!aiConfigured(ai)) throw new AiError('Es ist kein OpenAI-Schlüssel hinterlegt (Einstellungen → KI-Assistent).', 503);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
model: ai.model || AI_DEFAULT_MODEL,
|
||||||
|
instructions,
|
||||||
|
input,
|
||||||
|
max_output_tokens: maxOutputTokens,
|
||||||
|
store: false,
|
||||||
|
};
|
||||||
|
if (tools?.length) body.tools = tools;
|
||||||
|
// Nicht jedes Modell kennt die Stufe; ohne Angabe waehlt OpenAI selbst.
|
||||||
|
if (effort) body.reasoning = { effort };
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(ENDPOINT, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${ai.api_key}` },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (err.name === 'AbortError') throw new AiError('Das Modell hat nicht rechtzeitig geantwortet.', 504);
|
||||||
|
throw new AiError(`OpenAI ist nicht erreichbar: ${err.message}`);
|
||||||
|
}
|
||||||
|
clearTimeout(timer);
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = data?.error?.message || `HTTP ${response.status}`;
|
||||||
|
if (response.status === 401) throw new AiError('OpenAI hat den Schlüssel abgelehnt.', 502);
|
||||||
|
if (response.status === 429) throw new AiError('OpenAI drosselt gerade oder das Guthaben ist aufgebraucht.', 502);
|
||||||
|
if (response.status === 404) throw new AiError(`Das Modell „${body.model}“ steht für diesen Schlüssel nicht bereit.`, 502);
|
||||||
|
throw new AiError(`OpenAI: ${message}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Modelle, die fuer den Assistenten in Frage kommen. /v1/models liefert alles,
|
||||||
|
* was der Schluessel darf - auch Embeddings, Bild-, Audio- und Moderationsmodelle.
|
||||||
|
* Die koennen keine Textantwort mit Werkzeugen und fliegen raus.
|
||||||
|
*/
|
||||||
|
const CHAT_MODEL = /^(gpt-|o\d|chatgpt-)/;
|
||||||
|
const NOT_CHAT = /(embedding|tts|whisper|dall-e|image|audio|realtime|transcribe|moderation|search|instruct|codex)/;
|
||||||
|
|
||||||
|
/** Liste der Modelle fuer die Auswahl in den Einstellungen, neueste zuerst. */
|
||||||
|
export async function listModels(apiKey) {
|
||||||
|
if (!apiKey) throw new AiError('Es ist kein OpenAI-Schlüssel hinterlegt.', 400);
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 20_000);
|
||||||
|
let response;
|
||||||
|
try {
|
||||||
|
response = await fetch(MODELS_ENDPOINT, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'AbortError') throw new AiError('OpenAI hat nicht rechtzeitig geantwortet.', 504);
|
||||||
|
throw new AiError(`OpenAI ist nicht erreichbar: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (response.status === 401) throw new AiError('OpenAI hat den Schlüssel abgelehnt.', 502);
|
||||||
|
if (!response.ok) throw new AiError(`OpenAI: ${data?.error?.message || `HTTP ${response.status}`}`);
|
||||||
|
|
||||||
|
return (data.data || [])
|
||||||
|
.filter((m) => CHAT_MODEL.test(m.id) && !NOT_CHAT.test(m.id))
|
||||||
|
.sort((a, b) => (b.created || 0) - (a.created || 0) || a.id.localeCompare(b.id))
|
||||||
|
.map((m) => ({ id: m.id, created: m.created ? new Date(m.created * 1000).toISOString().slice(0, 10) : null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text aller Nachrichten einer Antwort. */
|
||||||
|
export function answerText(response) {
|
||||||
|
return (response.output || [])
|
||||||
|
.filter((item) => item.type === 'message')
|
||||||
|
.flatMap((item) => item.content || [])
|
||||||
|
.map((part) => part.text || '')
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Werkzeugaufrufe einer Antwort. */
|
||||||
|
export function toolCalls(response) {
|
||||||
|
return (response.output || []).filter((item) => item.type === 'function_call');
|
||||||
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import { pool } from '../db.js';
|
||||||
|
import { getSettings } from './settings.js';
|
||||||
|
import { loadEntries } from './entries.js';
|
||||||
|
import { loadProjects } from './projects.js';
|
||||||
|
import { visibleUserIds } from './access.js';
|
||||||
|
import { today, isoWeek } from './dates.js';
|
||||||
|
import { AiError, askModel, answerText, toolCalls } from './ai.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Der Assistent: eine Frage in Worten, eine Antwort aus den Daten dieser
|
||||||
|
* Anwendung.
|
||||||
|
*
|
||||||
|
* Anders als die KI-Suche im CRM bekommt das Modell hier NICHT den ganzen
|
||||||
|
* Bestand - 19.000 Buchungen passen in keinen Kontext. Es bekommt eine
|
||||||
|
* Uebersicht (Kunden, laufende Projekte, Personen) und vier Werkzeuge, mit
|
||||||
|
* denen es gezielt nachfragen kann. Die Werkzeuge rechnen mit denselben
|
||||||
|
* Funktionen wie die Reports, damit der Assistent keine zweite Wahrheit
|
||||||
|
* erzeugt.
|
||||||
|
*
|
||||||
|
* Was das Modell sehen darf, richtet sich nach der Rolle des Fragenden: die
|
||||||
|
* Werkzeuge schraenken auf `visibleUserIds` ein, genau wie die Auswertungen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_RUNDEN = 6;
|
||||||
|
const MAX_ZEILEN = 200;
|
||||||
|
|
||||||
|
export const TOOLS = [
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
name: 'auswertung',
|
||||||
|
description:
|
||||||
|
'Gebuchte Zeiten in einem Zeitraum, gruppiert. Liefert Stunden, abrechenbare Stunden und Betrag je Gruppe. ' +
|
||||||
|
'Das ist das Werkzeug für alle Fragen nach "wie viele Stunden", "wie viel Umsatz", "wer hat wie viel gebucht".',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
von: { type: 'string', description: 'Erster Tag, YYYY-MM-DD.' },
|
||||||
|
bis: { type: 'string', description: 'Letzter Tag, YYYY-MM-DD.' },
|
||||||
|
gruppieren: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['kunde', 'projekt', 'task', 'person', 'monat', 'woche', 'gesamt'],
|
||||||
|
description: 'Wonach gruppiert wird. "gesamt" liefert nur die Summe.',
|
||||||
|
},
|
||||||
|
kunde: { type: ['string', 'null'], description: 'Auf einen Kunden einschränken (Name oder Kürzel).' },
|
||||||
|
projekt: { type: ['string', 'null'], description: 'Auf ein Projekt einschränken (Code oder Name).' },
|
||||||
|
person: { type: ['string', 'null'], description: 'Auf eine Person einschränken (Name).' },
|
||||||
|
nur_abrechenbar: { type: ['boolean', 'null'], description: 'Nur abrechenbare Buchungen zählen.' },
|
||||||
|
ohne_interne_projekte: { type: ['boolean', 'null'], description: 'Interne Projekte und Abwesenheiten weglassen.' },
|
||||||
|
},
|
||||||
|
required: ['von', 'bis', 'gruppieren'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
name: 'projekte',
|
||||||
|
description:
|
||||||
|
'Projekte mit Budget, Verbrauch, Restbudget, Laufzeit und Budgettöpfen. Für Fragen nach Budgets, Restbudget, ' +
|
||||||
|
'Auftragslage, Projektstatus und danach, welche Projekte es zu einem Kunden gibt.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
suche: { type: ['string', 'null'], description: 'Text in Projektname oder Code.' },
|
||||||
|
kunde: { type: ['string', 'null'], description: 'Nur Projekte dieses Kunden.' },
|
||||||
|
status: { type: ['string', 'null'], enum: ['aktiv', 'archiviert', 'alle', null], description: 'Standard: aktiv.' },
|
||||||
|
nur_mit_budget: { type: ['boolean', 'null'], description: 'Nur Projekte mit Budgettopf.' },
|
||||||
|
limit: { type: ['integer', 'null'], description: 'Höchstens so viele Projekte (Standard 40).' },
|
||||||
|
},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
name: 'buchungen',
|
||||||
|
description:
|
||||||
|
'Einzelne Zeitbuchungen mit Datum, Person, Projekt, Task und Notiz. Für Fragen nach konkreten Tätigkeiten ' +
|
||||||
|
'("woran hat X am Montag gearbeitet"). Höchstens 100 Zeilen - für Summen die auswertung nehmen.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
von: { type: 'string', description: 'Erster Tag, YYYY-MM-DD.' },
|
||||||
|
bis: { type: 'string', description: 'Letzter Tag, YYYY-MM-DD.' },
|
||||||
|
person: { type: ['string', 'null'] },
|
||||||
|
projekt: { type: ['string', 'null'] },
|
||||||
|
kunde: { type: ['string', 'null'] },
|
||||||
|
suche: { type: ['string', 'null'], description: 'Text in der Notiz oder im Tasknamen.' },
|
||||||
|
limit: { type: ['integer', 'null'], description: 'Standard 50, höchstens 100.' },
|
||||||
|
},
|
||||||
|
required: ['von', 'bis'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
name: 'personen',
|
||||||
|
description:
|
||||||
|
'Mitarbeiter mit Rolle, Wochenstunden und - wenn ein Zeitraum angegeben ist - gebuchten Stunden, ' +
|
||||||
|
'abrechenbarem Anteil und Auslastung. Für Fragen nach Auslastung und danach, wer woran arbeitet.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
von: { type: ['string', 'null'], description: 'Zeitraum für Stunden und Auslastung, YYYY-MM-DD.' },
|
||||||
|
bis: { type: ['string', 'null'] },
|
||||||
|
nur_aktive: { type: ['boolean', 'null'], description: 'Standard: nur aktive Mitarbeiter.' },
|
||||||
|
},
|
||||||
|
required: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const ANWEISUNG = `Du bist der Assistent von Pulse, der Zeiterfassung der perinco GmbH. Du beantwortest Fragen zu Kunden, Projekten, Budgets, Buchungen und Auslastung.
|
||||||
|
|
||||||
|
Arbeitsweise:
|
||||||
|
- Hol dir die Zahlen IMMER über die Werkzeuge. Rechne nichts aus dem Gedächtnis und schätze nie.
|
||||||
|
- Die Übersicht am Anfang nennt nur laufende Projekte und Kunden. Alles Weitere steht in den Werkzeugen, auch archivierte Projekte.
|
||||||
|
- Stimmt ein Name nicht genau, melden die Werkzeuge Vorschläge zurück - nimm den passenden und frag erst nach, wenn es wirklich mehrdeutig ist.
|
||||||
|
- Mehrere Werkzeugaufrufe sind in Ordnung. Für Summen nimm "auswertung", nicht "buchungen".
|
||||||
|
|
||||||
|
Antwort:
|
||||||
|
- Deutsch, knapp, sachlich. Keine Anrede, keine Einleitung, kein Markdown und keine Sternchen.
|
||||||
|
- Nenne die Zahlen mit Einheit und deutschem Format (1.234,50 h / 12.345,67 €) und dazu den Zeitraum, auf den sie sich beziehen.
|
||||||
|
- Bei Listen höchstens zehn Zeilen, die größten zuerst, und sag dazu, wenn mehr existieren.
|
||||||
|
- Steht etwas nicht in den Daten, sag das klar. Erfinde nichts.
|
||||||
|
|
||||||
|
Fachliches:
|
||||||
|
- "Billable"/abrechenbar heißt: Projekt und Task sind abrechenbar. Interne Projekte und Abwesenheiten (Urlaub, Krankheit, Feiertag) sind nicht abrechenbar.
|
||||||
|
- Der Betrag ist Stunden mal Satz. Der Satz kommt vom Task, sonst vom Budgettopf, bei Abruf vom Projekt.
|
||||||
|
- Projekttypen: "billable" rechnet nach Aufwand gegen einen Budgettopf, "abruf" nach Aufwand ohne Deckel, "fixed" ist ein Festpreis, "non_billable" ist intern.
|
||||||
|
- Auslastung = gebuchte Stunden bis heute geteilt durch die Kapazität bis heute.`;
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- Werkzeuge -- */
|
||||||
|
|
||||||
|
const zahl = (minuten) => Math.round((minuten / 60) * 100) / 100;
|
||||||
|
const geld = (betrag) => Math.round(betrag * 100) / 100;
|
||||||
|
|
||||||
|
function passt(text, suche) {
|
||||||
|
return String(text || '').toLowerCase().includes(String(suche || '').toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Findet Kunde, Projekt oder Person zu einer Eingabe des Modells. */
|
||||||
|
async function finde(art, eingabe) {
|
||||||
|
const suche = String(eingabe).trim();
|
||||||
|
if (art === 'kunde') {
|
||||||
|
const [rows] = await pool.query('SELECT id, name, short_name FROM pulse_customers ORDER BY name');
|
||||||
|
const treffer = rows.filter((r) => passt(r.name, suche) || passt(r.short_name, suche));
|
||||||
|
return { treffer, vorschlaege: rows.map((r) => r.name) };
|
||||||
|
}
|
||||||
|
if (art === 'projekt') {
|
||||||
|
const [rows] = await pool.query('SELECT id, code, name FROM pulse_projects ORDER BY code');
|
||||||
|
const treffer = rows.filter((r) => passt(r.code, suche) || passt(r.name, suche));
|
||||||
|
return { treffer, vorschlaege: rows.slice(0, 40).map((r) => `${r.code} ${r.name}`) };
|
||||||
|
}
|
||||||
|
const [rows] = await pool.query("SELECT id, name FROM pulse_users WHERE role <> 'admin' ORDER BY name");
|
||||||
|
const treffer = rows.filter((r) => passt(r.name, suche));
|
||||||
|
return { treffer, vorschlaege: rows.map((r) => r.name) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function werkzeugAuswertung(args, ctx) {
|
||||||
|
const filter = {};
|
||||||
|
if (args.kunde) {
|
||||||
|
const { treffer, vorschlaege } = await finde('kunde', args.kunde);
|
||||||
|
if (treffer.length !== 1) return { fehler: `Kunde „${args.kunde}“ ist nicht eindeutig.`, moeglich: treffer.length ? treffer.map((t) => t.name) : vorschlaege };
|
||||||
|
filter.customerId = treffer[0].id;
|
||||||
|
}
|
||||||
|
if (args.projekt) {
|
||||||
|
const { treffer, vorschlaege } = await finde('projekt', args.projekt);
|
||||||
|
if (treffer.length !== 1) return { fehler: `Projekt „${args.projekt}“ ist nicht eindeutig.`, moeglich: treffer.length ? treffer.map((t) => `${t.code} ${t.name}`) : vorschlaege };
|
||||||
|
filter.projectId = treffer[0].id;
|
||||||
|
}
|
||||||
|
let userIds = ctx.userIds;
|
||||||
|
if (args.person) {
|
||||||
|
const { treffer, vorschlaege } = await finde('person', args.person);
|
||||||
|
if (treffer.length !== 1) return { fehler: `Person „${args.person}“ ist nicht eindeutig.`, moeglich: treffer.length ? treffer.map((t) => t.name) : vorschlaege };
|
||||||
|
const id = treffer[0].id;
|
||||||
|
if (ctx.userIds !== null && !ctx.userIds.includes(id)) return { fehler: 'Diese Person liegt außerhalb deiner Sicht.' };
|
||||||
|
userIds = [id];
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = await loadEntries(ctx.settings, {
|
||||||
|
from: args.von,
|
||||||
|
to: args.bis,
|
||||||
|
userIds,
|
||||||
|
...filter,
|
||||||
|
skipNonBillableProjects: Boolean(args.ohne_interne_projekte),
|
||||||
|
});
|
||||||
|
if (args.nur_abrechenbar) entries = entries.filter((e) => e.billable);
|
||||||
|
|
||||||
|
const schluessel = {
|
||||||
|
kunde: (e) => e.customer_name,
|
||||||
|
projekt: (e) => `${e.project_code} ${e.project_display}`,
|
||||||
|
task: (e) => e.task_name || '(ohne Task)',
|
||||||
|
person: (e) => e.user_name,
|
||||||
|
monat: (e) => String(e.entry_date).slice(0, 7),
|
||||||
|
woche: (e) => `KW ${isoWeek(String(e.entry_date)).week}/${String(e.entry_date).slice(0, 4)}`,
|
||||||
|
gesamt: () => 'Gesamt',
|
||||||
|
}[args.gruppieren];
|
||||||
|
|
||||||
|
const gruppen = new Map();
|
||||||
|
for (const e of entries) {
|
||||||
|
const k = schluessel(e);
|
||||||
|
const g = gruppen.get(k) || { gruppe: k, stunden: 0, abrechenbare_stunden: 0, betrag: 0, buchungen: 0 };
|
||||||
|
g.stunden += e.minutes;
|
||||||
|
if (e.billable) g.abrechenbare_stunden += e.minutes;
|
||||||
|
g.betrag += e.amount;
|
||||||
|
g.buchungen += 1;
|
||||||
|
gruppen.set(k, g);
|
||||||
|
}
|
||||||
|
const zeilen = [...gruppen.values()]
|
||||||
|
.map((g) => ({ ...g, stunden: zahl(g.stunden), abrechenbare_stunden: zahl(g.abrechenbare_stunden), betrag: geld(g.betrag) }))
|
||||||
|
.sort((a, b) => (args.gruppieren === 'monat' || args.gruppieren === 'woche' ? String(a.gruppe).localeCompare(String(b.gruppe)) : b.stunden - a.stunden));
|
||||||
|
|
||||||
|
return {
|
||||||
|
zeitraum: { von: args.von, bis: args.bis },
|
||||||
|
gruppiert_nach: args.gruppieren,
|
||||||
|
summe: {
|
||||||
|
stunden: zahl(entries.reduce((s, e) => s + e.minutes, 0)),
|
||||||
|
abrechenbare_stunden: zahl(entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0)),
|
||||||
|
betrag: geld(entries.reduce((s, e) => s + e.amount, 0)),
|
||||||
|
buchungen: entries.length,
|
||||||
|
},
|
||||||
|
zeilen: zeilen.slice(0, MAX_ZEILEN),
|
||||||
|
gekuerzt: zeilen.length > MAX_ZEILEN ? zeilen.length - MAX_ZEILEN : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function werkzeugProjekte(args, ctx) {
|
||||||
|
const where = [];
|
||||||
|
const params = [];
|
||||||
|
const status = args.status || 'aktiv';
|
||||||
|
if (status === 'aktiv') where.push('p.is_active = 1');
|
||||||
|
if (status === 'archiviert') where.push('p.is_active = 0');
|
||||||
|
if (args.kunde) {
|
||||||
|
const { treffer, vorschlaege } = await finde('kunde', args.kunde);
|
||||||
|
if (treffer.length !== 1) return { fehler: `Kunde „${args.kunde}“ ist nicht eindeutig.`, moeglich: treffer.length ? treffer.map((t) => t.name) : vorschlaege };
|
||||||
|
where.push('p.customer_id = ?');
|
||||||
|
params.push(treffer[0].id);
|
||||||
|
}
|
||||||
|
if (args.suche) {
|
||||||
|
where.push('(p.name LIKE ? OR p.code LIKE ?)');
|
||||||
|
params.push(`%${args.suche}%`, `%${args.suche}%`);
|
||||||
|
}
|
||||||
|
const projekte = await loadProjects(ctx.settings, { where: where.join(' AND ') || '1=1', params });
|
||||||
|
const gefiltert = args.nur_mit_budget ? projekte.filter((p) => p.pots.length) : projekte;
|
||||||
|
const limit = Math.min(Number(args.limit) || 40, 80);
|
||||||
|
return {
|
||||||
|
anzahl: gefiltert.length,
|
||||||
|
projekte: gefiltert.slice(0, limit).map((p) => ({
|
||||||
|
code: p.code,
|
||||||
|
name: p.display_name || p.name,
|
||||||
|
kunde: p.customer_name,
|
||||||
|
typ: p.type,
|
||||||
|
aktiv: p.is_active,
|
||||||
|
laufzeit: { von: String(p.start_date), bis: p.end_date ? String(p.end_date) : null },
|
||||||
|
satz: p.rate == null ? null : Number(p.rate),
|
||||||
|
gebuchte_stunden: zahl(p.used_minutes || 0),
|
||||||
|
budget_stunden: p.budget_hours == null ? null : Number(p.budget_hours),
|
||||||
|
rest_stunden: p.remaining_hours == null ? null : Number(p.remaining_hours),
|
||||||
|
toepfe: p.pots.map((b) => ({
|
||||||
|
bezeichnung: b.label,
|
||||||
|
von: String(b.valid_from),
|
||||||
|
bis: String(b.valid_to),
|
||||||
|
budget_stunden: b.budget_hours == null ? null : Number(b.budget_hours),
|
||||||
|
budget_euro: b.budget_amount == null ? null : Number(b.budget_amount),
|
||||||
|
satz: b.rate == null ? null : Number(b.rate),
|
||||||
|
bestellnummer: b.order_number || null,
|
||||||
|
genutzte_stunden: zahl(b.used_minutes || 0),
|
||||||
|
erloest_euro: b.earned == null ? null : Number(b.earned),
|
||||||
|
status: b.status,
|
||||||
|
hinweis: b.warning || null,
|
||||||
|
})),
|
||||||
|
tasks: p.tasks.map((t) => ({ name: t.name, abrechenbar: Boolean(t.billable), satz: t.rate == null ? null : Number(t.rate) })),
|
||||||
|
team: p.members.map((m) => m.name),
|
||||||
|
})),
|
||||||
|
gekuerzt: Math.max(0, gefiltert.length - limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function werkzeugBuchungen(args, ctx) {
|
||||||
|
const filter = {};
|
||||||
|
let userIds = ctx.userIds;
|
||||||
|
for (const [art, wert] of [['kunde', args.kunde], ['projekt', args.projekt], ['person', args.person]]) {
|
||||||
|
if (!wert) continue;
|
||||||
|
const { treffer, vorschlaege } = await finde(art, wert);
|
||||||
|
if (treffer.length !== 1) {
|
||||||
|
return { fehler: `${art} „${wert}“ ist nicht eindeutig.`, moeglich: treffer.length ? treffer.map((t) => t.name || `${t.code} ${t.name}`) : vorschlaege };
|
||||||
|
}
|
||||||
|
if (art === 'kunde') filter.customerId = treffer[0].id;
|
||||||
|
if (art === 'projekt') filter.projectId = treffer[0].id;
|
||||||
|
if (art === 'person') {
|
||||||
|
if (ctx.userIds !== null && !ctx.userIds.includes(treffer[0].id)) return { fehler: 'Diese Person liegt außerhalb deiner Sicht.' };
|
||||||
|
userIds = [treffer[0].id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let entries = await loadEntries(ctx.settings, { from: args.von, to: args.bis, userIds, ...filter });
|
||||||
|
if (args.suche) entries = entries.filter((e) => passt(e.note, args.suche) || passt(e.task_name, args.suche));
|
||||||
|
const limit = Math.min(Number(args.limit) || 50, 100);
|
||||||
|
return {
|
||||||
|
zeitraum: { von: args.von, bis: args.bis },
|
||||||
|
anzahl: entries.length,
|
||||||
|
summe_stunden: zahl(entries.reduce((s, e) => s + e.minutes, 0)),
|
||||||
|
buchungen: entries.slice(0, limit).map((e) => ({
|
||||||
|
datum: String(e.entry_date),
|
||||||
|
person: e.user_name,
|
||||||
|
projekt: `${e.project_code} ${e.project_display}`,
|
||||||
|
task: e.task_name || null,
|
||||||
|
stunden: zahl(e.minutes),
|
||||||
|
abrechenbar: Boolean(e.billable),
|
||||||
|
betrag: geld(e.amount),
|
||||||
|
notiz: e.note || null,
|
||||||
|
})),
|
||||||
|
gekuerzt: Math.max(0, entries.length - limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function werkzeugPersonen(args, ctx) {
|
||||||
|
const nurAktive = args.nur_aktive !== false;
|
||||||
|
const [users] = await pool.query(
|
||||||
|
`SELECT id, name, role, weekly_hours, is_external, company, is_active, job_title
|
||||||
|
FROM pulse_users WHERE role <> 'admin' ${nurAktive ? 'AND is_active = 1' : ''} ORDER BY name`
|
||||||
|
);
|
||||||
|
const sichtbar = users.filter((u) => ctx.userIds === null || ctx.userIds.includes(u.id));
|
||||||
|
const zeilen = sichtbar.map((u) => ({
|
||||||
|
name: u.name,
|
||||||
|
rolle: u.role,
|
||||||
|
wochenstunden: u.weekly_hours == null ? Number(ctx.settings.weekly_hours) : Number(u.weekly_hours),
|
||||||
|
extern: Boolean(u.is_external),
|
||||||
|
firma: u.company || null,
|
||||||
|
aktiv: Boolean(u.is_active),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (args.von && args.bis) {
|
||||||
|
const { buildTimeReport } = await import('../routes/reports.js');
|
||||||
|
const report = await buildTimeReport(ctx.viewer, { from: args.von, to: args.bis });
|
||||||
|
const nachName = new Map(report.users.map((r) => [r.name, r]));
|
||||||
|
for (const zeile of zeilen) {
|
||||||
|
const r = nachName.get(zeile.name);
|
||||||
|
zeile.stunden = r ? zahl(r.minutes) : 0;
|
||||||
|
zeile.abrechenbare_stunden = r ? zahl(r.billable_minutes) : 0;
|
||||||
|
zeile.betrag = r ? geld(r.amount) : 0;
|
||||||
|
zeile.kapazitaet_stunden = r ? zahl(r.capacity_minutes || 0) : 0;
|
||||||
|
zeile.auslastung_prozent = r ? r.utilization : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { zeitraum: args.von && args.bis ? { von: args.von, bis: args.bis } : null, anzahl: zeilen.length, personen: zeilen };
|
||||||
|
}
|
||||||
|
|
||||||
|
const WERKZEUGE = {
|
||||||
|
auswertung: werkzeugAuswertung,
|
||||||
|
projekte: werkzeugProjekte,
|
||||||
|
buchungen: werkzeugBuchungen,
|
||||||
|
personen: werkzeugPersonen,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- Uebersicht -- */
|
||||||
|
|
||||||
|
/** Kleiner Lagebericht, damit das Modell Namen kennt und nicht blind sucht. */
|
||||||
|
async function uebersicht(settings, ctx) {
|
||||||
|
const [[zahlen]] = await pool.query(
|
||||||
|
`SELECT (SELECT COUNT(*) FROM pulse_customers) AS kunden,
|
||||||
|
(SELECT COUNT(*) FROM pulse_projects) AS projekte,
|
||||||
|
(SELECT COUNT(*) FROM pulse_projects WHERE is_active = 1) AS projekte_aktiv,
|
||||||
|
(SELECT COUNT(*) FROM pulse_users WHERE is_active = 1 AND role <> 'admin') AS mitarbeiter,
|
||||||
|
(SELECT COUNT(*) FROM pulse_time_entries) AS buchungen,
|
||||||
|
(SELECT MIN(entry_date) FROM pulse_time_entries) AS erste_buchung,
|
||||||
|
(SELECT MAX(entry_date) FROM pulse_time_entries) AS letzte_buchung`
|
||||||
|
);
|
||||||
|
const [kunden] = await pool.query('SELECT name FROM pulse_customers ORDER BY name');
|
||||||
|
const aktive = await loadProjects(settings, { where: 'p.is_active = 1' });
|
||||||
|
return {
|
||||||
|
heute: today(),
|
||||||
|
firma: settings.company_name,
|
||||||
|
stunden_pro_tag: Number(settings.hours_per_day),
|
||||||
|
wochenstunden_standard: Number(settings.weekly_hours),
|
||||||
|
bestand: {
|
||||||
|
kunden: zahlen.kunden,
|
||||||
|
projekte_gesamt: zahlen.projekte,
|
||||||
|
projekte_aktiv: zahlen.projekte_aktiv,
|
||||||
|
mitarbeiter_aktiv: zahlen.mitarbeiter,
|
||||||
|
buchungen: zahlen.buchungen,
|
||||||
|
zeitraum: { von: String(zahlen.erste_buchung || ''), bis: String(zahlen.letzte_buchung || '') },
|
||||||
|
},
|
||||||
|
kunden: kunden.map((k) => k.name),
|
||||||
|
laufende_projekte: aktive.map((p) => ({
|
||||||
|
code: p.code,
|
||||||
|
name: p.display_name || p.name,
|
||||||
|
kunde: p.customer_name,
|
||||||
|
typ: p.type,
|
||||||
|
rest_stunden: p.remaining_hours == null ? null : Number(p.remaining_hours),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- Ablauf -- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Frage beantworten. `verlauf` sind frühere Frage/Antwort-Paare dieses
|
||||||
|
* Gesprächs - Werkzeugergebnisse gehen nicht mit, die holt das Modell neu.
|
||||||
|
*/
|
||||||
|
export async function ask({ viewer, question, verlauf = [] }) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
if (!settings.ai.enabled) throw new AiError('Der KI-Assistent ist ausgeschaltet (Einstellungen → KI-Assistent).', 503);
|
||||||
|
|
||||||
|
const ctx = { settings, viewer, userIds: await visibleUserIds(viewer) };
|
||||||
|
const input = [];
|
||||||
|
for (const runde of verlauf.slice(-6)) {
|
||||||
|
if (runde?.frage) input.push({ role: 'user', content: String(runde.frage).slice(0, 2000) });
|
||||||
|
if (runde?.antwort) input.push({ role: 'assistant', content: String(runde.antwort).slice(0, 4000) });
|
||||||
|
}
|
||||||
|
input.push({
|
||||||
|
role: 'user',
|
||||||
|
content: `Übersicht:\n${JSON.stringify(await uebersicht(settings, ctx))}\n\nFrage: ${question}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const benutzt = [];
|
||||||
|
for (let runde = 0; runde < MAX_RUNDEN; runde += 1) {
|
||||||
|
const response = await askModel(settings.ai, {
|
||||||
|
instructions: ANWEISUNG,
|
||||||
|
input,
|
||||||
|
tools: TOOLS,
|
||||||
|
maxOutputTokens: 3000,
|
||||||
|
});
|
||||||
|
const aufrufe = toolCalls(response);
|
||||||
|
if (aufrufe.length === 0) {
|
||||||
|
const text = answerText(response);
|
||||||
|
if (!text) throw new AiError('Das Modell hat keine Antwort geliefert.', 502);
|
||||||
|
return { answer: text, model: response.model, tools: benutzt };
|
||||||
|
}
|
||||||
|
for (const item of response.output) input.push(item);
|
||||||
|
for (const aufruf of aufrufe) {
|
||||||
|
let ergebnis;
|
||||||
|
try {
|
||||||
|
const args = JSON.parse(aufruf.arguments || '{}');
|
||||||
|
benutzt.push({ name: aufruf.name, args });
|
||||||
|
const fn = WERKZEUGE[aufruf.name];
|
||||||
|
ergebnis = fn ? await fn(args, ctx) : { fehler: `Unbekanntes Werkzeug ${aufruf.name}.` };
|
||||||
|
} catch (err) {
|
||||||
|
ergebnis = { fehler: `Werkzeug fehlgeschlagen: ${err.message}` };
|
||||||
|
}
|
||||||
|
input.push({ type: 'function_call_output', call_id: aufruf.call_id, output: JSON.stringify(ergebnis) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new AiError('Die Frage brauchte zu viele Zwischenschritte. Bitte enger stellen.', 502);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -114,3 +114,65 @@ export function periodLabel(granularity, from) {
|
|||||||
if (granularity === 'quarter') return `Q${Math.floor((Number(m) - 1) / 3) + 1} ${y.slice(2)}`;
|
if (granularity === 'quarter') return `Q${Math.floor((Number(m) - 1) / 3) + 1} ${y.slice(2)}`;
|
||||||
return y;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { userFilter } from './access.js';
|
|||||||
* (Topfbudget / alle auf dem Topf gebuchten Stunden). Nur
|
* (Topfbudget / alle auf dem Topf gebuchten Stunden). Nur
|
||||||
* intern, in Reports per Schalter zuschaltbar.
|
* intern, in Reports per Schalter zuschaltbar.
|
||||||
*/
|
*/
|
||||||
export async function loadEntries(settings, { from, to, userIds = null, customerId, projectId, taskId } = {}) {
|
export async function loadEntries(settings, { from, to, userIds = null, customerId, projectId, projectIds, taskId, skipNonBillableProjects = false } = {}) {
|
||||||
// Administratoren sind keine Mitarbeiter - ihre Zeiten zaehlen nirgends mit.
|
// Administratoren sind keine Mitarbeiter - ihre Zeiten zaehlen nirgends mit.
|
||||||
const where = ["u.role <> 'admin'"];
|
const where = ["u.role <> 'admin'"];
|
||||||
const params = [];
|
const params = [];
|
||||||
@@ -35,10 +35,19 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
|
|||||||
where.push('e.project_id = ?');
|
where.push('e.project_id = ?');
|
||||||
params.push(Number(projectId));
|
params.push(Number(projectId));
|
||||||
}
|
}
|
||||||
|
// Mehrere Projekte: fuer den Kundennachweis, der eine Auswahl abdecken soll.
|
||||||
|
const ids = (Array.isArray(projectIds) ? projectIds : []).map(Number).filter(Number.isFinite);
|
||||||
|
if (ids.length) {
|
||||||
|
where.push(`e.project_id IN (${ids.map(() => '?').join(',')})`);
|
||||||
|
params.push(...ids);
|
||||||
|
}
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
where.push('e.task_id = ?');
|
where.push('e.task_id = ?');
|
||||||
params.push(Number(taskId));
|
params.push(Number(taskId));
|
||||||
}
|
}
|
||||||
|
// Fuer Auswertungen, die nur das Kundengeschaeft zeigen sollen: interne
|
||||||
|
// Projekte und Abwesenheiten bleiben draussen.
|
||||||
|
if (skipNonBillableProjects) where.push("p.type <> 'non_billable'");
|
||||||
|
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT e.id, e.user_id, e.project_id, e.task_id, e.pot_id, e.entry_date, e.minutes, e.note, e.billable,
|
`SELECT e.id, e.user_id, e.project_id, e.task_id, e.pot_id, e.entry_date, e.minutes, e.note, e.billable,
|
||||||
|
|||||||
@@ -162,6 +162,9 @@ export class Hrworks {
|
|||||||
sick_leaves: (events.sickLeaves || []).map((e) => e?.id).filter(Boolean),
|
sick_leaves: (events.sickLeaves || []).map((e) => e?.id).filter(Boolean),
|
||||||
// Feiertage tragen keine id, nur das Vorhandensein zaehlt.
|
// Feiertage tragen keine id, nur das Vorhandensein zaehlt.
|
||||||
holiday: Array.isArray(events.holidays) && events.holidays.length > 0,
|
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 }] */
|
/** { person: [{ absences: [...] }] } -> [{ person, ...absence }] */
|
||||||
function flatten(data, key) {
|
function flatten(data, key) {
|
||||||
const out = [];
|
const out = [];
|
||||||
|
|||||||
@@ -56,12 +56,12 @@ export function matchUsers(persons, users) {
|
|||||||
return { matched, unmatched };
|
return { matched, unmatched };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Tage in Abschnitte von hoechstens CHUNK_DAYS zerlegen. */
|
/** Tage in Abschnitte von hoechstens `size` Tagen zerlegen. */
|
||||||
export function chunks(from, to) {
|
export function chunks(from, to, size = CHUNK_DAYS) {
|
||||||
const out = [];
|
const out = [];
|
||||||
let start = from;
|
let start = from;
|
||||||
while (start <= to) {
|
while (start <= to) {
|
||||||
let end = addDays(start, CHUNK_DAYS - 1);
|
let end = addDays(start, size - 1);
|
||||||
if (end > to) end = to;
|
if (end > to) end = to;
|
||||||
out.push([start, end]);
|
out.push([start, end]);
|
||||||
start = addDays(end, 1);
|
start = addDays(end, 1);
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ export function isBillable(project, task) {
|
|||||||
export async function loadProjects(settings, { where = '1=1', params = [] } = {}) {
|
export async function loadProjects(settings, { where = '1=1', params = [] } = {}) {
|
||||||
const [projects] = await pool.query(
|
const [projects] = await pool.query(
|
||||||
`SELECT p.*, c.name AS customer_name, c.short_name AS customer_short, c.color AS customer_color,
|
`SELECT p.*, c.name AS customer_name, c.short_name AS customer_short, c.color AS customer_color,
|
||||||
|
c.is_active AS customer_active,
|
||||||
u.name AS owner_name
|
u.name AS owner_name
|
||||||
FROM pulse_projects p
|
FROM pulse_projects p
|
||||||
JOIN pulse_customers c ON c.id = p.customer_id
|
JOIN pulse_customers c ON c.id = p.customer_id
|
||||||
@@ -270,7 +271,7 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
|
|||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
display_name: displayName(settings, project, customer),
|
display_name: displayName(settings, project, customer),
|
||||||
customer: { id: p.customer_id, name: p.customer_name, short_name: p.customer_short, color: p.customer_color },
|
customer: { id: p.customer_id, name: p.customer_name, short_name: p.customer_short, color: p.customer_color, is_active: Boolean(p.customer_active) },
|
||||||
pots: projectPots,
|
pots: projectPots,
|
||||||
tasks: tasks
|
tasks: tasks
|
||||||
.filter((t) => t.project_id === p.id)
|
.filter((t) => t.project_id === p.id)
|
||||||
@@ -343,8 +344,12 @@ export class BookingError extends Error {
|
|||||||
* - ist das Volumen erreicht oder das Ablaufdatum ueberschritten: nicht buchbar
|
* - ist das Volumen erreicht oder das Ablaufdatum ueberschritten: nicht buchbar
|
||||||
* - ueberlappen mehrere Toepfe, zuerst der, der am fruehesten endet
|
* - ueberlappen mehrere Toepfe, zuerst der, der am fruehesten endet
|
||||||
* - Projekte ohne Topf (z. B. intern, unbegrenzt) sind frei buchbar
|
* - Projekte ohne Topf (z. B. intern, unbegrenzt) sind frei buchbar
|
||||||
|
*
|
||||||
|
* Mit `historic` entfaellt die Ablaufsperre: bei der Uebernahme alter Zeiten
|
||||||
|
* zaehlt, ob der Topf am Leistungstag lief - nicht, ob er heute noch offen ist.
|
||||||
|
* Das Volumen wird weiter geprueft, damit Ueberbuchungen auffallen.
|
||||||
*/
|
*/
|
||||||
export async function resolvePot(conn, project, date, minutes, excludeEntryId = null, billable = true) {
|
export async function resolvePot(conn, project, date, minutes, excludeEntryId = null, billable = true, { historic = false } = {}) {
|
||||||
const onlyBillable = countsAgainstBudget(project) === 'billable';
|
const onlyBillable = countsAgainstBudget(project) === 'billable';
|
||||||
// Nicht abrechenbare Stunden in einem Billable-Projekt verbrauchen kein Volumen.
|
// Nicht abrechenbare Stunden in einem Billable-Projekt verbrauchen kein Volumen.
|
||||||
const needed = onlyBillable && !billable ? 0 : minutes;
|
const needed = onlyBillable && !billable ? 0 : minutes;
|
||||||
@@ -370,7 +375,7 @@ export async function resolvePot(conn, project, date, minutes, excludeEntryId =
|
|||||||
}
|
}
|
||||||
let lastShortage = null;
|
let lastShortage = null;
|
||||||
for (const pot of covering) {
|
for (const pot of covering) {
|
||||||
if (pot.valid_to < today()) {
|
if (!historic && pot.valid_to < today()) {
|
||||||
lastShortage = `Budgettopf „${pot.label}“ ist seit ${de(pot.valid_to)} abgelaufen.`;
|
lastShortage = `Budgettopf „${pot.label}“ ist seit ${de(pot.valid_to)} abgelaufen.`;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,14 @@ export const DEFAULTS = {
|
|||||||
'sick:KWW': 'Krankheit',
|
'sick:KWW': 'Krankheit',
|
||||||
'sick:KEK': 'Kind krank',
|
'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.
|
// Task fuer Feiertage; leer = Feiertage nicht buchen.
|
||||||
holiday_task: 'Feiertag',
|
holiday_task: 'Feiertag',
|
||||||
// Halber Tag: feste Stundenzahl (0 = aus HRworks uebernehmen).
|
// Halber Tag: feste Stundenzahl (0 = aus HRworks uebernehmen).
|
||||||
@@ -71,6 +79,19 @@ export const DEFAULTS = {
|
|||||||
last_run: null,
|
last_run: null,
|
||||||
last_result: 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
|
||||||
|
* Namen regelmaessig wechselt.
|
||||||
|
*/
|
||||||
|
ai: {
|
||||||
|
enabled: false,
|
||||||
|
api_key: '',
|
||||||
|
model: 'gpt-5.6-luna',
|
||||||
|
},
|
||||||
sso: {
|
sso: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
button_label: 'Mit Microsoft anmelden',
|
button_label: 'Mit Microsoft anmelden',
|
||||||
@@ -104,6 +125,7 @@ export async function getSettings() {
|
|||||||
}
|
}
|
||||||
if (result.sso.client_secret) result.sso.client_secret = decrypt(result.sso.client_secret);
|
if (result.sso.client_secret) result.sso.client_secret = decrypt(result.sso.client_secret);
|
||||||
if (result.hrworks.secret_access_key) result.hrworks.secret_access_key = decrypt(result.hrworks.secret_access_key);
|
if (result.hrworks.secret_access_key) result.hrworks.secret_access_key = decrypt(result.hrworks.secret_access_key);
|
||||||
|
if (result.ai.api_key) result.ai.api_key = decrypt(result.ai.api_key);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +134,7 @@ export async function saveSetting(key, value) {
|
|||||||
let stored = value;
|
let stored = value;
|
||||||
if (key === 'sso' && value.client_secret) stored = { ...value, client_secret: encrypt(value.client_secret) };
|
if (key === 'sso' && value.client_secret) stored = { ...value, client_secret: encrypt(value.client_secret) };
|
||||||
if (key === 'hrworks' && value.secret_access_key) stored = { ...value, secret_access_key: encrypt(value.secret_access_key) };
|
if (key === 'hrworks' && value.secret_access_key) stored = { ...value, secret_access_key: encrypt(value.secret_access_key) };
|
||||||
|
if (key === 'ai' && value.api_key) stored = { ...value, api_key: encrypt(value.api_key) };
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'INSERT INTO pulse_settings (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)',
|
'INSERT INTO pulse_settings (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)',
|
||||||
[key, JSON.stringify(stored)]
|
[key, JSON.stringify(stored)]
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ function secret() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function signToken(user) {
|
export function signToken(user) {
|
||||||
|
// Eine Woche ab Anmeldung, ohne Verlaengerung bei Aktivitaet. Deaktivierte
|
||||||
|
// Konten und geaenderte Rollen greifen trotzdem sofort - requireAuth laedt
|
||||||
|
// den Benutzer bei jeder Anfrage frisch.
|
||||||
return jwt.sign({ sub: user.id, role: user.role }, secret(), {
|
return jwt.sign({ sub: user.id, role: user.role }, secret(), {
|
||||||
expiresIn: process.env.JWT_EXPIRES_IN || '12h',
|
expiresIn: process.env.JWT_EXPIRES_IN || '7d',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { requireRole } from '../middleware/auth.js';
|
||||||
|
import { getSettings } from '../lib/settings.js';
|
||||||
|
import { aiConfigured } from '../lib/ai.js';
|
||||||
|
import { ask } from '../lib/assistant.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Der Assistent im Kopf der Anwendung.
|
||||||
|
*
|
||||||
|
* Nur Management und Administration: die Antworten fassen Zeiten, Budgets und
|
||||||
|
* Umsaetze ueber alle Mitarbeiter zusammen - das ist keine Sicht fuer jeden.
|
||||||
|
* Welche Daten einfliessen, entscheidet trotzdem `visibleUserIds`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireRole('management'));
|
||||||
|
|
||||||
|
router.get('/status', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { ai } = await getSettings();
|
||||||
|
res.json({ available: Boolean(ai.enabled && aiConfigured(ai)), enabled: ai.enabled, model: ai.model });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/ask', async (req, res, next) => {
|
||||||
|
const question = String(req.body?.question || '').trim().slice(0, 1000);
|
||||||
|
if (!question) return res.status(400).json({ error: 'Keine Frage übergeben.' });
|
||||||
|
const verlauf = Array.isArray(req.body?.verlauf) ? req.body.verlauf.slice(-6) : [];
|
||||||
|
try {
|
||||||
|
const result = await ask({ viewer: req.user, question, verlauf });
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status) return res.status(err.status).json({ error: err.message });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -76,7 +76,7 @@ router.patch('/me', requireAuth, async (req, res, next) => {
|
|||||||
const updates = {};
|
const updates = {};
|
||||||
if (req.body?.theme !== undefined) {
|
if (req.body?.theme !== undefined) {
|
||||||
const theme = req.body.theme;
|
const theme = req.body.theme;
|
||||||
if (theme !== null && !['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow', 'aura'].includes(theme)) {
|
if (theme !== null && !['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow', 'aura', 'mobile'].includes(theme)) {
|
||||||
return res.status(400).json({ error: 'Unbekanntes Design.' });
|
return res.status(400).json({ error: 'Unbekanntes Design.' });
|
||||||
}
|
}
|
||||||
updates.theme = theme === 'perinco' ? null : theme;
|
updates.theme = theme === 'perinco' ? null : theme;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -42,12 +42,20 @@ router.get('/timesheet', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
const showAmounts = req.query.amounts === '1' && hasRole(req.user, 'lead');
|
const showAmounts = req.query.amounts === '1' && hasRole(req.user, 'lead');
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
|
// ?project_ids=1,2,3 fuer den Kundennachweis ueber eine Projektauswahl.
|
||||||
|
const projectIds = String(req.query.project_ids || '')
|
||||||
|
.split(',')
|
||||||
|
.map((v) => Number(v.trim()))
|
||||||
|
// Ohne Angabe bleibt die Liste leer - aus '' wuerde sonst eine 0 und
|
||||||
|
// damit ein Filter auf ein Projekt, das es nicht gibt.
|
||||||
|
.filter((v) => Number.isFinite(v) && v > 0);
|
||||||
const entries = await loadEntries(settings, {
|
const entries = await loadEntries(settings, {
|
||||||
from: range.from,
|
from: range.from,
|
||||||
to: range.to,
|
to: range.to,
|
||||||
userIds,
|
userIds,
|
||||||
customerId: req.query.customer_id,
|
customerId: req.query.customer_id,
|
||||||
projectId: req.query.project_id,
|
projectId: req.query.project_id,
|
||||||
|
projectIds,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [y, m] = req.query.month.split('-');
|
const [y, m] = req.query.month.split('-');
|
||||||
@@ -110,6 +118,20 @@ router.get('/timesheet', async (req, res, next) => {
|
|||||||
summary.set(key, item);
|
summary.set(key, item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Wer war beteiligt? Auf einem Kundennachweis ueber mehrere Berater ist das
|
||||||
|
* die Frage, die als Erstes kommt - die Spalte je Zeile beantwortet sie
|
||||||
|
* erst nach dem Durchlesen.
|
||||||
|
*/
|
||||||
|
const perPerson = new Map();
|
||||||
|
for (const e of entries) {
|
||||||
|
const item = perPerson.get(e.user_id) || { user_name: e.user_name, days: new Set(), minutes: 0, amount: 0 };
|
||||||
|
item.days.add(e.entry_date);
|
||||||
|
item.minutes += e.minutes;
|
||||||
|
item.amount += e.amount;
|
||||||
|
perPerson.set(e.user_id, item);
|
||||||
|
}
|
||||||
|
|
||||||
const orderNumbers = [...new Set(entries.map((e) => e.pot_order_number).filter(Boolean))];
|
const orderNumbers = [...new Set(entries.map((e) => e.pot_order_number).filter(Boolean))];
|
||||||
|
|
||||||
const doc = {
|
const doc = {
|
||||||
@@ -138,6 +160,24 @@ router.get('/timesheet', async (req, res, next) => {
|
|||||||
rows,
|
rows,
|
||||||
totals: { entry_date: null, weekday: 'Summe', duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) },
|
totals: { entry_date: null, weekday: 'Summe', duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) },
|
||||||
},
|
},
|
||||||
|
...(multiUser && perPerson.size > 1
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
title: 'Zusammenfassung nach Berater',
|
||||||
|
columns: [
|
||||||
|
{ key: 'user_name', label: 'Berater', xlsWidth: 28 },
|
||||||
|
{ key: 'days', label: 'Tage', align: 'right', width: 60 },
|
||||||
|
{ key: 'duration', label: 'Dauer', align: 'right', width: 60 },
|
||||||
|
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 60 },
|
||||||
|
...(showAmounts ? [{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 80 }] : []),
|
||||||
|
],
|
||||||
|
rows: [...perPerson.values()]
|
||||||
|
.sort((a, b) => b.minutes - a.minutes)
|
||||||
|
.map((r) => ({ ...r, days: String(r.days.size), duration: durationDe(r.minutes) })),
|
||||||
|
totals: { user_name: 'Summe', days: String(days), duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
{
|
{
|
||||||
title: 'Zusammenfassung nach Projekt und Task',
|
title: 'Zusammenfassung nach Projekt und Task',
|
||||||
columns: [
|
columns: [
|
||||||
@@ -244,6 +284,7 @@ router.get('/report', async (req, res, next) => {
|
|||||||
info: [
|
info: [
|
||||||
['Kunde', customerName || 'Alle Kunden'],
|
['Kunde', customerName || 'Alle Kunden'],
|
||||||
['Festpreisprojekte', report.include_fixed ? 'im Betrag enthalten' : 'nicht im Betrag enthalten'],
|
['Festpreisprojekte', report.include_fixed ? 'im Betrag enthalten' : 'nicht im Betrag enthalten'],
|
||||||
|
...(report.hide_non_billable ? [['Nicht abrechenbare Projekte', 'ausgeblendet']] : []),
|
||||||
],
|
],
|
||||||
kpis: [
|
kpis: [
|
||||||
['Gesamtstunden', hoursDe(s.minutes)],
|
['Gesamtstunden', hoursDe(s.minutes)],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { pool } from '../db.js';
|
|||||||
import { getSettings } from '../lib/settings.js';
|
import { getSettings } from '../lib/settings.js';
|
||||||
import { Hrworks } from '../lib/hrworks.js';
|
import { Hrworks } from '../lib/hrworks.js';
|
||||||
import { syncHrworks } from '../lib/hrworksSync.js';
|
import { syncHrworks } from '../lib/hrworksSync.js';
|
||||||
|
import { syncCalendar } from '../lib/calendarSync.js';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Abwesenheiten aus HRworks - Bedienung und Kontrolle.
|
* Abwesenheiten aus HRworks - Bedienung und Kontrolle.
|
||||||
@@ -16,7 +17,7 @@ const router = express.Router();
|
|||||||
/** Was ist eingerichtet, was hat der letzte Lauf gebracht? */
|
/** Was ist eingerichtet, was hat der letzte Lauf gebracht? */
|
||||||
router.get('/status', async (req, res, next) => {
|
router.get('/status', async (req, res, next) => {
|
||||||
try {
|
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', [
|
const [[project]] = await pool.query('SELECT id, code, name, type, start_date FROM pulse_projects WHERE code = ? LIMIT 1', [
|
||||||
hrworks.project_code,
|
hrworks.project_code,
|
||||||
]);
|
]);
|
||||||
@@ -35,6 +36,8 @@ router.get('/status', async (req, res, next) => {
|
|||||||
type_map: hrworks.type_map,
|
type_map: hrworks.type_map,
|
||||||
last_run: hrworks.last_run,
|
last_run: hrworks.last_run,
|
||||||
last_result: hrworks.last_result,
|
last_result: hrworks.last_result,
|
||||||
|
calendar_types: hrworks.calendar_types,
|
||||||
|
calendar: calendar_status,
|
||||||
entries: Number(n),
|
entries: Number(n),
|
||||||
hours: Math.round((Number(range.minutes) / 60) * 100) / 100,
|
hours: Math.round((Number(range.minutes) / 60) * 100) / 100,
|
||||||
first_date: range.von,
|
first_date: range.von,
|
||||||
@@ -77,11 +80,16 @@ router.post('/test', async (req, res, next) => {
|
|||||||
*/
|
*/
|
||||||
router.post('/sync', async (req, res, next) => {
|
router.post('/sync', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const bericht = await syncHrworks({
|
const dryRun = req.body?.apply !== true;
|
||||||
dryRun: req.body?.apply !== true,
|
const bericht = await syncHrworks({ dryRun, from: req.body?.from, to: req.body?.to });
|
||||||
from: req.body?.from,
|
// Der Kalender zieht beim Uebernehmen gleich mit - er hat sein eigenes Fenster.
|
||||||
to: req.body?.to,
|
if (!dryRun) {
|
||||||
});
|
try {
|
||||||
|
bericht.calendar = await syncCalendar({ dryRun: false });
|
||||||
|
} catch (err) {
|
||||||
|
bericht.calendar_error = err.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
res.json(bericht);
|
res.json(bericht);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status) return res.status(400).json({ error: err.message });
|
if (err.status) return res.status(400).json({ error: err.message });
|
||||||
|
|||||||
@@ -16,11 +16,21 @@ import { isBillable, resolvePot } from '../lib/projects.js';
|
|||||||
*
|
*
|
||||||
* Wiedererkennung ueber harvest_id: ein zweiter Lauf legt nichts doppelt an,
|
* Wiedererkennung ueber harvest_id: ein zweiter Lauf legt nichts doppelt an,
|
||||||
* sondern meldet die Buchung als "schon vorhanden".
|
* sondern meldet die Buchung als "schon vorhanden".
|
||||||
|
*
|
||||||
|
* Eine Ausnahme von den Fachregeln: die Grenze von 24 Stunden je Tag gilt hier
|
||||||
|
* nicht. In Harvest stehen Sammelbuchungen ("Nachbuchung DORA September und
|
||||||
|
* Oktober", 80 Stunden auf einem Tag). Wer sie ablehnt, verliert die Stunden
|
||||||
|
* und damit das Restbudget des Projekts; wer sie auf Tage verteilt, erfindet
|
||||||
|
* Leistungsdaten. Sie kommen deshalb so herein, wie sie gebucht wurden. Im
|
||||||
|
* Buchungsdialog bleibt die Tagesgrenze bestehen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
const MAX_BATCH = 1000;
|
const MAX_BATCH = 1000;
|
||||||
|
// Sammelbuchungen duerfen mehr als einen Tag umfassen - hoechstens aber 31 Tage,
|
||||||
|
// damit Zahlendreher nicht unbemerkt durchgehen.
|
||||||
|
const MAX_MINUTES = 31 * 24 * 60;
|
||||||
|
|
||||||
router.post('/entries', async (req, res, next) => {
|
router.post('/entries', async (req, res, next) => {
|
||||||
const body = req.body || {};
|
const body = req.body || {};
|
||||||
@@ -47,7 +57,9 @@ router.post('/entries', async (req, res, next) => {
|
|||||||
const note = row.note == null ? null : String(row.note).trim().slice(0, 1000) || null;
|
const note = row.note == null ? null : String(row.note).trim().slice(0, 1000) || null;
|
||||||
|
|
||||||
if (!parseDate(date)) throw new Error('Datum fehlt oder ist unguelltig.');
|
if (!parseDate(date)) throw new Error('Datum fehlt oder ist unguelltig.');
|
||||||
if (!Number.isInteger(minutes) || minutes < 1 || minutes > 24 * 60) throw new Error('Minuten muessen zwischen 1 und 1440 liegen.');
|
if (!Number.isInteger(minutes) || minutes < 1 || minutes > MAX_MINUTES) {
|
||||||
|
throw new Error(`Minuten muessen zwischen 1 und ${MAX_MINUTES} liegen.`);
|
||||||
|
}
|
||||||
|
|
||||||
// Schon importiert? Dann nicht erneut anlegen.
|
// Schon importiert? Dann nicht erneut anlegen.
|
||||||
if (harvestId) {
|
if (harvestId) {
|
||||||
@@ -77,14 +89,9 @@ router.post('/entries', async (req, res, next) => {
|
|||||||
task = found;
|
task = found;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [[day]] = await conn.query(
|
|
||||||
'SELECT COALESCE(SUM(minutes), 0) AS total FROM pulse_time_entries WHERE user_id = ? AND entry_date = ?',
|
|
||||||
[userId, date]
|
|
||||||
);
|
|
||||||
if (Number(day.total) + minutes > 24 * 60) throw new Error('An diesem Tag waeren es mehr als 24 Stunden.');
|
|
||||||
|
|
||||||
const billable = isBillable(project, task);
|
const billable = isBillable(project, task);
|
||||||
const pot = await resolvePot(conn, project, date, minutes, null, billable);
|
// Historische Uebernahme: abgelaufene Toepfe nehmen ihre eigenen Zeiten noch an.
|
||||||
|
const pot = await resolvePot(conn, project, date, minutes, null, billable, { historic: true });
|
||||||
if (dryRun) return 0;
|
if (dryRun) return 0;
|
||||||
const [result] = await conn.query(
|
const [result] = await conn.query(
|
||||||
`INSERT INTO pulse_time_entries (user_id, project_id, task_id, pot_id, entry_date, minutes, note, billable, harvest_id)
|
`INSERT INTO pulse_time_entries (user_id, project_id, task_id, pot_id, entry_date, minutes, note, billable, harvest_id)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { pool, withTransaction } from '../db.js';
|
import { pool, withTransaction } from '../db.js';
|
||||||
import { hasRole, requireRole } from '../middleware/auth.js';
|
import { hasRole, requireRole } from '../middleware/auth.js';
|
||||||
|
import { visibleUserIds } from '../lib/access.js';
|
||||||
import { getSettings } from '../lib/settings.js';
|
import { getSettings } from '../lib/settings.js';
|
||||||
import { parseDate } from '../lib/dates.js';
|
import { parseDate } from '../lib/dates.js';
|
||||||
import {
|
import {
|
||||||
@@ -148,16 +149,29 @@ async function refreshBillable(conn, projectId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Liste. Mitarbeiter sehen nur zugewiesene Projekte, ohne Betraege.
|
// Liste. Mitarbeiter sehen nur zugewiesene Projekte, ohne Betraege.
|
||||||
// ?scope=mine liefert auch fuer Fuehrungsrollen nur die eigenen (Buchungsdialog).
|
/*
|
||||||
|
* ?scope=mine liefert auch fuer Fuehrungsrollen nur die eigenen (Buchungsdialog).
|
||||||
|
* ?user_id=<id> die Projekte einer anderen Person - gebraucht, wenn eine
|
||||||
|
* Fuehrungskraft den Stundennachweis fuer jemanden aus ihrem Team baut. Wer
|
||||||
|
* eingesehen werden darf, entscheidet dieselbe Sicht wie bei den Zeiten.
|
||||||
|
*/
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
|
let memberId = req.user.id;
|
||||||
|
if (req.query.user_id && Number(req.query.user_id) !== req.user.id) {
|
||||||
|
if (!hasRole(req.user, 'lead')) return res.status(403).json({ error: 'Keine Einsicht in fremde Projekte.' });
|
||||||
|
const visible = await visibleUserIds(req.user);
|
||||||
|
const wanted = Number(req.query.user_id);
|
||||||
|
if (visible !== null && !visible.includes(wanted)) return res.status(403).json({ error: 'Keine Einsicht in diese Projekte.' });
|
||||||
|
memberId = wanted;
|
||||||
|
}
|
||||||
// Mitarbeiter und Externe sehen nur zugewiesene Projekte.
|
// Mitarbeiter und Externe sehen nur zugewiesene Projekte.
|
||||||
const mineOnly = !hasRole(req.user, 'lead') || req.query.scope === 'mine';
|
const mineOnly = !hasRole(req.user, 'lead') || req.query.scope === 'mine' || memberId !== req.user.id;
|
||||||
const projects = await loadProjects(
|
const projects = await loadProjects(
|
||||||
settings,
|
settings,
|
||||||
mineOnly
|
mineOnly
|
||||||
? { where: 'p.id IN (SELECT project_id FROM pulse_project_members WHERE user_id = ?)', params: [req.user.id] }
|
? { where: 'p.id IN (SELECT project_id FROM pulse_project_members WHERE user_id = ?)', params: [memberId] }
|
||||||
: {}
|
: {}
|
||||||
);
|
);
|
||||||
res.json(hasRole(req.user, 'lead') ? projects : projects.map(stripAmounts));
|
res.json(hasRole(req.user, 'lead') ? projects : projects.map(stripAmounts));
|
||||||
@@ -195,7 +209,9 @@ router.post('/', requireRole('lead'), async (req, res, next) => {
|
|||||||
errors.push(...result.errors);
|
errors.push(...result.errors);
|
||||||
pots.push(result.pot);
|
pots.push(result.pot);
|
||||||
}
|
}
|
||||||
} else if (!out.rate) {
|
} else if (out.type === 'abruf' && !out.rate) {
|
||||||
|
// Nur Abruf rechnet ueber den Satz am Projekt ab; nicht abrechenbare
|
||||||
|
// Projekte brauchen keinen - er wird gleich darunter ohnehin verworfen.
|
||||||
errors.push('Abruf: Stundensatz oder Tagessatz fehlt.');
|
errors.push('Abruf: Stundensatz oder Tagessatz fehlt.');
|
||||||
}
|
}
|
||||||
if (out.type !== 'abruf') out.rate = null;
|
if (out.type !== 'abruf') out.rate = null;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { teamOf, userFilter, visibleUserIds } from '../lib/access.js';
|
|||||||
import { getSettings } from '../lib/settings.js';
|
import { getSettings } from '../lib/settings.js';
|
||||||
import { loadEntries } from '../lib/entries.js';
|
import { loadEntries } from '../lib/entries.js';
|
||||||
import { loadProjects } from '../lib/projects.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';
|
import { capacityMinutes } from './dashboard.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -39,6 +39,9 @@ export async function buildTimeReport(viewer, query) {
|
|||||||
const from = query.from;
|
const from = query.from;
|
||||||
const to = query.to;
|
const to = query.to;
|
||||||
const includeFixed = query.include_fixed === '1' || query.include_fixed === 'true';
|
const includeFixed = query.include_fixed === '1' || query.include_fixed === 'true';
|
||||||
|
// Schalter im Report: nicht abrechenbare Projekte (intern, Abwesenheiten)
|
||||||
|
// ausblenden - dann zeigt die Auswertung nur das Kundengeschaeft.
|
||||||
|
const hideNonBillable = query.hide_non_billable === '1' || query.hide_non_billable === 'true';
|
||||||
const userIds = await reportUserIds(viewer, query.team);
|
const userIds = await reportUserIds(viewer, query.team);
|
||||||
const entries = await loadEntries(settings, {
|
const entries = await loadEntries(settings, {
|
||||||
from,
|
from,
|
||||||
@@ -46,6 +49,7 @@ export async function buildTimeReport(viewer, query) {
|
|||||||
userIds,
|
userIds,
|
||||||
customerId: query.customer_id,
|
customerId: query.customer_id,
|
||||||
projectId: query.project_id,
|
projectId: query.project_id,
|
||||||
|
skipNonBillableProjects: hideNonBillable,
|
||||||
});
|
});
|
||||||
|
|
||||||
const filter = userFilter(userIds, 'id');
|
const filter = userFilter(userIds, 'id');
|
||||||
@@ -91,6 +95,7 @@ export async function buildTimeReport(viewer, query) {
|
|||||||
return {
|
return {
|
||||||
range: { from, to },
|
range: { from, to },
|
||||||
include_fixed: includeFixed,
|
include_fixed: includeFixed,
|
||||||
|
hide_non_billable: hideNonBillable,
|
||||||
summary: {
|
summary: {
|
||||||
minutes,
|
minutes,
|
||||||
billable_minutes: billable,
|
billable_minutes: billable,
|
||||||
@@ -167,15 +172,6 @@ function buildProjectDetail(project, entries) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
/*
|
|
||||||
* Auslastung nur bis heute. Die Kapazitaet endet ohnehin am heutigen Tag
|
|
||||||
* (capTo) - werden Zeiten im Voraus gebucht, etwa eine ganze Woche Urlaub am
|
|
||||||
* Montag, stehen sonst 40 gebuchte Stunden gegen 8 Stunden Kapazitaet und die
|
|
||||||
* Auslastung springt auf 500 Prozent. Die Spalte "Stunden" zeigt weiter
|
|
||||||
* alles, was im Zeitraum gebucht ist.
|
|
||||||
*/
|
|
||||||
const bisHeute = entries.filter((e) => e.entry_date <= capTo);
|
|
||||||
|
|
||||||
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);
|
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
|
||||||
return {
|
return {
|
||||||
@@ -200,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. */
|
/** Kundenreport: alle Projekte eines Kunden mit Budget, Verlauf und Team. */
|
||||||
router.get('/customer/:id', async (req, res, next) => {
|
router.get('/customer/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -208,39 +251,20 @@ router.get('/customer/:id', async (req, res, next) => {
|
|||||||
if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden.' });
|
if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden.' });
|
||||||
const projects = await loadProjects(settings, { where: 'p.customer_id = ?', params: [customer.id] });
|
const projects = await loadProjects(settings, { where: 'p.customer_id = ?', params: [customer.id] });
|
||||||
const userIds = await visibleUserIds(req.user);
|
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);
|
||||||
// Monatsreihe ueber die ganze Kundenbeziehung, hoechstens 24 Monate.
|
const from = ranged ? req.query.from : null;
|
||||||
const months = [...new Set(entries.map((e) => e.entry_date.slice(0, 7)))].sort();
|
const to = ranged ? req.query.to : null;
|
||||||
const first = months[0] || today().slice(0, 7);
|
if (ranged && from > to) return res.status(400).json({ error: 'Zeitraum ungültig.' });
|
||||||
const series = [];
|
const entries = await loadEntries(settings, { customerId: customer.id, userIds, from, to });
|
||||||
const cursor = new Date(`${first}-01T00:00:00Z`);
|
const series = customerSeries(entries, from, to);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Auslastung nur bis heute. Die Kapazitaet endet ohnehin am heutigen Tag
|
|
||||||
* (capTo) - werden Zeiten im Voraus gebucht, etwa eine ganze Woche Urlaub am
|
|
||||||
* Montag, stehen sonst 40 gebuchte Stunden gegen 8 Stunden Kapazitaet und die
|
|
||||||
* Auslastung springt auf 500 Prozent. Die Spalte "Stunden" zeigt weiter
|
|
||||||
* alles, was im Zeitraum gebucht ist.
|
|
||||||
*/
|
|
||||||
const bisHeute = entries.filter((e) => e.entry_date <= capTo);
|
|
||||||
|
|
||||||
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);
|
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
|
||||||
res.json({
|
res.json({
|
||||||
customer,
|
customer,
|
||||||
|
range: { from, to },
|
||||||
|
series_unit: series.unit,
|
||||||
totals: {
|
totals: {
|
||||||
minutes,
|
minutes,
|
||||||
billable_minutes: billable,
|
billable_minutes: billable,
|
||||||
@@ -250,7 +274,7 @@ router.get('/customer/:id', async (req, res, next) => {
|
|||||||
open: r2(projects.reduce((s, p) => s + p.open, 0)),
|
open: r2(projects.reduce((s, p) => s + p.open, 0)),
|
||||||
budget_value: r2(projects.reduce((s, p) => s + p.budget_value, 0)),
|
budget_value: r2(projects.reduce((s, p) => s + p.budget_value, 0)),
|
||||||
},
|
},
|
||||||
series: series.slice(-24),
|
series: series.points,
|
||||||
projects: projects.map((p) => {
|
projects: projects.map((p) => {
|
||||||
const own = entries.filter((e) => e.project_id === p.id);
|
const own = entries.filter((e) => e.project_id === p.id);
|
||||||
return { ...buildProjectDetail(p, own), weekly: undefined };
|
return { ...buildProjectDetail(p, own), weekly: undefined };
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import express from 'express';
|
|||||||
import { requireRole, ROLES } from '../middleware/auth.js';
|
import { requireRole, ROLES } from '../middleware/auth.js';
|
||||||
import { DEFAULTS, getSettings, saveSetting } from '../lib/settings.js';
|
import { DEFAULTS, getSettings, saveSetting } from '../lib/settings.js';
|
||||||
import { ssoConfigured, testConnection } from '../lib/sso.js';
|
import { ssoConfigured, testConnection } from '../lib/sso.js';
|
||||||
|
import { answerText, askModel, listModels } from '../lib/ai.js';
|
||||||
import { sendDocument } from '../export/render.js';
|
import { sendDocument } from '../export/render.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -37,6 +38,7 @@ router.get('/', requireRole('admin'), async (req, res, next) => {
|
|||||||
secret_set: Boolean(s.hrworks.secret_access_key),
|
secret_set: Boolean(s.hrworks.secret_access_key),
|
||||||
configured: Boolean(s.hrworks.access_key && s.hrworks.secret_access_key),
|
configured: Boolean(s.hrworks.access_key && s.hrworks.secret_access_key),
|
||||||
},
|
},
|
||||||
|
ai: { ...s.ai, api_key: '', key_set: Boolean(s.ai.api_key), configured: Boolean(s.ai.api_key) },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@@ -116,6 +118,12 @@ router.put('/', requireRole('admin'), async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
next_.type_map = map;
|
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.
|
// 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.secret_access_key) next_.secret_access_key = text(body.hrworks.secret_access_key, 500).trim();
|
||||||
if (body.hrworks.clear_secret) next_.secret_access_key = '';
|
if (body.hrworks.clear_secret) next_.secret_access_key = '';
|
||||||
@@ -125,6 +133,17 @@ router.put('/', requireRole('admin'), async (req, res, next) => {
|
|||||||
if (!errors.length) await saveSetting('hrworks', next_);
|
if (!errors.length) await saveSetting('hrworks', next_);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (body.ai) {
|
||||||
|
const next_ = { ...current.ai };
|
||||||
|
if (body.ai.model !== undefined) next_.model = text(body.ai.model, 80).trim() || DEFAULTS.ai.model;
|
||||||
|
if (body.ai.enabled !== undefined) next_.enabled = Boolean(body.ai.enabled);
|
||||||
|
// Leer = unveraendert; der Schluessel wird nie ausgeliefert.
|
||||||
|
if (body.ai.api_key) next_.api_key = text(body.ai.api_key, 300).trim();
|
||||||
|
if (body.ai.clear_key) next_.api_key = '';
|
||||||
|
if (next_.enabled && !next_.api_key) errors.push('Der KI-Assistent braucht einen OpenAI-Schlüssel.');
|
||||||
|
if (!errors.length) await saveSetting('ai', next_);
|
||||||
|
}
|
||||||
|
|
||||||
if (body.sso) {
|
if (body.sso) {
|
||||||
const next_ = { ...current.sso };
|
const next_ = { ...current.sso };
|
||||||
for (const key of ['tenant_id', 'client_id', 'redirect_uri', 'button_label']) {
|
for (const key of ['tenant_id', 'client_id', 'redirect_uri', 'button_label']) {
|
||||||
@@ -149,6 +168,37 @@ router.put('/', requireRole('admin'), async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Schluessel und Modell einmal ausprobieren. */
|
||||||
|
router.post('/ai/test', requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { ai } = await getSettings();
|
||||||
|
const response = await askModel(ai, {
|
||||||
|
instructions: 'Antworte mit genau einem Wort: OK.',
|
||||||
|
input: [{ role: 'user', content: 'Test' }],
|
||||||
|
maxOutputTokens: 200,
|
||||||
|
effort: 'low',
|
||||||
|
});
|
||||||
|
res.json({ ok: true, model: response.model, answer: answerText(response).slice(0, 200) });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(err.status || 502).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Verfuegbare Modelle fuer das Auswahlfeld. POST statt GET, weil ein gerade
|
||||||
|
* eingetippter, noch nicht gespeicherter Schluessel mitkommen darf - der
|
||||||
|
* gehoert nicht in eine URL. Ohne ihn gilt der hinterlegte.
|
||||||
|
*/
|
||||||
|
router.post('/ai/models', requireRole('admin'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const typed = typeof req.body?.api_key === 'string' ? req.body.api_key.trim().slice(0, 300) : '';
|
||||||
|
const apiKey = typed || (await getSettings()).ai.api_key;
|
||||||
|
res.json({ models: await listModels(apiKey) });
|
||||||
|
} catch (err) {
|
||||||
|
res.status(err.status || 502).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.post('/sso/test', requireRole('admin'), async (req, res) => {
|
router.post('/sso/test', requireRole('admin'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { sso } = await getSettings();
|
const { sso } = await getSettings();
|
||||||
|
|||||||
@@ -164,3 +164,19 @@ CREATE TABLE IF NOT EXISTS pulse_settings (
|
|||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
PRIMARY KEY (k)
|
PRIMARY KEY (k)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) 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;
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import settingsRoutes from './routes/settings.js';
|
|||||||
import directoryRoutes from './routes/directory.js';
|
import directoryRoutes from './routes/directory.js';
|
||||||
import importRoutes from './routes/imports.js';
|
import importRoutes from './routes/imports.js';
|
||||||
import hrworksRoutes from './routes/hrworks.js';
|
import hrworksRoutes from './routes/hrworks.js';
|
||||||
|
import assistantRoutes from './routes/assistant.js';
|
||||||
|
import calendarRoutes from './routes/calendar.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = Number(process.env.PORT || 5010);
|
const PORT = Number(process.env.PORT || 5010);
|
||||||
@@ -49,12 +51,16 @@ app.use('/api/entries', requireAuth, entryRoutes);
|
|||||||
app.use('/api/dashboard', requireAuth, requireRole('lead'), dashboardRoutes);
|
app.use('/api/dashboard', requireAuth, requireRole('lead'), dashboardRoutes);
|
||||||
app.use('/api/reports', requireAuth, requireRole('lead'), reportRoutes);
|
app.use('/api/reports', requireAuth, requireRole('lead'), reportRoutes);
|
||||||
app.use('/api/exports', requireAuth, exportRoutes);
|
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/settings', requireAuth, settingsRoutes);
|
||||||
app.use('/api/directory', requireAuth, requireRole('management'), directoryRoutes);
|
app.use('/api/directory', requireAuth, requireRole('management'), directoryRoutes);
|
||||||
// Uebernahme aus Fremdsystemen - nur Administratoren.
|
// Uebernahme aus Fremdsystemen - nur Administratoren.
|
||||||
app.use('/api/imports', requireAuth, requireRole('admin'), importRoutes);
|
app.use('/api/imports', requireAuth, requireRole('admin'), importRoutes);
|
||||||
// Abwesenheiten aus HRworks - nur Administratoren.
|
// Abwesenheiten aus HRworks - nur Administratoren.
|
||||||
app.use('/api/hrworks', requireAuth, requireRole('admin'), hrworksRoutes);
|
app.use('/api/hrworks', requireAuth, requireRole('admin'), hrworksRoutes);
|
||||||
|
// Der Assistent pruft die Rolle selbst (Management und Administration).
|
||||||
|
app.use('/api/assistant', requireAuth, assistantRoutes);
|
||||||
|
|
||||||
app.use('/api', (req, res) => res.status(404).json({ error: 'Endpunkt nicht gefunden.' }));
|
app.use('/api', (req, res) => res.status(404).json({ error: 'Endpunkt nicht gefunden.' }));
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -66,3 +66,9 @@ test('nur genehmigte Abwesenheiten, bei Krankheit auch offene Bescheinigungen',
|
|||||||
assert.ok(!SICK_STATES.includes('cancelled'));
|
assert.ok(!SICK_STATES.includes('cancelled'));
|
||||||
assert.ok(!ABSENCE_STATES.includes('requested'));
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Dashboard } from './components/dashboard/Dashboard';
|
|||||||
import { Booking } from './components/booking/Booking';
|
import { Booking } from './components/booking/Booking';
|
||||||
import { Projects } from './components/projects/Projects';
|
import { Projects } from './components/projects/Projects';
|
||||||
import { Team } from './components/team/Team';
|
import { Team } from './components/team/Team';
|
||||||
|
import { Calendar } from './components/calendar/Calendar';
|
||||||
import { Customers } from './components/customers/Customers';
|
import { Customers } from './components/customers/Customers';
|
||||||
import { Reports } from './components/reports/Reports';
|
import { Reports } from './components/reports/Reports';
|
||||||
import { Settings } from './components/settings/Settings';
|
import { Settings } from './components/settings/Settings';
|
||||||
@@ -21,6 +22,7 @@ const SUBTITLES: Record<ViewKey, string> = {
|
|||||||
dashboard: 'Gebuchte Zeiten, Auslastung und letzte Buchungen',
|
dashboard: 'Gebuchte Zeiten, Auslastung und letzte Buchungen',
|
||||||
buchung: 'Zeiten erfassen, bearbeiten und exportieren',
|
buchung: 'Zeiten erfassen, bearbeiten und exportieren',
|
||||||
projekte: 'Projekte, Budgettöpfe und Tasks',
|
projekte: 'Projekte, Budgettöpfe und Tasks',
|
||||||
|
kalender: 'Urlaube und Feiertage aller Mitarbeiter',
|
||||||
team: 'Hierarchie, Benutzer und externe Mitarbeiter',
|
team: 'Hierarchie, Benutzer und externe Mitarbeiter',
|
||||||
kunden: 'Kundenstamm, Projekte und Umsatz',
|
kunden: 'Kundenstamm, Projekte und Umsatz',
|
||||||
reports: 'Auswertungen nach Kunde, Projekt, Task und Mitarbeiter',
|
reports: 'Auswertungen nach Kunde, Projekt, Task und Mitarbeiter',
|
||||||
@@ -68,6 +70,7 @@ const App: React.FC = () => {
|
|||||||
{view === 'dashboard' && <Dashboard />}
|
{view === 'dashboard' && <Dashboard />}
|
||||||
{view === 'buchung' && <Booking />}
|
{view === 'buchung' && <Booking />}
|
||||||
{view === 'projekte' && <Projects />}
|
{view === 'projekte' && <Projects />}
|
||||||
|
{view === 'kalender' && <Calendar />}
|
||||||
{view === 'team' && <Team />}
|
{view === 'team' && <Team />}
|
||||||
{view === 'kunden' && <Customers />}
|
{view === 'kunden' && <Customers />}
|
||||||
{view === 'reports' && <Reports />}
|
{view === 'reports' && <Reports />}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { Button, ErrorBox, inputClass } from '../ui/controls';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { assistantApi, type AssistantAnswer } from '../../lib/api';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { atLeast } from '../../types';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Assistent im Kopf: eine Frage in Worten, eine Antwort aus den Daten von
|
||||||
|
* Pulse. Sichtbar nur fuer Management und Administration - die Antworten
|
||||||
|
* fassen Zeiten und Umsaetze ueber alle Mitarbeiter zusammen.
|
||||||
|
*
|
||||||
|
* Der Knopf erscheint erst, wenn in den Einstellungen ein Schluessel steht;
|
||||||
|
* sonst waere er eine Tuer, hinter der nichts ist.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BEISPIELE = [
|
||||||
|
'Wie viele Stunden hat das Team diesen Monat gebucht?',
|
||||||
|
'Welche Projekte haben weniger als 20 Prozent Restbudget?',
|
||||||
|
'Wie ist die Auslastung im September?',
|
||||||
|
'Wie viel Umsatz kam dieses Jahr von Hays?',
|
||||||
|
];
|
||||||
|
|
||||||
|
type Runde = { frage: string; antwort: string; werkzeuge: AssistantAnswer['tools'] };
|
||||||
|
|
||||||
|
export const Assistant: React.FC = () => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const erlaubt = atLeast(user, 'management');
|
||||||
|
const [status, setStatus] = useState<{ available: boolean; model: string } | null>(null);
|
||||||
|
const [offen, setOffen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!erlaubt) return;
|
||||||
|
assistantApi
|
||||||
|
.status()
|
||||||
|
.then((s) => setStatus({ available: s.available, model: s.model }))
|
||||||
|
.catch(() => setStatus(null));
|
||||||
|
}, [erlaubt]);
|
||||||
|
|
||||||
|
if (!erlaubt || !status?.available) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setOffen(true)}
|
||||||
|
title="Fragen zu den Daten stellen"
|
||||||
|
className="flex items-center gap-2 rounded-xl border border-brand-200 bg-brand-50 px-2.5 py-2 text-xs font-bold text-brand-700 transition hover:border-brand-400 hover:bg-brand-100 sm:px-3"
|
||||||
|
>
|
||||||
|
<Icon name="spark" className="h-4 w-4" />
|
||||||
|
<span className="hidden md:inline">Fragen</span>
|
||||||
|
</button>
|
||||||
|
{offen && <AssistantDialog model={status.model} onClose={() => setOffen(false)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AssistantDialog: React.FC<{ model: string; onClose: () => void }> = ({ model, onClose }) => {
|
||||||
|
const [frage, setFrage] = useState('');
|
||||||
|
const [verlauf, setVerlauf] = useState<Runde[]>([]);
|
||||||
|
const [laeuft, setLaeuft] = useState(false);
|
||||||
|
const [fehler, setFehler] = useState<string | null>(null);
|
||||||
|
const ende = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
ende.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||||
|
}, [verlauf, laeuft]);
|
||||||
|
|
||||||
|
const fragen = async (text: string) => {
|
||||||
|
const gestellt = text.trim();
|
||||||
|
if (!gestellt || laeuft) return;
|
||||||
|
setFrage('');
|
||||||
|
setFehler(null);
|
||||||
|
setLaeuft(true);
|
||||||
|
try {
|
||||||
|
const antwort = await assistantApi.ask(
|
||||||
|
gestellt,
|
||||||
|
verlauf.map((r) => ({ frage: r.frage, antwort: r.antwort }))
|
||||||
|
);
|
||||||
|
setVerlauf((v) => [...v, { frage: gestellt, antwort: antwort.answer, werkzeuge: antwort.tools }]);
|
||||||
|
} catch (err) {
|
||||||
|
setFehler(err instanceof Error ? err.message : 'Die Anfrage ist fehlgeschlagen.');
|
||||||
|
setFrage(gestellt);
|
||||||
|
} finally {
|
||||||
|
setLaeuft(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="Fragen an die Daten"
|
||||||
|
subtitle={`Antworten kommen aus Pulse, gerechnet wie in den Reports · Modell ${model}`}
|
||||||
|
size="lg"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<div className="flex w-full items-center gap-2">
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Frage eingeben…"
|
||||||
|
value={frage}
|
||||||
|
onChange={(e) => setFrage(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
void fragen(frage);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => void fragen(frage)} disabled={laeuft || !frage.trim()}>
|
||||||
|
{laeuft ? 'Denkt…' : 'Fragen'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{verlauf.length === 0 && !laeuft && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-ink-500">
|
||||||
|
Frag nach Stunden, Umsatz, Budgets oder Auslastung. Der Assistent liest nur - er ändert nichts.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{BEISPIELE.map((b) => (
|
||||||
|
<button
|
||||||
|
key={b}
|
||||||
|
onClick={() => void fragen(b)}
|
||||||
|
className="rounded-xl border border-ink-200 px-3 py-2 text-left text-xs font-semibold text-ink-600 transition hover:border-brand-300 hover:text-brand-700"
|
||||||
|
>
|
||||||
|
{b}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verlauf.map((runde, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<p className="ml-auto w-fit max-w-[85%] rounded-2xl rounded-br-sm bg-brand-500 px-3.5 py-2 text-sm font-semibold text-white">
|
||||||
|
{runde.frage}
|
||||||
|
</p>
|
||||||
|
<div className="w-fit max-w-[92%] space-y-2 rounded-2xl rounded-bl-sm bg-ink-50 px-3.5 py-2.5">
|
||||||
|
<p className="whitespace-pre-wrap text-sm leading-relaxed text-ink-800">{runde.antwort}</p>
|
||||||
|
{runde.werkzeuge.length > 0 && (
|
||||||
|
<p className="text-[11px] text-ink-400">
|
||||||
|
Grundlage:{' '}
|
||||||
|
{runde.werkzeuge
|
||||||
|
.map((w) => {
|
||||||
|
const args = Object.entries(w.args)
|
||||||
|
.filter(([, v]) => v !== null && v !== undefined && v !== '')
|
||||||
|
.map(([k, v]) => `${k}=${String(v)}`)
|
||||||
|
.join(', ');
|
||||||
|
return args ? `${w.name} (${args})` : w.name;
|
||||||
|
})
|
||||||
|
.join(' · ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{laeuft && (
|
||||||
|
<p className="flex items-center gap-2 text-sm text-ink-400">
|
||||||
|
<Icon name="spark" className="h-4 w-4 animate-pulse" />
|
||||||
|
Sucht die Zahlen zusammen…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{fehler && <ErrorBox message={fehler} />}
|
||||||
|
<div ref={ende} />
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { Avatar, Button, Card, ErrorBox, Segmented, Spinner, inputClass } from '
|
|||||||
import { Icon } from '../ui/Icon';
|
import { Icon } from '../ui/Icon';
|
||||||
import { Donut } from '../ui/charts';
|
import { Donut } from '../ui/charts';
|
||||||
import { EntryModal } from './EntryModal';
|
import { EntryModal } from './EntryModal';
|
||||||
|
import { CopyDialog } from './CopyDialog';
|
||||||
import { ExportDialog } from './ExportDialog';
|
import { ExportDialog } from './ExportDialog';
|
||||||
import { WeekView } from './WeekView';
|
import { WeekView } from './WeekView';
|
||||||
import { MonthView } from './MonthView';
|
import { MonthView } from './MonthView';
|
||||||
@@ -54,6 +55,7 @@ export const Booking: React.FC = () => {
|
|||||||
const [layout, setLayout] = usePersistentState<BookingLayout>('pulse_booking_layout', 'calendar');
|
const [layout, setLayout] = usePersistentState<BookingLayout>('pulse_booking_layout', 'calendar');
|
||||||
const [anchor, setAnchor] = useState(todayIso());
|
const [anchor, setAnchor] = useState(todayIso());
|
||||||
const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null } | null>(null);
|
const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null } | null>(null);
|
||||||
|
const [copyEntry, setCopyEntry] = useState<TimeEntry | null>(null);
|
||||||
const [exportOpen, setExportOpen] = useState(false);
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
const [viewUserId, setViewUserId] = useState<number>(user!.id);
|
const [viewUserId, setViewUserId] = useState<number>(user!.id);
|
||||||
const isLead = atLeast(user, 'lead');
|
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) });
|
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
|
* Im Export stehen nur die eigenen Projekte zur Wahl - auch fuer
|
||||||
* Vorgesetzte. Wer einen Nachweis fuer einen Kunden erstellt, filtert nach
|
* Vorgesetzte. Wer einen Nachweis fuer einen Kunden erstellt, filtert nach
|
||||||
@@ -131,7 +140,7 @@ export const Booking: React.FC = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1500px]">
|
<div className={`mx-auto max-w-[1500px] ${isMobile && !readOnly ? 'pb-16' : ''}`}>
|
||||||
{/* Werkzeugleiste */}
|
{/* Werkzeugleiste */}
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2 sm:gap-3">
|
<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">
|
<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
|
<EntryList
|
||||||
entries={list}
|
entries={list}
|
||||||
onEdit={readOnly ? undefined : (entry) => setModal({ date: entry.entry_date, entry })}
|
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'}.`}
|
emptyText={`Keine Buchungen ${mode === 'week' ? 'in dieser Woche' : mode === 'month' ? 'in diesem Monat' : 'in diesem Jahr'}.`}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
{layout === 'calendar' && mode === 'week' && (
|
{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' && (
|
{layout === 'calendar' && mode === 'month' && (
|
||||||
<MonthView
|
<MonthView
|
||||||
@@ -266,14 +276,29 @@ export const Booking: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
onClick={() => openNew()}
|
onClick={() => openNew()}
|
||||||
aria-label="Zeit buchen"
|
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} />
|
<Icon name="plus" className="h-8 w-8" strokeWidth={2.6} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{modal && projects.data && (
|
{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 && (
|
{exportOpen && (
|
||||||
<ExportDialog
|
<ExportDialog
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -17,8 +17,10 @@ export const EntryList: React.FC<{
|
|||||||
entries: TimeEntry[];
|
entries: TimeEntry[];
|
||||||
showUser?: boolean;
|
showUser?: boolean;
|
||||||
onEdit?: (entry: TimeEntry) => void;
|
onEdit?: (entry: TimeEntry) => void;
|
||||||
|
/** Kopier-Knopf je Zeile: legt die Buchung als neue Buchung an. */
|
||||||
|
onCopy?: (entry: TimeEntry) => void;
|
||||||
emptyText?: string;
|
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 [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: 'entry_date', dir: 1 });
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
@@ -79,7 +81,8 @@ export const EntryList: React.FC<{
|
|||||||
{head('project_display', 'Projekt', 'left', 'w-[24%]')}
|
{head('project_display', 'Projekt', 'left', 'w-[24%]')}
|
||||||
{head('task_name', 'Task', 'left')}
|
{head('task_name', 'Task', 'left')}
|
||||||
{head('billable', 'Billable Zeit', 'right', 'w-[104px]')}
|
{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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -93,7 +96,7 @@ export const EntryList: React.FC<{
|
|||||||
<tr
|
<tr
|
||||||
key={e.id}
|
key={e.id}
|
||||||
onClick={() => onEdit?.(e)}
|
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' : ''
|
first && i > 0 ? 'border-t border-t-ink-200' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{ '--i': Math.min(i, 15) } as React.CSSProperties}
|
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'}`}>
|
<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) : '–'}
|
{bill ? formatHours(bill) : '–'}
|
||||||
</td>
|
</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) : '–'}
|
{non ? formatHours(non) : '–'}
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -148,7 +166,8 @@ export const EntryList: React.FC<{
|
|||||||
</td>
|
</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 + sum.non)} h gesamt</td>
|
||||||
<td className="tabular px-3 py-3 text-right">{formatHours(sum.billable)}</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>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ interface Props {
|
|||||||
entry?: TimeEntry | null;
|
entry?: TimeEntry | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
|
onDuplicate?: (entry: TimeEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reihenfolge wie in der Auswahlliste: nach Kunde gruppiert, innerhalb in API-Reihenfolge. */
|
/** 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
|
* Buchungsdialog: oben Projekt, darunter Task, darunter Freitext mit der Zeit
|
||||||
* rechts daneben. Die Zeit versteht "1:23" und "1,5".
|
* 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 { settings } = useAuth();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const bookable = useMemo(() => projects.filter((p) => p.is_active || p.id === entry?.project_id), [projects, entry]);
|
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={
|
footer={
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
{entry ? (
|
{entry ? (
|
||||||
<Button variant="ghost" icon="trash" className="text-red-600 hover:bg-red-50" onClick={() => setConfirmDelete(true)}>
|
<div className="flex gap-1">
|
||||||
Löschen
|
<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>
|
</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>
|
<span className="hidden text-[11px] text-ink-400 sm:block">Enter im Zeitfeld bucht · sonst Strg + Enter</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { Button, Field, inputClass, Toggle } from '../ui/controls';
|
import { Button, Field, inputClass, Toggle } from '../ui/controls';
|
||||||
import { Icon, type IconName } from '../ui/Icon';
|
import { Icon, type IconName } from '../ui/Icon';
|
||||||
import { useAuth } from '../../lib/auth';
|
import { useAuth } from '../../lib/auth';
|
||||||
import { download, qs } from '../../lib/api';
|
import { download, projectsApi, qs } from '../../lib/api';
|
||||||
import { MONTHS } from '../../lib/format';
|
import { MONTHS } from '../../lib/format';
|
||||||
import { useToast } from '../Toast';
|
import { useToast } from '../Toast';
|
||||||
import { atLeast, type DirectoryUser, type Project } from '../../types';
|
import { atLeast, type DirectoryUser, type Project } from '../../types';
|
||||||
@@ -33,12 +33,55 @@ export const ExportDialog: React.FC<{
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const isLead = atLeast(user, 'lead');
|
const isLead = atLeast(user, 'lead');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Kunde und Projekt gehoeren zu der Person, fuer die der Nachweis entsteht -
|
||||||
|
* sonst baut eine Fuehrungskraft den Export ihres Mitarbeiters mit den
|
||||||
|
* eigenen Projekten. Fuer sich selbst reicht die Liste, die die Buchung
|
||||||
|
* ohnehin geladen hat; fuer andere wird sie nachgeladen.
|
||||||
|
*/
|
||||||
|
const [fremdeProjekte, setFremdeProjekte] = useState<Project[] | null>(null);
|
||||||
|
const [laden, setLaden] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (userId === String(user!.id)) {
|
||||||
|
setFremdeProjekte(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let abgebrochen = false;
|
||||||
|
setLaden(true);
|
||||||
|
// "Alle": die Projekte, die der Fragende ueberhaupt sehen darf.
|
||||||
|
const anfrage = userId === 'all' ? projectsApi.list() : projectsApi.list('mine', Number(userId));
|
||||||
|
anfrage
|
||||||
|
.then((list) => !abgebrochen && setFremdeProjekte(list))
|
||||||
|
.catch(() => !abgebrochen && setFremdeProjekte([]))
|
||||||
|
.finally(() => !abgebrochen && setLaden(false));
|
||||||
|
return () => {
|
||||||
|
abgebrochen = true;
|
||||||
|
};
|
||||||
|
}, [userId, user]);
|
||||||
|
|
||||||
|
const basis = fremdeProjekte ?? projects;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Zur Auswahl stehen nur laufende Projekte aktiver Kunden. Archivierte
|
||||||
|
* Projekte und stillgelegte Kunden wuerden die Liste mit Aufträgen fuellen,
|
||||||
|
* fuer die niemand mehr einen Nachweis schreibt. Ihre Zeiten sind dadurch
|
||||||
|
* nicht verloren: ohne Filter exportiert der Nachweis alles, was die Person
|
||||||
|
* im Monat gebucht hat - und der Projektreport hat einen eigenen Export.
|
||||||
|
*/
|
||||||
|
const waehlbar = useMemo(() => basis.filter((p) => p.is_active && p.customer.is_active !== false), [basis]);
|
||||||
const customers = useMemo(() => {
|
const customers = useMemo(() => {
|
||||||
const map = new Map<number, string>();
|
const map = new Map<number, string>();
|
||||||
projects.forEach((p) => map.set(p.customer.id, p.customer.name));
|
waehlbar.forEach((p) => map.set(p.customer.id, p.customer.name));
|
||||||
return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1], 'de'));
|
return [...map.entries()].sort((a, b) => a[1].localeCompare(b[1], 'de'));
|
||||||
}, [projects]);
|
}, [waehlbar]);
|
||||||
const projectOptions = projects.filter((p) => !customerId || p.customer_id === customerId);
|
|
||||||
|
// Wechselt die Person, passt die alte Auswahl nicht mehr.
|
||||||
|
useEffect(() => {
|
||||||
|
setCustomerId('');
|
||||||
|
setProjectId('');
|
||||||
|
}, [userId]);
|
||||||
|
const projectOptions = waehlbar.filter((p) => !customerId || p.customer_id === customerId);
|
||||||
|
|
||||||
// Monate: 18 zurueck, 2 voraus.
|
// Monate: 18 zurueck, 2 voraus.
|
||||||
const months = useMemo(() => {
|
const months = useMemo(() => {
|
||||||
@@ -129,13 +172,14 @@ export const ExportDialog: React.FC<{
|
|||||||
{t.is_external ? ' (extern)' : ''}
|
{t.is_external ? ' (extern)' : ''}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
<option value="all">Alle meine Mitarbeiter</option>
|
<option value="all">{atLeast(user, 'management') ? 'Alle Mitarbeiter' : 'Alle meine Mitarbeiter'}</option>
|
||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
<Field label="Kunde">
|
<Field label="Kunde" hint={laden ? 'Lädt die Projekte dieser Person…' : undefined}>
|
||||||
<select
|
<select
|
||||||
className={inputClass}
|
className={inputClass}
|
||||||
|
disabled={laden}
|
||||||
value={customerId}
|
value={customerId}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setCustomerId(e.target.value ? Number(e.target.value) : '');
|
setCustomerId(e.target.value ? Number(e.target.value) : '');
|
||||||
@@ -151,7 +195,7 @@ export const ExportDialog: React.FC<{
|
|||||||
</select>
|
</select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Projekt">
|
<Field label="Projekt">
|
||||||
<select className={inputClass} value={projectId} onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : '')}>
|
<select className={inputClass} disabled={laden} value={projectId} onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : '')}>
|
||||||
<option value="">Alle Projekte</option>
|
<option value="">Alle Projekte</option>
|
||||||
{projectOptions.map((p) => (
|
{projectOptions.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
|
|||||||
@@ -11,13 +11,14 @@ interface Props {
|
|||||||
dailyTarget: number;
|
dailyTarget: number;
|
||||||
onAdd: (date: string) => void;
|
onAdd: (date: string) => void;
|
||||||
onEdit: (entry: TimeEntry) => void;
|
onEdit: (entry: TimeEntry) => void;
|
||||||
|
onCopy: (entry: TimeEntry) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wochenansicht wie ein moderner Stundenzettel: sieben Spalten, jede Buchung
|
* Wochenansicht wie ein moderner Stundenzettel: sieben Spalten, jede Buchung
|
||||||
* als Karte mit Kundenfarbe. Auf dem Handy ein Tagesstreifen mit Liste.
|
* 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 isMobile = useIsMobile();
|
||||||
const today = todayIso();
|
const today = todayIso();
|
||||||
const days = Array.from({ length: 7 }, (_, i) => addDays(from, i));
|
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>
|
||||||
<div key={selected} className="space-y-2">
|
<div key={selected} className="space-y-2">
|
||||||
{list.map((entry, i) => (
|
{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 && (
|
{list.length === 0 && (
|
||||||
<button
|
<button
|
||||||
@@ -102,14 +103,12 @@ export const WeekView: React.FC<Props> = ({ from, entries, readOnly, dailyTarget
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-1 flex-col gap-2 p-2">
|
<div className="flex flex-1 flex-col gap-2 p-2">
|
||||||
{list.map((entry, j) => (
|
{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 && (
|
{!readOnly && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onAdd(date)}
|
onClick={() => onAdd(date)}
|
||||||
className={`mt-auto flex items-center justify-center gap-1 rounded-xl border border-dashed border-ink-200 py-2 text-xs font-bold text-ink-400 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700 ${
|
className="mt-auto flex items-center justify-center gap-1 rounded-xl border border-dashed border-ink-200 py-2 text-xs font-bold text-ink-400 transition hover:border-brand-300 hover:bg-brand-50 hover:text-brand-700"
|
||||||
list.length ? 'opacity-0 group-hover:opacity-100 focus:opacity-100' : ''
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<Icon name="plus" className="h-3.5 w-3.5" strokeWidth={2.4} />
|
<Icon name="plus" className="h-3.5 w-3.5" strokeWidth={2.4} />
|
||||||
Buchen
|
Buchen
|
||||||
@@ -133,15 +132,19 @@ 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,
|
entry,
|
||||||
index,
|
index,
|
||||||
onClick,
|
onClick,
|
||||||
|
onCopy,
|
||||||
readOnly,
|
readOnly,
|
||||||
large,
|
large,
|
||||||
}) => {
|
}) => {
|
||||||
const color = colorFor(entry.customer_id, entry.customer_color);
|
const color = colorFor(entry.customer_id, entry.customer_color);
|
||||||
|
const canCopy = !readOnly && !!onCopy;
|
||||||
return (
|
return (
|
||||||
|
// Kopier-Knopf als Geschwister der Karte - ein Button im Button ist kein gueltiges HTML.
|
||||||
|
<div className="group/card relative">
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={readOnly && !large}
|
disabled={readOnly && !large}
|
||||||
@@ -149,16 +152,32 @@ export const EntryCard: React.FC<{ entry: TimeEntry; index: number; onClick: ()
|
|||||||
readOnly ? 'cursor-default' : 'hover:-translate-y-0.5 hover:border-brand-300 hover:shadow-lift'
|
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'}`}
|
} ${large ? 'p-4 pl-5' : 'p-2.5 pl-3.5'}`}
|
||||||
style={{ '--i': index } as React.CSSProperties}
|
style={{ '--i': index } as React.CSSProperties}
|
||||||
title={`${entry.project_display}${entry.task_name ? ` · ${entry.task_name}` : ''}${entry.note ? `\n${entry.note}` : ''}`}
|
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 }} />
|
<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">
|
<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>
|
<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>
|
<span className={`tabular shrink-0 font-extrabold text-ink-800 ${large ? 'text-lg' : 'text-xs'}`}>{formatDuration(entry.minutes)}</span>
|
||||||
</div>
|
</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.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.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>}
|
{!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>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Avatar, Card, EmptyState, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
|
||||||
|
import { Icon } from '../ui/Icon';
|
||||||
|
import { MonthGrid } from './MonthGrid';
|
||||||
|
import { Timeline } from './Timeline';
|
||||||
|
import { calendarApi, type CalendarUser } from '../../lib/api';
|
||||||
|
import { useLoader, usePersistentState } from '../../lib/hooks';
|
||||||
|
import { MONTHS, addDays, addMonths, formatDate, fromIso, mondayOf, monthEnd, monthStart, todayIso } from '../../lib/format';
|
||||||
|
import { subordinates } from '../team/hierarchy';
|
||||||
|
import { buildIndex, holidaysOn, shortNames, userColor, vacationRanges, vacationsOn } from './calendarData';
|
||||||
|
|
||||||
|
type Mode = 'month' | 'timeline';
|
||||||
|
|
||||||
|
/** Tage als "5.10." bzw. "5.–9.10." / "28.9.–2.10." */
|
||||||
|
function rangeLabel(from: string, to: string) {
|
||||||
|
const a = fromIso(from);
|
||||||
|
const b = fromIso(to);
|
||||||
|
if (from === to) return `${a.getDate()}.${a.getMonth() + 1}.`;
|
||||||
|
if (a.getMonth() === b.getMonth()) return `${a.getDate()}.–${b.getDate()}.${b.getMonth() + 1}.`;
|
||||||
|
return `${a.getDate()}.${a.getMonth() + 1}.–${b.getDate()}.${b.getMonth() + 1}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayCount = (n: number) => `${n.toLocaleString('de-DE')} ${n === 1 ? 'Tag' : 'Tage'}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kalender fuer alle: genehmigte Urlaube und Feiertage aus HRworks, auch in
|
||||||
|
* der Zukunft. Monatsansicht als Standard, dazu eine Zeitleiste je Person.
|
||||||
|
*/
|
||||||
|
export const Calendar: React.FC = () => {
|
||||||
|
const today = todayIso();
|
||||||
|
// Bewusst nicht gemerkt: der Kalender oeffnet immer in der Monatsansicht.
|
||||||
|
const [mode, setMode] = useState<Mode>('month');
|
||||||
|
const [month, setMonth] = useState(monthStart(today));
|
||||||
|
const [selected, setSelected] = useState<string | null>(today);
|
||||||
|
const [team, setTeam] = usePersistentState<string>('pulse_calendar_team', 'all');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [onlyAway, setOnlyAway] = usePersistentState('pulse_calendar_only_away', false);
|
||||||
|
|
||||||
|
const gridStart = mondayOf(month);
|
||||||
|
const end = monthEnd(month);
|
||||||
|
const weeks = Math.ceil(((fromIso(end).getTime() - fromIso(gridStart).getTime()) / 86400000 + 1) / 7);
|
||||||
|
const gridEnd = addDays(gridStart, weeks * 7 - 1);
|
||||||
|
|
||||||
|
const data = useLoader(() => calendarApi.get(gridStart, gridEnd), [gridStart, gridEnd]);
|
||||||
|
|
||||||
|
const shift = (delta: number) => {
|
||||||
|
const next = addMonths(month, delta);
|
||||||
|
setMonth(next);
|
||||||
|
setSelected(today.slice(0, 7) === next.slice(0, 7) ? today : null);
|
||||||
|
};
|
||||||
|
const goToday = () => {
|
||||||
|
setMonth(monthStart(today));
|
||||||
|
setSelected(today);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
const target = e.target as HTMLElement | null;
|
||||||
|
if (target && ['INPUT', 'SELECT', 'TEXTAREA'].includes(target.tagName)) return;
|
||||||
|
if (e.key === 'ArrowLeft') shift(-1);
|
||||||
|
if (e.key === 'ArrowRight') shift(1);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
const users = data.data?.users ?? [];
|
||||||
|
|
||||||
|
// Teams: jeder, dem jemand unterstellt ist, samt allen darunter.
|
||||||
|
const leads = useMemo(() => {
|
||||||
|
const ids = new Set(users.map((u) => u.manager_id).filter((id): id is number => id != null));
|
||||||
|
return users.filter((u) => ids.has(u.id)).sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||||
|
}, [users]);
|
||||||
|
|
||||||
|
const visibleUsers = useMemo(() => {
|
||||||
|
let list: CalendarUser[] = users;
|
||||||
|
if (team.startsWith('lead:')) {
|
||||||
|
const leadId = Number(team.slice(5));
|
||||||
|
const lead = users.find((u) => u.id === leadId);
|
||||||
|
// Unbekanntes Team (Vorgesetzter weg): alle zeigen statt einer leeren Seite.
|
||||||
|
if (lead) list = [lead, ...subordinates(users, leadId)];
|
||||||
|
}
|
||||||
|
const q = search.trim().toLowerCase();
|
||||||
|
if (q) list = list.filter((u) => u.name.toLowerCase().includes(q));
|
||||||
|
return [...list].sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||||
|
}, [users, team, search]);
|
||||||
|
|
||||||
|
const index = useMemo(
|
||||||
|
() => buildIndex(data.data?.days ?? [], new Set(visibleUsers.map((u) => u.id))),
|
||||||
|
[data.data, visibleUsers]
|
||||||
|
);
|
||||||
|
const userMap = useMemo(() => new Map(users.map((u) => [u.id, u])), [users]);
|
||||||
|
const names = useMemo(() => shortNames(users), [users]);
|
||||||
|
|
||||||
|
// Wer ist in diesem Monat weg, und wann?
|
||||||
|
const monthly = useMemo(
|
||||||
|
() =>
|
||||||
|
visibleUsers
|
||||||
|
.map((u) => ({ user: u, ranges: vacationRanges(index.byUser.get(u.id), month, end) }))
|
||||||
|
.filter((x) => x.ranges.length > 0),
|
||||||
|
[visibleUsers, index, month, end]
|
||||||
|
);
|
||||||
|
|
||||||
|
const timelineUsers = onlyAway ? visibleUsers.filter((u) => monthly.some((m) => m.user.id === u.id)) : visibleUsers;
|
||||||
|
const selectedAway = selected
|
||||||
|
? vacationsOn(index, selected)
|
||||||
|
.map((d) => ({ entry: d, user: userMap.get(d.user_id)! }))
|
||||||
|
.filter((x) => x.user)
|
||||||
|
.sort((a, b) => a.user.name.localeCompare(b.user.name, 'de'))
|
||||||
|
: [];
|
||||||
|
const selectedHolidays = selected ? holidaysOn(index, selected) : [];
|
||||||
|
const monthHolidays = useMemo(() => {
|
||||||
|
const out: { day: string; labels: string[] }[] = [];
|
||||||
|
for (let d = month; d <= end; d = addDays(d, 1)) {
|
||||||
|
const labels = holidaysOn(index, d);
|
||||||
|
if (labels.length) out.push({ day: d, labels });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [index, month, end]);
|
||||||
|
|
||||||
|
const label = `${MONTHS[fromIso(month).getMonth()]} ${fromIso(month).getFullYear()}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-[1500px]">
|
||||||
|
{/* Werkzeugleiste */}
|
||||||
|
<div className="mb-4 flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
|
<div className="flex items-center rounded-xl border border-ink-200 bg-surface shadow-card">
|
||||||
|
<button onClick={() => shift(-1)} className="rounded-l-xl p-2.5 text-ink-500 hover:bg-ink-50 hover:text-ink-900" aria-label="Vorheriger Monat">
|
||||||
|
<Icon name="chevronLeft" className="h-4 w-4" strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
<button onClick={goToday} className="border-x border-ink-200 px-3 py-2 text-xs font-bold text-ink-600 hover:bg-ink-50">
|
||||||
|
Heute
|
||||||
|
</button>
|
||||||
|
<button onClick={() => shift(1)} className="rounded-r-xl p-2.5 text-ink-500 hover:bg-ink-50 hover:text-ink-900" aria-label="Nächster Monat">
|
||||||
|
<Icon name="chevronRight" className="h-4 w-4" strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<h2 className="tabular min-w-0 flex-1 truncate text-base text-ink-900 sm:flex-none sm:text-lg">{label}</h2>
|
||||||
|
|
||||||
|
<div className="order-last flex w-full flex-wrap items-center gap-2 sm:order-none sm:ml-auto sm:w-auto sm:flex-nowrap">
|
||||||
|
<Segmented
|
||||||
|
value={mode}
|
||||||
|
onChange={setMode}
|
||||||
|
className="flex-1 sm:flex-none"
|
||||||
|
options={[
|
||||||
|
{ value: 'month', label: 'Monat', icon: 'calendar', title: 'Monatsansicht' },
|
||||||
|
{ value: 'timeline', label: 'Zeitleiste', icon: 'list', title: 'Eine Zeile je Mitarbeiter' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className={`${inputClass} w-auto min-w-0 max-w-[200px] flex-1 py-2 text-xs font-bold sm:flex-none`}
|
||||||
|
value={team.startsWith('lead:') && !leads.some((l) => `lead:${l.id}` === team) ? 'all' : team}
|
||||||
|
onChange={(e) => setTeam(e.target.value)}
|
||||||
|
title="Team filtern"
|
||||||
|
>
|
||||||
|
<option value="all">Alle Mitarbeiter</option>
|
||||||
|
{leads.map((l) => (
|
||||||
|
<option key={l.id} value={`lead:${l.id}`}>
|
||||||
|
Team {l.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="relative min-w-0 flex-1 sm:w-44 sm:flex-none">
|
||||||
|
<Icon name="search" className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-ink-400" />
|
||||||
|
<input
|
||||||
|
className={`${inputClass} py-2 pl-8 text-xs`}
|
||||||
|
placeholder="Name suchen"
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
aria-label="Name suchen"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data.error && <ErrorBox message={data.error} onRetry={data.reload} />}
|
||||||
|
{data.loading && !data.data ? (
|
||||||
|
<Spinner />
|
||||||
|
) : data.data && !data.data.last_run && data.data.days.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon="calendar"
|
||||||
|
title="Noch keine Urlaube übernommen"
|
||||||
|
text="Der Kalender füllt sich mit dem nächsten Abgleich aus HRworks (morgens und mittags)."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||||
|
<div key={`${mode}-${month}`} className="animate-fade min-w-0 space-y-3">
|
||||||
|
{mode === 'month' ? (
|
||||||
|
<MonthGrid
|
||||||
|
month={month}
|
||||||
|
gridStart={gridStart}
|
||||||
|
weeks={weeks}
|
||||||
|
index={index}
|
||||||
|
users={userMap}
|
||||||
|
names={names}
|
||||||
|
selected={selected}
|
||||||
|
onSelect={setSelected}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label className="flex w-fit cursor-pointer items-center gap-2 text-xs font-bold text-ink-600">
|
||||||
|
<input type="checkbox" className="accent-[var(--color-brand-500)]" checked={onlyAway} onChange={(e) => setOnlyAway(e.target.checked)} />
|
||||||
|
Nur Mitarbeiter mit Urlaub in diesem Monat
|
||||||
|
</label>
|
||||||
|
{timelineUsers.length ? (
|
||||||
|
<Timeline month={month} index={index} users={timelineUsers} selected={selected} onSelect={setSelected} />
|
||||||
|
) : (
|
||||||
|
<EmptyState icon="calendar" title="Niemand im Urlaub" text="In diesem Monat ist für die Auswahl kein Urlaub eingetragen." />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Legend lastRun={data.data?.last_run ?? null} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-w-0 space-y-4">
|
||||||
|
{selected && (
|
||||||
|
<Card className="animate-rise p-4">
|
||||||
|
<h3 className="text-sm text-ink-900">{formatDate(selected, 'long')}</h3>
|
||||||
|
{selectedHolidays.length > 0 && (
|
||||||
|
<p className="mt-2 inline-flex items-center gap-1.5 rounded-lg bg-amber-50 px-2 py-1 text-xs font-bold text-amber-700">
|
||||||
|
<Icon name="sun" className="h-3.5 w-3.5" />
|
||||||
|
{selectedHolidays.join(' · ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{selectedAway.length ? (
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{selectedAway.map(({ entry, user }) => (
|
||||||
|
<li key={user.id} className="flex items-center gap-2.5">
|
||||||
|
<Avatar id={user.id} name={user.name} color={user.color} size={28} external={user.is_external} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-bold text-ink-800">{user.name}</span>
|
||||||
|
<span className="block text-xs text-ink-400">
|
||||||
|
{entry.label}
|
||||||
|
{entry.half ? ' · halber Tag' : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-xs text-ink-400">Niemand im Urlaub.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card className="animate-rise p-4" style={{ '--i': 1 } as React.CSSProperties}>
|
||||||
|
<h3 className="text-sm text-ink-900">Urlaub im {MONTHS[fromIso(month).getMonth()]}</h3>
|
||||||
|
{monthly.length ? (
|
||||||
|
<ul className="mt-3 space-y-2.5">
|
||||||
|
{monthly.map(({ user, ranges }) => (
|
||||||
|
<li key={user.id} className="flex items-start gap-2.5">
|
||||||
|
<span className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: userColor(user) }} />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-bold text-ink-800">{user.name}</span>
|
||||||
|
<span className="block text-xs text-ink-500">
|
||||||
|
{ranges.map((r) => `${rangeLabel(r.from, r.to)} (${dayCount(r.days)})`).join(', ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="mt-2 text-xs text-ink-400">In diesem Monat ist kein Urlaub eingetragen.</p>
|
||||||
|
)}
|
||||||
|
{monthHolidays.length > 0 && (
|
||||||
|
<div className="mt-4 border-t border-ink-100 pt-3">
|
||||||
|
<p className="mb-1.5 text-[11px] font-extrabold uppercase tracking-wider text-ink-400">Feiertage</p>
|
||||||
|
<ul className="space-y-1 text-xs text-ink-600">
|
||||||
|
{monthHolidays.map((h) => (
|
||||||
|
<li key={h.day}>
|
||||||
|
<b className="tabular text-amber-700">{formatDate(h.day, 'day')}</b> {h.labels.join(' · ')}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Legend: React.FC<{ lastRun: string | null }> = ({ lastRun }) => (
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 px-1 text-[11px] font-semibold text-ink-400">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="h-2.5 w-4 rounded-sm bg-brand-500" /> Urlaub (genehmigt)
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="h-2.5 w-4 rounded-sm bg-brand-500 opacity-50" /> halber Tag
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="h-2.5 w-4 rounded-sm border border-amber-200 bg-amber-50" /> Feiertag
|
||||||
|
</span>
|
||||||
|
{lastRun && <span className="sm:ml-auto">Stand HRworks: {new Date(lastRun).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { WEEKDAYS_SHORT, addDays, fromIso, todayIso } from '../../lib/format';
|
||||||
|
import { useIsMobile } from '../../lib/hooks';
|
||||||
|
import type { CalendarUser } from '../../lib/api';
|
||||||
|
import { holidaysOn, userColor, vacationsOn, type CalendarIndex } from './calendarData';
|
||||||
|
|
||||||
|
/** Monatsraster: je Tag die Abwesenden, Feiertage in Bernstein. */
|
||||||
|
export const MonthGrid: React.FC<{
|
||||||
|
month: string;
|
||||||
|
gridStart: string;
|
||||||
|
weeks: number;
|
||||||
|
index: CalendarIndex;
|
||||||
|
users: Map<number, CalendarUser>;
|
||||||
|
names: Map<number, string>;
|
||||||
|
selected: string | null;
|
||||||
|
onSelect: (day: string) => void;
|
||||||
|
}> = ({ month, gridStart, weeks, index, users, names, selected, onSelect }) => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const today = todayIso();
|
||||||
|
const cells = Array.from({ length: weeks * 7 }, (_, i) => addDays(gridStart, i));
|
||||||
|
const maxChips = isMobile ? 4 : 3;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-ink-200 bg-surface p-2 shadow-card sm:p-3">
|
||||||
|
<div className="mb-1 grid grid-cols-7 gap-1 sm:gap-2">
|
||||||
|
{WEEKDAYS_SHORT.map((d) => (
|
||||||
|
<p key={d} className="py-1 text-center text-[11px] font-extrabold uppercase tracking-wider text-ink-400">
|
||||||
|
{d}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-7 gap-1 sm:gap-2">
|
||||||
|
{cells.map((date, i) => {
|
||||||
|
const inMonth = date.slice(0, 7) === month.slice(0, 7);
|
||||||
|
const weekend = i % 7 >= 5;
|
||||||
|
const holidays = holidaysOn(index, date);
|
||||||
|
const away = vacationsOn(index, date)
|
||||||
|
.map((d) => ({ entry: d, user: users.get(d.user_id) }))
|
||||||
|
.filter((x): x is { entry: typeof x.entry; user: CalendarUser } => Boolean(x.user))
|
||||||
|
.sort((a, b) => a.user.name.localeCompare(b.user.name, 'de'));
|
||||||
|
const isSelected = selected === date;
|
||||||
|
const title = [
|
||||||
|
...holidays,
|
||||||
|
...away.map(({ entry, user }) => `${user.name}${entry.half ? ' (halber Tag)' : ''}`),
|
||||||
|
].join('\n');
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={date}
|
||||||
|
onClick={() => onSelect(date)}
|
||||||
|
title={title || undefined}
|
||||||
|
aria-pressed={isSelected}
|
||||||
|
className={`animate-rise relative flex min-h-[62px] min-w-0 flex-col overflow-hidden rounded-xl border p-1.5 text-left transition hover:border-brand-300 hover:shadow-card sm:min-h-[112px] sm:p-2 ${
|
||||||
|
!inMonth ? 'border-transparent opacity-35' : ''
|
||||||
|
} ${
|
||||||
|
isSelected
|
||||||
|
? 'border-brand-500 ring-2 ring-brand-200'
|
||||||
|
: date === today
|
||||||
|
? 'border-brand-400'
|
||||||
|
: holidays.length
|
||||||
|
? 'border-amber-200 bg-amber-50/70'
|
||||||
|
: weekend
|
||||||
|
? 'border-ink-100 bg-ink-50/60'
|
||||||
|
: 'border-ink-200'
|
||||||
|
}`}
|
||||||
|
style={{ '--i': Math.floor(i / 7) } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<span className="flex items-center justify-between gap-1">
|
||||||
|
<span className={`text-xs font-extrabold sm:text-sm ${date === today ? 'text-brand-700' : holidays.length ? 'text-amber-700' : 'text-ink-700'}`}>
|
||||||
|
{fromIso(date).getDate()}
|
||||||
|
</span>
|
||||||
|
{away.length > 0 && isMobile && <span className="text-[10px] font-extrabold text-ink-500">{away.length}</span>}
|
||||||
|
</span>
|
||||||
|
{holidays.length > 0 && !isMobile && (
|
||||||
|
<span className="mt-0.5 truncate text-[10px] font-bold leading-tight text-amber-700">{holidays.join(' · ')}</span>
|
||||||
|
)}
|
||||||
|
{isMobile ? (
|
||||||
|
away.length > 0 && (
|
||||||
|
<span className="mt-auto flex flex-wrap gap-0.5">
|
||||||
|
{away.slice(0, maxChips).map(({ entry, user }) => (
|
||||||
|
<span
|
||||||
|
key={user.id}
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ background: userColor(user), opacity: entry.half ? 0.5 : 1 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="mt-1 flex min-w-0 flex-col gap-0.5">
|
||||||
|
{away.slice(0, maxChips).map(({ entry, user }) => (
|
||||||
|
<span
|
||||||
|
key={user.id}
|
||||||
|
className="flex min-w-0 items-center gap-1 rounded-md px-1 py-px text-[11px] font-bold text-ink-800"
|
||||||
|
style={{ background: `color-mix(in srgb, ${userColor(user)} 16%, transparent)` }}
|
||||||
|
>
|
||||||
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full" style={{ background: userColor(user) }} />
|
||||||
|
<span className="truncate">{names.get(user.id)}</span>
|
||||||
|
{entry.half && <span className="ml-auto shrink-0 text-[10px] text-ink-500">½</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{away.length > maxChips && <span className="px-1 text-[10px] font-bold text-ink-400">+{away.length - maxChips} weitere</span>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { WEEKDAYS_SHORT, addDays, fromIso, monthEnd, todayIso, weekdayIndex } from '../../lib/format';
|
||||||
|
import { Avatar } from '../ui/controls';
|
||||||
|
import type { CalendarUser } from '../../lib/api';
|
||||||
|
import { holidaysOn, userColor, type CalendarIndex } from './calendarData';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeitleiste: Personen in Zeilen, Tage des Monats in Spalten. Zeigt auf einen
|
||||||
|
* Blick, wo sich Urlaube ueberschneiden. Breiter als das Handy - die Tabelle
|
||||||
|
* scrollt seitlich, die Namensspalte bleibt stehen.
|
||||||
|
*/
|
||||||
|
export const Timeline: React.FC<{
|
||||||
|
month: string;
|
||||||
|
index: CalendarIndex;
|
||||||
|
users: CalendarUser[];
|
||||||
|
selected: string | null;
|
||||||
|
onSelect: (day: string) => void;
|
||||||
|
}> = ({ month, index, users, selected, onSelect }) => {
|
||||||
|
const today = todayIso();
|
||||||
|
const days: string[] = [];
|
||||||
|
for (let d = month; d <= monthEnd(month); d = addDays(d, 1)) days.push(d);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="scrollbar-slim overflow-x-auto rounded-2xl border border-ink-200 bg-surface shadow-card">
|
||||||
|
<table className="w-full border-separate border-spacing-0 text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="sticky left-0 z-20 min-w-[128px] border-b border-ink-200 bg-surface px-3 py-2 text-left text-[11px] font-extrabold uppercase tracking-wider text-ink-400 sm:min-w-[180px]">
|
||||||
|
Mitarbeiter
|
||||||
|
</th>
|
||||||
|
{days.map((day) => {
|
||||||
|
const weekend = weekdayIndex(day) >= 5;
|
||||||
|
const holidays = holidaysOn(index, day);
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
key={day}
|
||||||
|
title={holidays.join(', ') || undefined}
|
||||||
|
onClick={() => onSelect(day)}
|
||||||
|
className={`min-w-[28px] cursor-pointer border-b border-ink-200 px-0 py-1.5 text-center font-extrabold ${
|
||||||
|
day === selected ? 'bg-brand-100 text-brand-700' : holidays.length ? 'bg-amber-50 text-amber-700' : weekend ? 'bg-ink-50 text-ink-400' : 'text-ink-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="block text-[9px] font-bold uppercase text-ink-400">{WEEKDAYS_SHORT[weekdayIndex(day)].slice(0, 2)}</span>
|
||||||
|
<span className={day === today ? 'rounded-full bg-brand-500 px-1 text-white' : ''}>{fromIso(day).getDate()}</span>
|
||||||
|
</th>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{users.map((user) => {
|
||||||
|
const entries = index.byUser.get(user.id);
|
||||||
|
const color = userColor(user);
|
||||||
|
return (
|
||||||
|
<tr key={user.id} className="group">
|
||||||
|
<td className="sticky left-0 z-10 border-b border-ink-100 bg-surface px-3 py-1.5 group-hover:bg-ink-50">
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<Avatar id={user.id} name={user.name} color={user.color} size={22} external={user.is_external} />
|
||||||
|
<span className="max-w-[92px] truncate font-bold text-ink-800 sm:max-w-[140px]">{user.name}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{days.map((day) => {
|
||||||
|
const entry = entries?.get(day);
|
||||||
|
const weekend = weekdayIndex(day) >= 5;
|
||||||
|
const vacation = entry?.kind === 'vacation';
|
||||||
|
// Aneinanderstossende Urlaubstage als ein Balken zeichnen.
|
||||||
|
const prev = entries?.get(addDays(day, -1))?.kind === 'vacation';
|
||||||
|
const next = entries?.get(addDays(day, 1))?.kind === 'vacation';
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={day}
|
||||||
|
title={entry ? `${user.name}: ${entry.label}${entry.half ? ' (halber Tag)' : ''}` : undefined}
|
||||||
|
onClick={() => onSelect(day)}
|
||||||
|
className={`h-9 cursor-pointer border-b border-ink-100 px-0 py-1 ${
|
||||||
|
day === selected ? 'bg-brand-50' : entry?.kind === 'holiday' ? 'bg-amber-50' : weekend ? 'bg-ink-50/70' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{vacation && (
|
||||||
|
<span
|
||||||
|
className={`block h-5 ${prev ? '' : 'ml-0.5 rounded-l-md'} ${next ? '' : 'mr-0.5 rounded-r-md'}`}
|
||||||
|
style={{
|
||||||
|
background: entry.half ? `linear-gradient(to top, ${color} 50%, color-mix(in srgb, ${color} 25%, transparent) 50%)` : color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{entry?.kind === 'holiday' && <span className="mx-auto block h-1.5 w-1.5 rounded-full bg-amber-400" />}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { CalendarDay, CalendarUser } from '../../lib/api';
|
||||||
|
import { addDays, colorFor, weekdayIndex } from '../../lib/format';
|
||||||
|
|
||||||
|
/** Schnellzugriff auf die Kalendereintraege: je Tag und je Person. */
|
||||||
|
export interface CalendarIndex {
|
||||||
|
byDay: Map<string, CalendarDay[]>;
|
||||||
|
byUser: Map<number, Map<string, CalendarDay>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildIndex(days: CalendarDay[], visible: Set<number>): CalendarIndex {
|
||||||
|
const byDay = new Map<string, CalendarDay[]>();
|
||||||
|
const byUser = new Map<number, Map<string, CalendarDay>>();
|
||||||
|
for (const d of days) {
|
||||||
|
if (!visible.has(d.user_id)) continue;
|
||||||
|
if (!byDay.has(d.day)) byDay.set(d.day, []);
|
||||||
|
byDay.get(d.day)!.push(d);
|
||||||
|
if (!byUser.has(d.user_id)) byUser.set(d.user_id, new Map());
|
||||||
|
byUser.get(d.user_id)!.set(d.day, d);
|
||||||
|
}
|
||||||
|
return { byDay, byUser };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Feiertage eines Tages (bei mehreren Standorten koennen es verschiedene sein). */
|
||||||
|
export function holidaysOn(index: CalendarIndex, day: string): string[] {
|
||||||
|
return [...new Set((index.byDay.get(day) ?? []).filter((d) => d.kind === 'holiday').map((d) => d.label))];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vacationsOn(index: CalendarIndex, day: string): CalendarDay[] {
|
||||||
|
return (index.byDay.get(day) ?? []).filter((d) => d.kind === 'vacation');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurzname fuer enge Stellen: der Vorname, bei Doppelten mit Initiale des
|
||||||
|
* Nachnamens ("Luisa S.").
|
||||||
|
*/
|
||||||
|
export function shortNames(users: CalendarUser[]): Map<number, string> {
|
||||||
|
const first = (name: string) => name.trim().split(/\s+/)[0] ?? name;
|
||||||
|
const count = new Map<string, number>();
|
||||||
|
for (const u of users) count.set(first(u.name), (count.get(first(u.name)) ?? 0) + 1);
|
||||||
|
return new Map(
|
||||||
|
users.map((u) => {
|
||||||
|
const parts = u.name.trim().split(/\s+/);
|
||||||
|
const f = first(u.name);
|
||||||
|
return [u.id, (count.get(f) ?? 0) > 1 && parts.length > 1 ? `${f} ${parts[parts.length - 1][0]}.` : f];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userColor = (u: Pick<CalendarUser, 'id' | 'color'>) => colorFor(u.id, u.color);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Urlaubstage einer Person zu Zeitraeumen zusammenfassen. Wochenenden und
|
||||||
|
* Feiertage dazwischen unterbrechen einen Urlaub nicht.
|
||||||
|
*/
|
||||||
|
export function vacationRanges(entries: Map<string, CalendarDay> | undefined, from: string, to: string) {
|
||||||
|
const ranges: { from: string; to: string; days: number; label: string }[] = [];
|
||||||
|
if (!entries) return ranges;
|
||||||
|
let current: (typeof ranges)[number] | null = null;
|
||||||
|
for (let day = from; day <= to; day = addDays(day, 1)) {
|
||||||
|
const entry = entries.get(day);
|
||||||
|
if (entry?.kind === 'vacation') {
|
||||||
|
if (current && current.label === entry.label) {
|
||||||
|
current.to = day;
|
||||||
|
} else {
|
||||||
|
current = { from: day, to: day, days: 0, label: entry.label };
|
||||||
|
ranges.push(current);
|
||||||
|
}
|
||||||
|
current.days += entry.half ? 0.5 : 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Freie Tage ueberbruecken, alles andere beendet den Zeitraum.
|
||||||
|
const free = weekdayIndex(day) >= 5 || entry?.kind === 'holiday';
|
||||||
|
if (!free) current = null;
|
||||||
|
}
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { Button, Field, Toggle, inputClass } from '../ui/controls';
|
||||||
|
import { Icon, type IconName } from '../ui/Icon';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { download, qs, type CustomerOverview } from '../../lib/api';
|
||||||
|
import { MONTHS, formatHours } from '../../lib/format';
|
||||||
|
import { atLeast } from '../../types';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
|
||||||
|
const FORMATS: { value: 'pdf' | 'xlsx' | 'csv'; label: string; hint: string; icon: IconName }[] = [
|
||||||
|
{ value: 'pdf', label: 'PDF', hint: 'Nachweis mit Logo, Kopf und Fuß', icon: 'document' },
|
||||||
|
{ value: 'xlsx', label: 'Excel', hint: 'Zum Weiterrechnen, mit Summenformeln', icon: 'grid' },
|
||||||
|
{ value: 'csv', label: 'CSV', hint: 'Rohdaten, Semikolon, UTF-8', icon: 'list' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kundennachweis: alle Buchungen eines Kunden in einem Monat, ueber alle
|
||||||
|
* Berater hinweg. Anders als der Nachweis aus der Buchung geht es hier nicht um
|
||||||
|
* eine Person, sondern um den Auftrag - das Dokument zeigt deshalb je Zeile,
|
||||||
|
* wer gebucht hat, und fasst am Ende nach Berater und nach Projekt zusammen.
|
||||||
|
*/
|
||||||
|
export const CustomerExportDialog: React.FC<{ customer: CustomerOverview; onClose: () => void }> = ({ customer, onClose }) => {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7));
|
||||||
|
const [format, setFormat] = useState<'pdf' | 'xlsx' | 'csv'>('pdf');
|
||||||
|
const [amounts, setAmounts] = useState(false);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
// Leere Auswahl = alle Projekte des Kunden.
|
||||||
|
const [gewaehlt, setGewaehlt] = useState<number[]>([]);
|
||||||
|
|
||||||
|
const projekte = useMemo(
|
||||||
|
() => [...customer.projects].sort((a, b) => Number(b.is_active) - Number(a.is_active) || a.display_name.localeCompare(b.display_name, 'de')),
|
||||||
|
[customer.projects]
|
||||||
|
);
|
||||||
|
|
||||||
|
const months = useMemo(() => {
|
||||||
|
const list: string[] = [];
|
||||||
|
const now = new Date();
|
||||||
|
for (let i = -2; i < 18; i++) {
|
||||||
|
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||||
|
list.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const umschalten = (id: number) =>
|
||||||
|
setGewaehlt((v) => (v.includes(id) ? v.filter((x) => x !== id) : [...v, id]));
|
||||||
|
|
||||||
|
const run = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await download(
|
||||||
|
`/exports/timesheet${qs({
|
||||||
|
month,
|
||||||
|
format,
|
||||||
|
user_id: 'all',
|
||||||
|
customer_id: customer.id,
|
||||||
|
project_ids: gewaehlt.length ? gewaehlt.join(',') : undefined,
|
||||||
|
amounts: amounts ? 1 : undefined,
|
||||||
|
})}`,
|
||||||
|
`${customer.short_name}-${month}.${format}`
|
||||||
|
);
|
||||||
|
addToast('Export erstellt.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Export fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`Kundennachweis – ${customer.name}`}
|
||||||
|
subtitle="Alle Buchungen des Monats über alle Berater"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
<Button icon="download" busy={busy} onClick={run}>
|
||||||
|
Exportieren
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{FORMATS.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f.value}
|
||||||
|
onClick={() => setFormat(f.value)}
|
||||||
|
className={`flex flex-col items-center gap-1 rounded-2xl border p-3 text-center transition ${
|
||||||
|
format === f.value ? 'border-brand-400 bg-brand-50 text-brand-700 shadow-card' : 'border-ink-200 text-ink-600 hover:border-brand-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon name={f.icon} className="h-6 w-6" />
|
||||||
|
<span className="text-sm font-extrabold">{f.label}</span>
|
||||||
|
<span className="hidden text-[10px] leading-tight text-ink-400 sm:block">{f.hint}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Monat">
|
||||||
|
<select className={inputClass} value={month} onChange={(e) => setMonth(e.target.value)}>
|
||||||
|
{months.map((m) => (
|
||||||
|
<option key={m} value={m}>
|
||||||
|
{MONTHS[Number(m.slice(5)) - 1]} {m.slice(0, 4)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Projekte"
|
||||||
|
hint={gewaehlt.length ? `${gewaehlt.length} von ${projekte.length} ausgewählt` : 'Ohne Auswahl sind alle Projekte des Kunden enthalten'}
|
||||||
|
>
|
||||||
|
<div className="scrollbar-slim max-h-56 space-y-1 overflow-y-auto rounded-xl border border-ink-200 p-2">
|
||||||
|
{projekte.length === 0 && <p className="px-2 py-3 text-sm text-ink-400">Für diesen Kunden gibt es noch kein Projekt.</p>}
|
||||||
|
{projekte.map((p) => (
|
||||||
|
<label
|
||||||
|
key={p.id}
|
||||||
|
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition hover:bg-ink-50"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="h-4 w-4 accent-[var(--color-brand-500)]"
|
||||||
|
checked={gewaehlt.includes(p.id)}
|
||||||
|
onChange={() => umschalten(p.id)}
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-semibold text-ink-800">{p.display_name}</span>
|
||||||
|
{!p.is_active && <span className="shrink-0 text-[11px] text-ink-400">archiviert</span>}
|
||||||
|
<span className="tabular shrink-0 text-xs text-ink-400">{formatHours(p.used_minutes)} h</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{gewaehlt.length > 0 && (
|
||||||
|
<button onClick={() => setGewaehlt([])} className="text-xs font-bold text-brand-700 hover:underline">
|
||||||
|
Auswahl aufheben – alle Projekte nehmen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{atLeast(user, 'lead') && (
|
||||||
|
<Toggle
|
||||||
|
checked={amounts}
|
||||||
|
onChange={setAmounts}
|
||||||
|
label="Stundensätze und Beträge ausweisen"
|
||||||
|
hint="Für die Rechnungsstellung. Im Nachweis an den Kunden meist ausgeschaltet."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ import { useGrow, useLoader, usePersistentState } from '../../lib/hooks';
|
|||||||
import { MONTHS, colorFor, compactEuro, formatEuro, formatHours } from '../../lib/format';
|
import { MONTHS, colorFor, compactEuro, formatEuro, formatHours } from '../../lib/format';
|
||||||
import { useRouter } from '../../lib/router';
|
import { useRouter } from '../../lib/router';
|
||||||
import { useToast } from '../Toast';
|
import { useToast } from '../Toast';
|
||||||
|
import { CustomerExportDialog } from './CustomerExportDialog';
|
||||||
import { TYPE_TONE } from '../projects/Projects';
|
import { TYPE_TONE } from '../projects/Projects';
|
||||||
import { PROJECT_TYPE_LABELS, type Customer } from '../../types';
|
import { PROJECT_TYPE_LABELS, type Customer } from '../../types';
|
||||||
|
|
||||||
@@ -17,16 +18,17 @@ export const Customers: React.FC = () => {
|
|||||||
const { navigate } = useRouter();
|
const { navigate } = useRouter();
|
||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [status, setStatus] = useState<'active' | 'all'>('active');
|
const [status, setStatus] = useState<'active' | 'inactive' | 'all'>('active');
|
||||||
const [form, setForm] = useState<{ customer: Customer | null } | null>(null);
|
const [form, setForm] = useState<{ customer: Customer | null } | null>(null);
|
||||||
const [remove, setRemove] = useState<CustomerOverview | null>(null);
|
const [remove, setRemove] = useState<CustomerOverview | null>(null);
|
||||||
const [expanded, setExpanded] = useState<number | null>(null);
|
const [expanded, setExpanded] = useState<number | null>(null);
|
||||||
|
const [exportFor, setExportFor] = useState<CustomerOverview | null>(null);
|
||||||
const [layout, setLayout] = usePersistentState<'cards' | 'list'>('pulse_customers_layout', 'cards');
|
const [layout, setLayout] = usePersistentState<'cards' | 'list'>('pulse_customers_layout', 'cards');
|
||||||
|
|
||||||
const customers = useMemo(() => {
|
const customers = useMemo(() => {
|
||||||
const q = search.trim().toLowerCase();
|
const q = search.trim().toLowerCase();
|
||||||
return (data?.customers ?? [])
|
return (data?.customers ?? [])
|
||||||
.filter((c) => (status === 'all' || c.is_active) && (!q || `${c.name} ${c.short_name} ${c.city ?? ''}`.toLowerCase().includes(q)))
|
.filter((c) => (status === 'all' || (status === 'inactive' ? !c.is_active : c.is_active)) && (!q || `${c.name} ${c.short_name} ${c.city ?? ''}`.toLowerCase().includes(q)))
|
||||||
.sort((a, b) => b.earned + b.open - (a.earned + a.open) || a.name.localeCompare(b.name, 'de'));
|
.sort((a, b) => b.earned + b.open - (a.earned + a.open) || a.name.localeCompare(b.name, 'de'));
|
||||||
}, [data, search, status]);
|
}, [data, search, status]);
|
||||||
|
|
||||||
@@ -74,7 +76,16 @@ export const Customers: React.FC = () => {
|
|||||||
<Icon name="search" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-400" />
|
<Icon name="search" className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-ink-400" />
|
||||||
<input className={`${inputClass} pl-9`} placeholder="Kunde suchen…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
<input className={`${inputClass} pl-9`} placeholder="Kunde suchen…" value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<Segmented size="sm" value={status} onChange={setStatus} options={[{ value: 'active', label: 'Aktiv' }, { value: 'all', label: 'Alle' }]} />
|
<Segmented
|
||||||
|
size="sm"
|
||||||
|
value={status}
|
||||||
|
onChange={setStatus}
|
||||||
|
options={[
|
||||||
|
{ value: 'active', label: 'Aktiv' },
|
||||||
|
{ value: 'inactive', label: 'Inaktiv', title: 'Kunden ohne laufende Zusammenarbeit' },
|
||||||
|
{ value: 'all', label: 'Alle' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
<Segmented
|
<Segmented
|
||||||
size="sm"
|
size="sm"
|
||||||
value={layout}
|
value={layout}
|
||||||
@@ -98,6 +109,7 @@ export const Customers: React.FC = () => {
|
|||||||
onEdit={(c) => setForm({ customer: c })}
|
onEdit={(c) => setForm({ customer: c })}
|
||||||
onDelete={setRemove}
|
onDelete={setRemove}
|
||||||
onReport={(id) => navigate(`reports/kunde/${id}`)}
|
onReport={(id) => navigate(`reports/kunde/${id}`)}
|
||||||
|
onExport={setExportFor}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -113,12 +125,14 @@ export const Customers: React.FC = () => {
|
|||||||
onEdit={() => setForm({ customer: c })}
|
onEdit={() => setForm({ customer: c })}
|
||||||
onDelete={() => setRemove(c)}
|
onDelete={() => setRemove(c)}
|
||||||
onReport={() => navigate(`reports/kunde/${c.id}`)}
|
onReport={() => navigate(`reports/kunde/${c.id}`)}
|
||||||
|
onExport={() => setExportFor(c)}
|
||||||
onProject={(id) => navigate(`projekte/${id}`)}
|
onProject={(id) => navigate(`projekte/${id}`)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{exportFor && <CustomerExportDialog customer={exportFor} onClose={() => setExportFor(null)} />}
|
||||||
{form && <CustomerForm customer={form.customer} onClose={() => setForm(null)} onSaved={reload} />}
|
{form && <CustomerForm customer={form.customer} onClose={() => setForm(null)} onSaved={reload} />}
|
||||||
{remove && (
|
{remove && (
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -214,10 +228,11 @@ const CustomerCard: React.FC<{
|
|||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
onExport: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onReport: () => void;
|
onReport: () => void;
|
||||||
onProject: (id: number) => void;
|
onProject: (id: number) => void;
|
||||||
}> = ({ customer: c, months, index, expanded, onToggle, onEdit, onDelete, onReport, onProject }) => {
|
}> = ({ customer: c, months, index, expanded, onToggle, onEdit, onExport, onDelete, onReport, onProject }) => {
|
||||||
const color = colorFor(c.id, c.color);
|
const color = colorFor(c.id, c.color);
|
||||||
const total = c.earned + c.open;
|
const total = c.earned + c.open;
|
||||||
const percent = total ? (c.earned / total) * 100 : 0;
|
const percent = total ? (c.earned / total) * 100 : 0;
|
||||||
@@ -239,6 +254,7 @@ const CustomerCard: React.FC<{
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0">
|
<div className="flex shrink-0">
|
||||||
|
<IconButton icon="download" label="Kundennachweis exportieren" onClick={onExport} />
|
||||||
<IconButton icon="chart" label="Kundenreport" onClick={onReport} />
|
<IconButton icon="chart" label="Kundenreport" onClick={onReport} />
|
||||||
<IconButton icon="edit" label="Bearbeiten" onClick={onEdit} />
|
<IconButton icon="edit" label="Bearbeiten" onClick={onEdit} />
|
||||||
{c.projects.length === 0 && <IconButton icon="trash" tone="danger" label="Löschen" onClick={onDelete} />}
|
{c.projects.length === 0 && <IconButton icon="trash" tone="danger" label="Löschen" onClick={onDelete} />}
|
||||||
@@ -346,9 +362,10 @@ type CustomerSortKey = 'name' | 'city' | 'projects' | 'used_minutes' | 'earned'
|
|||||||
const CustomerTable: React.FC<{
|
const CustomerTable: React.FC<{
|
||||||
customers: CustomerOverview[];
|
customers: CustomerOverview[];
|
||||||
onEdit: (customer: CustomerOverview) => void;
|
onEdit: (customer: CustomerOverview) => void;
|
||||||
|
onExport: (customer: CustomerOverview) => void;
|
||||||
onDelete: (customer: CustomerOverview) => void;
|
onDelete: (customer: CustomerOverview) => void;
|
||||||
onReport: (id: number) => void;
|
onReport: (id: number) => void;
|
||||||
}> = ({ customers, onEdit, onDelete, onReport }) => {
|
}> = ({ customers, onEdit, onExport, onDelete, onReport }) => {
|
||||||
const [sort, setSort] = useState<{ key: CustomerSortKey; dir: 1 | -1 }>({ key: 'earned', dir: -1 });
|
const [sort, setSort] = useState<{ key: CustomerSortKey; dir: 1 | -1 }>({ key: 'earned', dir: -1 });
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
@@ -439,6 +456,7 @@ const CustomerTable: React.FC<{
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-2.5 pl-3 pr-4">
|
<td className="py-2.5 pl-3 pr-4">
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
|
<IconButton icon="download" label="Kundennachweis exportieren" onClick={() => onExport(c)} />
|
||||||
<IconButton icon="chart" label="Kundenreport" onClick={() => onReport(c.id)} />
|
<IconButton icon="chart" label="Kundenreport" onClick={() => onReport(c.id)} />
|
||||||
<IconButton icon="edit" label="Bearbeiten" onClick={() => onEdit(c)} />
|
<IconButton icon="edit" label="Bearbeiten" onClick={() => onEdit(c)} />
|
||||||
{c.projects.length === 0 && <IconButton icon="trash" tone="danger" label="Löschen" onClick={() => onDelete(c)} />}
|
{c.projects.length === 0 && <IconButton icon="trash" tone="danger" label="Löschen" onClick={() => onDelete(c)} />}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useTheme } from '../../lib/theme';
|
|||||||
import { THEME_PALETTES, THEME_PALETTE_LABELS } from '../../types';
|
import { THEME_PALETTES, THEME_PALETTE_LABELS } from '../../types';
|
||||||
import { Icon } from '../ui/Icon';
|
import { Icon } from '../ui/Icon';
|
||||||
import { PulseMark } from './PulseMark';
|
import { PulseMark } from './PulseMark';
|
||||||
|
import { Assistant } from '../assistant/Assistant';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -37,6 +38,8 @@ export const Header: React.FC<HeaderProps> = ({ title, subtitle, onToggleSidebar
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{/* Nur Management und Administration - zeigt Zahlen ueber alle Mitarbeiter. */}
|
||||||
|
<Assistant />
|
||||||
<label className="sr-only" htmlFor="theme-palette">
|
<label className="sr-only" htmlFor="theme-palette">
|
||||||
Design
|
Design
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const VIEW_ICONS: Record<ViewKey, IconName> = {
|
|||||||
dashboard: 'dashboard',
|
dashboard: 'dashboard',
|
||||||
buchung: 'clock',
|
buchung: 'clock',
|
||||||
projekte: 'folder',
|
projekte: 'folder',
|
||||||
|
kalender: 'calendar',
|
||||||
team: 'team',
|
team: 'team',
|
||||||
kunden: 'building',
|
kunden: 'building',
|
||||||
reports: 'chart',
|
reports: 'chart',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { Badge, BudgetBar, Card, EmptyState, ErrorBox, Spinner, inputClass } from '../ui/controls';
|
import { Badge, BudgetBar, Card, EmptyState, ErrorBox, Segmented, Spinner, inputClass } from '../ui/controls';
|
||||||
import { Icon } from '../ui/Icon';
|
import { Icon } from '../ui/Icon';
|
||||||
import { BILLABLE, CountUp, Legend, NON_BILLABLE, StackedBars } from '../ui/charts';
|
import { BILLABLE, CountUp, Legend, NON_BILLABLE, StackedBars } from '../ui/charts';
|
||||||
import { ReportTable } from './ReportTable';
|
import { ReportTable } from './ReportTable';
|
||||||
@@ -7,8 +7,9 @@ import { ExportMenu } from './ExportMenu';
|
|||||||
import { ReportKpi, ReportRow } from './ProjectReport';
|
import { ReportKpi, ReportRow } from './ProjectReport';
|
||||||
import { useAuth } from '../../lib/auth';
|
import { useAuth } from '../../lib/auth';
|
||||||
import { customersApi, qs, reportsApi, type CustomerReport as CustomerReportData } from '../../lib/api';
|
import { customersApi, qs, reportsApi, type CustomerReport as CustomerReportData } from '../../lib/api';
|
||||||
import { useLoader } from '../../lib/hooks';
|
import { useLoader, usePersistentState } from '../../lib/hooks';
|
||||||
import { MONTHS, colorFor, formatEuro, formatHours, formatNumber, todayIso } from '../../lib/format';
|
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 { useRouter } from '../../lib/router';
|
||||||
import { TYPE_TONE } from '../projects/Projects';
|
import { TYPE_TONE } from '../projects/Projects';
|
||||||
import { PROJECT_TYPE_LABELS } from '../../types';
|
import { PROJECT_TYPE_LABELS } from '../../types';
|
||||||
@@ -16,18 +17,48 @@ import { PROJECT_TYPE_LABELS } from '../../types';
|
|||||||
export const CustomerReport: React.FC<{ id: number | null }> = ({ id }) => {
|
export const CustomerReport: React.FC<{ id: number | null }> = ({ id }) => {
|
||||||
const { navigate } = useRouter();
|
const { navigate } = useRouter();
|
||||||
const customers = useLoader(() => customersApi.list(), []);
|
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
|
||||||
|
// ausblenden wuerde - sonst springt die Auswahl beim Umschalten weg.
|
||||||
|
const auswahl = (customers.data ?? []).filter(
|
||||||
|
(c) => c.id === id || status === 'all' || (status === 'inactive' ? !c.is_active : c.is_active)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<select className={`${inputClass} !w-auto min-w-[260px] max-w-full`} value={id ?? ''} onChange={(e) => navigate(e.target.value ? `reports/kunde/${e.target.value}` : 'reports/kunde')}>
|
<select className={`${inputClass} !w-auto min-w-[260px] max-w-full`} value={id ?? ''} onChange={(e) => navigate(e.target.value ? `reports/kunde/${e.target.value}` : 'reports/kunde')}>
|
||||||
<option value="">Kunde wählen…</option>
|
<option value="">Kunde wählen…</option>
|
||||||
{(customers.data ?? []).map((c) => (
|
{auswahl.map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{c.name}
|
{c.name}
|
||||||
|
{c.is_active ? '' : ' (inaktiv)'}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<Segmented
|
||||||
|
size="sm"
|
||||||
|
value={status}
|
||||||
|
onChange={setStatus}
|
||||||
|
options={[
|
||||||
|
{ value: 'active', label: 'Aktiv' },
|
||||||
|
{ value: 'inactive', label: 'Inaktiv', title: 'Kunden ohne laufende Zusammenarbeit' },
|
||||||
|
{ value: 'all', label: 'Alle' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<PeriodPicker period={period} onPeriod={setPeriod} anchor={anchor} onAnchor={setAnchor} custom={custom} onCustom={setCustom} allLabel="Gesamt" />
|
||||||
|
</div>
|
||||||
{!id ? (
|
{!id ? (
|
||||||
<EmptyState icon="building" title="Kunde auswählen" text="Alle Projekte eines Kunden mit Budget, Umsatz, Verlauf und Team." />
|
<EmptyState icon="building" title="Kunde auswählen" text="Alle Projekte eines Kunden mit Budget, Umsatz, Verlauf und Team." />
|
||||||
) : report.error ? (
|
) : report.error ? (
|
||||||
@@ -35,13 +66,15 @@ export const CustomerReport: React.FC<{ id: number | null }> = ({ id }) => {
|
|||||||
) : !report.data ? (
|
) : !report.data ? (
|
||||||
<Spinner label="Lade Kundenreport…" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
|
const Body: React.FC<{ data: CustomerReportData; rangeLabel: string | null }> = ({ data, rangeLabel }) => {
|
||||||
const { settings } = useAuth();
|
const { settings } = useAuth();
|
||||||
const { navigate } = useRouter();
|
const { navigate } = useRouter();
|
||||||
const [tab, setTab] = useState<'projects' | 'team' | 'tasks'>('projects');
|
const [tab, setTab] = useState<'projects' | 'team' | 'tasks'>('projects');
|
||||||
@@ -49,14 +82,26 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
|
|||||||
const c = data.customer;
|
const c = data.customer;
|
||||||
const t = data.totals;
|
const t = data.totals;
|
||||||
const color = colorFor(c.id, c.color);
|
const color = colorFor(c.id, c.color);
|
||||||
const month = todayIso().slice(0, 7);
|
const ranged = Boolean(data.range.from && data.range.to);
|
||||||
const series = useMemo(() => data.series.slice(-months), [data.series, months]);
|
// 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 = [
|
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: '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: '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: `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 (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: `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 (
|
return (
|
||||||
@@ -68,6 +113,7 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h2 className="truncate text-2xl text-ink-900 sm:text-3xl">{c.name}</h2>
|
<h2 className="truncate text-2xl text-ink-900 sm:text-3xl">{c.name}</h2>
|
||||||
<p className="text-sm text-ink-500">
|
<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
|
{data.projects.length} Projekte · {data.team.length} Mitarbeiter
|
||||||
{c.contact_person && ` · Ansprechpartner: ${c.contact_person}`}
|
{c.contact_person && ` · Ansprechpartner: ${c.contact_person}`}
|
||||||
</p>
|
</p>
|
||||||
@@ -81,10 +127,17 @@ const Body: React.FC<{ data: CustomerReportData }> = ({ data }) => {
|
|||||||
<ReportRow label="Billable" value={formatHours(t.billable_minutes)} />
|
<ReportRow label="Billable" value={formatHours(t.billable_minutes)} />
|
||||||
<ReportRow label="Non-billable" value={formatHours(t.non_billable_minutes)} />
|
<ReportRow label="Non-billable" value={formatHours(t.non_billable_minutes)} />
|
||||||
</ReportKpi>
|
</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">
|
<ReportKpi index={2} title="Umsatz erbracht">
|
||||||
<CountUp value={t.earned} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
<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)} />
|
<ReportRow label="davon aus Buchungen" value={formatEuro(t.amount, 2)} />
|
||||||
</ReportKpi>
|
</ReportKpi>
|
||||||
|
)}
|
||||||
<ReportKpi index={3} title="Umsatz offen">
|
<ReportKpi index={3} title="Umsatz offen">
|
||||||
<CountUp value={t.open} format={(v) => formatEuro(v, 2)} className="text-3xl font-extrabold text-ink-900" />
|
<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)}`} />
|
<ReportRow label="in laufenden Töpfen" value={`${data.projects.reduce((s, p) => s + p.project.pots.filter((x) => x.status === 'active').length, 0)}`} />
|
||||||
@@ -98,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}>
|
<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 className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||||
<div>
|
<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' }]} />
|
<Legend className="mt-1" items={[{ color: BILLABLE, label: 'Billable' }, { color: NON_BILLABLE, label: 'Non-billable' }]} />
|
||||||
</div>
|
</div>
|
||||||
|
{!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))}>
|
<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={6}>6 Monate</option>
|
||||||
<option value={12}>12 Monate</option>
|
<option value={12}>12 Monate</option>
|
||||||
<option value={24}>24 Monate</option>
|
<option value={24}>24 Monate</option>
|
||||||
</select>
|
</select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{series.length ? (
|
{series.some((m) => m.minutes > 0) ? (
|
||||||
<StackedBars
|
<StackedBars
|
||||||
scale={60}
|
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`}
|
format={(v) => `${formatHours(v, 1)} h`}
|
||||||
axisFormat={(v) => formatNumber(v / 60, 0)}
|
axisFormat={(v) => formatNumber(v / 60, 0)}
|
||||||
height={220}
|
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>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,15 +52,15 @@ export const ReportTable: React.FC<{
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="scrollbar-slim overflow-x-auto">
|
<div className="scrollbar-slim overflow-x-auto">
|
||||||
<table className="w-full min-w-[720px] text-sm">
|
<table className="w-full min-w-[900px] table-fixed text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-ink-200 text-[11px] text-ink-400">
|
<tr className="border-b border-ink-200 text-[11px] text-ink-400">
|
||||||
{head('name', label, 'left', 'pl-4')}
|
{head('name', label, 'left', 'pl-4')}
|
||||||
{head('minutes', 'Stunden')}
|
{head('minutes', 'Stunden', 'right', 'w-[108px]')}
|
||||||
<th className="w-[22%] px-3 py-3" />
|
<th className="w-[20%] px-3 py-3" />
|
||||||
{showUtil && head('utilization', 'Auslastung')}
|
{showUtil && head('utilization', 'Auslastung', 'right', 'w-[116px]')}
|
||||||
{head('billable_minutes', 'Billable Std.')}
|
{head('billable_minutes', 'Billable Std.', 'right', 'w-[160px]')}
|
||||||
{showAmounts && head('amount', 'Billable Betrag', 'right', 'pr-4')}
|
{showAmounts && head('amount', 'Billable Betrag', 'right', 'w-[150px] pr-4')}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -82,12 +82,12 @@ export const ReportTable: React.FC<{
|
|||||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: colorFor(r.customer_id ?? r.project_id ?? r.name, r.color) }} />
|
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: colorFor(r.customer_id ?? r.project_id ?? r.name, r.color) }} />
|
||||||
) : null}
|
) : null}
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className={`block truncate font-bold ${clickable ? 'text-ink-900 underline decoration-ink-200 underline-offset-4 group-hover:decoration-brand-400' : 'text-ink-800'}`}>{r.name}</span>
|
<span className={`block break-words font-bold ${clickable ? 'text-ink-900 underline decoration-ink-200 underline-offset-4 group-hover:decoration-brand-400' : 'text-ink-800'}`}>{r.name}</span>
|
||||||
{r.customer_name && <span className="block truncate text-[11px] text-ink-400">{r.customer_name}{r.code ? ` · ${r.code}` : ''}</span>}
|
{r.customer_name && <span className="block break-words text-[11px] text-ink-400">{r.customer_name}{r.code ? ` · ${r.code}` : ''}</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="tabular px-3 py-3 text-right font-extrabold text-ink-900">{formatHours(r.minutes)}</td>
|
<td className="tabular whitespace-nowrap px-3 py-3 text-right font-extrabold text-ink-900">{formatHours(r.minutes)}</td>
|
||||||
<td className="px-3 py-3">
|
<td className="px-3 py-3">
|
||||||
<SplitBar a={r.billable_minutes} b={r.non_billable_minutes} max={max} delay={i * 30} />
|
<SplitBar a={r.billable_minutes} b={r.non_billable_minutes} max={max} delay={i * 30} />
|
||||||
</td>
|
</td>
|
||||||
@@ -96,10 +96,10 @@ export const ReportTable: React.FC<{
|
|||||||
{r.utilization == null ? '–' : `${r.utilization} %`}
|
{r.utilization == null ? '–' : `${r.utilization} %`}
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="tabular px-3 py-3 text-right text-ink-700">
|
<td className="tabular whitespace-nowrap px-3 py-3 text-right text-ink-700">
|
||||||
{formatHours(r.billable_minutes)} <span className="inline-block w-14 whitespace-nowrap text-ink-400">({share} %)</span>
|
{formatHours(r.billable_minutes)} <span className="inline-block w-14 whitespace-nowrap text-ink-400">({share} %)</span>
|
||||||
</td>
|
</td>
|
||||||
{showAmounts && <td className="tabular py-3 pl-3 pr-4 text-right font-extrabold text-ink-900">{formatEuro(r.amount, 2)}</td>}
|
{showAmounts && <td className="tabular whitespace-nowrap py-3 pl-3 pr-4 text-right font-extrabold text-ink-900">{formatEuro(r.amount, 2)}</td>}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,38 +1,22 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Avatar, Card, ErrorBox, IconButton, Spinner, inputClass } from '../ui/controls';
|
import { Avatar, Card, ErrorBox, IconButton, Spinner, inputClass } from '../ui/controls';
|
||||||
import { Modal } from '../ui/Modal';
|
import { Modal } from '../ui/Modal';
|
||||||
import { Icon } from '../ui/Icon';
|
|
||||||
import { CountUp, Donut, BILLABLE, NON_BILLABLE } from '../ui/charts';
|
import { CountUp, Donut, BILLABLE, NON_BILLABLE } from '../ui/charts';
|
||||||
import { ReportTable } from './ReportTable';
|
import { ReportTable } from './ReportTable';
|
||||||
import { EntryList } from '../booking/EntryList';
|
import { EntryList } from '../booking/EntryList';
|
||||||
import { ExportMenu } from './ExportMenu';
|
import { ExportMenu } from './ExportMenu';
|
||||||
import { CustomerReport } from './CustomerReport';
|
import { CustomerReport } from './CustomerReport';
|
||||||
import { ProjectReport } from './ProjectReport';
|
import { ProjectReport } from './ProjectReport';
|
||||||
|
import { PeriodPicker, periodRange, type Period } from './PeriodPicker';
|
||||||
import { useAuth } from '../../lib/auth';
|
import { useAuth } from '../../lib/auth';
|
||||||
import { customersApi, entriesApi, qs, reportsApi, usersApi, type GroupRow } from '../../lib/api';
|
import { customersApi, entriesApi, qs, reportsApi, usersApi, type GroupRow } from '../../lib/api';
|
||||||
import { useLoader, usePersistentState } from '../../lib/hooks';
|
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 { useRouter } from '../../lib/router';
|
||||||
import { atLeast, type Customer, type DirectoryUser } from '../../types';
|
import { atLeast, type Customer, type DirectoryUser } from '../../types';
|
||||||
|
|
||||||
type Period = 'week' | 'month' | 'quarter' | 'year' | 'custom';
|
|
||||||
type Tab = 'clients' | 'projects' | 'tasks' | 'users';
|
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 = () => {
|
export const Reports: React.FC = () => {
|
||||||
const { route, navigate } = useRouter();
|
const { route, navigate } = useRouter();
|
||||||
const [kind, id] = route.params;
|
const [kind, id] = route.params;
|
||||||
@@ -75,6 +59,9 @@ const TimeReport: React.FC = () => {
|
|||||||
const [customerId, setCustomerId] = useState<number | ''>('');
|
const [customerId, setCustomerId] = useState<number | ''>('');
|
||||||
const [team, setTeam] = useState<number | ''>('');
|
const [team, setTeam] = useState<number | ''>('');
|
||||||
const [includeFixed, setIncludeFixed] = useState(false);
|
const [includeFixed, setIncludeFixed] = useState(false);
|
||||||
|
// Blendet interne Projekte und Abwesenheiten aus - der Report zeigt dann
|
||||||
|
// nur das Kundengeschaeft, inklusive Auslastung und Export.
|
||||||
|
const [hideNonBillable, setHideNonBillable] = useState(false);
|
||||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||||
const [leads, setLeads] = useState<DirectoryUser[]>([]);
|
const [leads, setLeads] = useState<DirectoryUser[]>([]);
|
||||||
// Klick auf einen Mitarbeiter oeffnet seine Buchungen des Zeitraums als Liste.
|
// Klick auf einen Mitarbeiter oeffnet seine Buchungen des Zeitraums als Liste.
|
||||||
@@ -90,18 +77,19 @@ const TimeReport: React.FC = () => {
|
|||||||
|
|
||||||
const range = periodRange(period, anchor, custom);
|
const range = periodRange(period, anchor, custom);
|
||||||
const report = useLoader(
|
const report = useLoader(
|
||||||
() => reportsApi.time({ from: range.from, to: range.to, customer_id: customerId || null, team: team || null, include_fixed: includeFixed }),
|
() =>
|
||||||
[range.from, range.to, customerId, team, includeFixed]
|
reportsApi.time({
|
||||||
|
from: range.from,
|
||||||
|
to: range.to,
|
||||||
|
customer_id: customerId || null,
|
||||||
|
team: team || null,
|
||||||
|
include_fixed: includeFixed,
|
||||||
|
hide_non_billable: hideNonBillable,
|
||||||
|
}),
|
||||||
|
[range.from, range.to, customerId, team, includeFixed, hideNonBillable]
|
||||||
);
|
);
|
||||||
|
|
||||||
const shift = (step: number) => {
|
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 };
|
||||||
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 };
|
|
||||||
const groupName = { clients: 'Kunden', projects: 'Projekte', tasks: 'Tasks', users: 'Mitarbeiter' }[tab];
|
const groupName = { clients: 'Kunden', projects: 'Projekte', tasks: 'Tasks', users: 'Mitarbeiter' }[tab];
|
||||||
const exportOptions = useMemo(
|
const exportOptions = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -113,7 +101,7 @@ const TimeReport: React.FC = () => {
|
|||||||
{ label: 'CSV – alle Buchungen', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'csv' })}`, filename: 'Zeitreport-Detail.csv' },
|
{ label: 'CSV – alle Buchungen', path: `/exports/report${qs({ ...exportParams, group: 'detail', format: 'csv' })}`, filename: 'Zeitreport-Detail.csv' },
|
||||||
],
|
],
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[tab, range.from, range.to, customerId, team, includeFixed]
|
[tab, range.from, range.to, customerId, team, includeFixed, hideNonBillable]
|
||||||
);
|
);
|
||||||
|
|
||||||
const data = report.data;
|
const data = report.data;
|
||||||
@@ -136,39 +124,25 @@ const TimeReport: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
<select className={`${inputClass} !w-auto max-w-[220px]`} value={customerId} onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : '')}>
|
<select className={`${inputClass} !w-auto max-w-[220px]`} value={customerId} onChange={(e) => setCustomerId(e.target.value ? Number(e.target.value) : '')}>
|
||||||
<option value="">Alle Kunden</option>
|
<option value="">Alle Kunden</option>
|
||||||
{customers.map((c) => (
|
{/* Stillgelegte Kunden stehen weiter zur Wahl - die Historie bleibt ja. */}
|
||||||
|
<optgroup label="Aktiv">
|
||||||
|
{customers.filter((c) => c.is_active).map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{c.name}
|
{c.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</optgroup>
|
||||||
<select className={`${inputClass} !w-auto`} value={period} onChange={(e) => setPeriod(e.target.value as Period)}>
|
{customers.some((c) => !c.is_active) && (
|
||||||
<option value="week">Woche</option>
|
<optgroup label="Inaktiv">
|
||||||
<option value="month">Monat</option>
|
{customers.filter((c) => !c.is_active).map((c) => (
|
||||||
<option value="quarter">Quartal</option>
|
<option key={c.id} value={c.id}>
|
||||||
<option value="year">Jahr</option>
|
{c.name}
|
||||||
<option value="custom">Zeitraum</option>
|
</option>
|
||||||
</select>
|
))}
|
||||||
{period === 'custom' ? (
|
</optgroup>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
</select>
|
||||||
|
<PeriodPicker period={period} onPeriod={setPeriod} anchor={anchor} onAnchor={setAnchor} custom={custom} onCustom={setCustom} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{report.error && <ErrorBox message={report.error} onRetry={report.reload} />}
|
{report.error && <ErrorBox message={report.error} onRetry={report.reload} />}
|
||||||
@@ -176,13 +150,13 @@ const TimeReport: React.FC = () => {
|
|||||||
<Spinner label="Lade Report…" />
|
<Spinner label="Lade Report…" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Card className={`animate-rise grid gap-6 p-5 transition-opacity sm:p-6 lg:grid-cols-[1fr_1.2fr_1.3fr_1fr] ${report.loading ? 'opacity-60' : ''}`}>
|
<Card className={`animate-rise grid gap-6 p-5 transition-opacity sm:grid-cols-2 sm:p-6 xl:grid-cols-[1fr_1.2fr_1.3fr_1fr] ${report.loading ? 'opacity-60' : ''}`}>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-semibold text-ink-500">Gesamtstunden</p>
|
<p className="text-sm font-semibold text-ink-500">Gesamtstunden</p>
|
||||||
<CountUp value={s!.minutes / 60} format={(v) => v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} className="text-4xl font-extrabold text-ink-900" />
|
<CountUp value={s!.minutes / 60} format={(v) => v.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} className="text-4xl font-extrabold text-ink-900" />
|
||||||
<p className="mt-1 text-xs text-ink-400">{s!.entries} Buchungen</p>
|
<p className="mt-1 text-xs text-ink-400">{s!.entries} Buchungen</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex min-w-0 items-center gap-4">
|
||||||
<Donut percent={share} size={96} stroke={13} />
|
<Donut percent={share} size={96} stroke={13} />
|
||||||
<div className="flex-1 space-y-1.5 text-sm">
|
<div className="flex-1 space-y-1.5 text-sm">
|
||||||
<p className="flex items-center justify-between gap-3">
|
<p className="flex items-center justify-between gap-3">
|
||||||
@@ -197,9 +171,18 @@ const TimeReport: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
<b className="tabular text-ink-900">{formatHours(s!.non_billable_minutes)}</b>
|
<b className="tabular text-ink-900">{formatHours(s!.non_billable_minutes)}</b>
|
||||||
</p>
|
</p>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2 pt-1 text-xs font-semibold text-ink-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideNonBillable}
|
||||||
|
onChange={(e) => setHideNonBillable(e.target.checked)}
|
||||||
|
className="h-4 w-4 accent-[var(--color-brand-500)]"
|
||||||
|
/>
|
||||||
|
Nicht abrechenbare Projekte ausblenden
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-semibold text-ink-500">Billable Betrag</p>
|
<p className="text-sm font-semibold text-ink-500">Billable Betrag</p>
|
||||||
<CountUp value={s!.amount} format={(v) => formatEuro(v, 2)} className="text-4xl font-extrabold text-ink-900" />
|
<CountUp value={s!.amount} format={(v) => formatEuro(v, 2)} className="text-4xl font-extrabold text-ink-900" />
|
||||||
<label className="mt-2 flex cursor-pointer items-center gap-2 text-sm font-semibold text-ink-700">
|
<label className="mt-2 flex cursor-pointer items-center gap-2 text-sm font-semibold text-ink-700">
|
||||||
@@ -210,7 +193,7 @@ const TimeReport: React.FC = () => {
|
|||||||
Festpreis-Beträge werden über den virtuellen Stundensatz (Budget ÷ gebuchte Stunden) verteilt – nur für den internen Gebrauch.
|
Festpreis-Beträge werden über den virtuellen Stundensatz (Budget ÷ gebuchte Stunden) verteilt – nur für den internen Gebrauch.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-semibold text-ink-500">Auslastung</p>
|
<p className="text-sm font-semibold text-ink-500">Auslastung</p>
|
||||||
<CountUp value={capacity ? (s!.minutes_until_today / capacity) * 100 : 0} format={(v) => `${Math.round(v)} %`} className="text-4xl font-extrabold text-ink-900" />
|
<CountUp value={capacity ? (s!.minutes_until_today / capacity) * 100 : 0} format={(v) => `${Math.round(v)} %`} className="text-4xl font-extrabold text-ink-900" />
|
||||||
<p className="mt-1 text-xs text-ink-400">Kapazität {formatHours(capacity, 0)} h bis heute</p>
|
<p className="mt-1 text-xs text-ink-400">Kapazität {formatHours(capacity, 0)} h bis heute</p>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import { useToast } from '../Toast';
|
|||||||
* Abwesenheiten aus HRworks.
|
* Abwesenheiten aus HRworks.
|
||||||
*
|
*
|
||||||
* Urlaub, Krankheit, Elternzeit und Feiertage stehen in HRworks; Pulse holt sie
|
* 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
|
* stehen die Zugangsdaten, die Zuordnung der Arten auf Tasks und der Knopf fuer
|
||||||
* einen Probelauf.
|
* einen Probelauf.
|
||||||
*/
|
*/
|
||||||
@@ -44,10 +45,12 @@ export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () =>
|
|||||||
setReport(r);
|
setReport(r);
|
||||||
addToast(
|
addToast(
|
||||||
was === 'apply'
|
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.`,
|
: `Probelauf: ${r.planned_create} neu, ${r.planned_update} geändert, ${r.planned_delete} zu entfernen.`,
|
||||||
'success'
|
'success'
|
||||||
);
|
);
|
||||||
|
if (r.calendar_error) addToast(`Kalender: ${r.calendar_error}`, 'error');
|
||||||
hrworksApi.status().then(setStatus).catch(() => undefined);
|
hrworksApi.status().then(setStatus).catch(() => undefined);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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" />
|
<Icon name={settings.hrworks.enabled ? 'check' : 'info'} className="mt-0.5 h-5 w-5 shrink-0" />
|
||||||
<span>
|
<span>
|
||||||
{settings.hrworks.enabled
|
{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
|
: settings.hrworks.configured
|
||||||
? 'Zugangsdaten hinterlegt, der Abgleich ist noch nicht eingeschaltet.'
|
? 'Zugangsdaten hinterlegt, der Abgleich ist noch nicht eingeschaltet.'
|
||||||
: 'Noch keine Zugangsdaten. Schlüsselpaar in HRworks unter Grundlagen → Integrationen → HRworks-API erzeugen.'}
|
: '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">
|
<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{' '}
|
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
|
<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>
|
</p>
|
||||||
<div className="grid gap-2 sm:grid-cols-2">
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
{eintraege.map(([key, value]) => (
|
{eintraege.map(([key, value]) => (
|
||||||
@@ -159,6 +163,25 @@ export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () =>
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => set({ type_map: { ...form.type_map, [key]: e.target.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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -193,6 +216,12 @@ export const HrworksSettings: React.FC<{ settings: AdminSettings; onSaved: () =>
|
|||||||
: ''}
|
: ''}
|
||||||
</p>
|
</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>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,17 +5,18 @@ import { Team } from '../team/Team';
|
|||||||
import { SsoDirectory } from './SsoDirectory';
|
import { SsoDirectory } from './SsoDirectory';
|
||||||
import { HrworksSettings } from './HrworksSettings';
|
import { HrworksSettings } from './HrworksSettings';
|
||||||
import { useAuth } from '../../lib/auth';
|
import { useAuth } from '../../lib/auth';
|
||||||
import { download, settingsApi, type AdminSettings } from '../../lib/api';
|
import { download, settingsApi, type AdminSettings, type AiModel } from '../../lib/api';
|
||||||
import { useLoader } from '../../lib/hooks';
|
import { useLoader } from '../../lib/hooks';
|
||||||
import { useRouter } from '../../lib/router';
|
import { useRouter } from '../../lib/router';
|
||||||
import { useToast } from '../Toast';
|
import { useToast } from '../Toast';
|
||||||
|
|
||||||
type Tab = 'allgemein' | 'export' | 'sso' | 'sso-verwaltung' | 'hrworks' | 'benutzer';
|
type Tab = 'allgemein' | 'export' | 'sso' | 'sso-verwaltung' | 'hrworks' | 'ki' | 'benutzer';
|
||||||
const TABS: { key: Tab; label: string; icon: IconName; adminOnly: boolean }[] = [
|
const TABS: { key: Tab; label: string; icon: IconName; adminOnly: boolean }[] = [
|
||||||
{ key: 'allgemein', label: 'Allgemein', icon: 'settings', adminOnly: true },
|
{ key: 'allgemein', label: 'Allgemein', icon: 'settings', adminOnly: true },
|
||||||
{ key: 'export', label: 'Export: Kopf & Fuß', icon: 'document', adminOnly: true },
|
{ key: 'export', label: 'Export: Kopf & Fuß', icon: 'document', adminOnly: true },
|
||||||
{ key: 'sso', label: 'SSO / Entra ID', icon: 'key', adminOnly: true },
|
{ key: 'sso', label: 'SSO / Entra ID', icon: 'key', adminOnly: true },
|
||||||
{ key: 'hrworks', label: 'Abwesenheiten (HRworks)', icon: 'calendar', adminOnly: true },
|
{ key: 'hrworks', label: 'Abwesenheiten (HRworks)', icon: 'calendar', adminOnly: true },
|
||||||
|
{ key: 'ki', label: 'KI-Assistent', icon: 'spark', adminOnly: true },
|
||||||
// Management sieht in den Einstellungen nur diesen Reiter.
|
// Management sieht in den Einstellungen nur diesen Reiter.
|
||||||
{ key: 'sso-verwaltung', label: 'SSO-Verwaltung', icon: 'globe', adminOnly: false },
|
{ key: 'sso-verwaltung', label: 'SSO-Verwaltung', icon: 'globe', adminOnly: false },
|
||||||
{ key: 'benutzer', label: 'Benutzerverwaltung', icon: 'team', adminOnly: true },
|
{ key: 'benutzer', label: 'Benutzerverwaltung', icon: 'team', adminOnly: true },
|
||||||
@@ -60,6 +61,7 @@ export const Settings: React.FC = () => {
|
|||||||
{tab === 'export' && <ExportSettings settings={data} onSaved={(s) => setData(s)} />}
|
{tab === 'export' && <ExportSettings settings={data} onSaved={(s) => setData(s)} />}
|
||||||
{tab === 'sso' && <SsoSettings settings={data} onSaved={reload} />}
|
{tab === 'sso' && <SsoSettings settings={data} onSaved={reload} />}
|
||||||
{tab === 'hrworks' && <HrworksSettings settings={data} onSaved={reload} />}
|
{tab === 'hrworks' && <HrworksSettings settings={data} onSaved={reload} />}
|
||||||
|
{tab === 'ki' && <AiSettings settings={data} onSaved={reload} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -438,3 +440,146 @@ const SsoSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> =
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* KI-Assistent: Schluessel und Modell. Der Schluessel wird verschluesselt
|
||||||
|
* abgelegt und nie wieder ausgeliefert - wie das SSO-Secret.
|
||||||
|
*/
|
||||||
|
const AiSettings: React.FC<{ settings: AdminSettings; onSaved: () => void }> = ({ settings, onSaved }) => {
|
||||||
|
const [ai, setAi] = useState({ ...settings.ai, api_key: '' });
|
||||||
|
const { busy, save } = useSaver();
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const set = (patch: Partial<typeof ai>) => setAi({ ...ai, ...patch });
|
||||||
|
|
||||||
|
useEffect(() => setAi({ ...settings.ai, api_key: '' }), [settings.ai]);
|
||||||
|
|
||||||
|
// Auswahl kommt live von OpenAI - welche Modelle es gibt, haengt am Schluessel.
|
||||||
|
const [models, setModels] = useState<AiModel[]>([]);
|
||||||
|
const [modelsBusy, setModelsBusy] = useState(false);
|
||||||
|
const [modelsError, setModelsError] = useState('');
|
||||||
|
const loadModels = async (apiKey?: string) => {
|
||||||
|
setModelsBusy(true);
|
||||||
|
setModelsError('');
|
||||||
|
try {
|
||||||
|
setModels((await settingsApi.aiModels(apiKey)).models);
|
||||||
|
} catch (err) {
|
||||||
|
setModelsError(err instanceof Error ? err.message : 'Modelle konnten nicht geladen werden.');
|
||||||
|
} finally {
|
||||||
|
setModelsBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings.ai.key_set) loadModels();
|
||||||
|
}, [settings.ai.key_set]);
|
||||||
|
|
||||||
|
// Das eingestellte Modell bleibt waehlbar, auch wenn OpenAI es nicht (mehr) listet.
|
||||||
|
const modelOptions = models.some((m) => m.id === ai.model) || !ai.model ? models : [{ id: ai.model, created: null }, ...models];
|
||||||
|
|
||||||
|
const test = async () => {
|
||||||
|
setTesting(true);
|
||||||
|
try {
|
||||||
|
const r = await settingsApi.aiTest();
|
||||||
|
addToast(`Verbindung ok – ${r.model} antwortet: ${r.answer || 'OK'}`, 'success');
|
||||||
|
} catch (err) {
|
||||||
|
addToast(err instanceof Error ? err.message : 'Test fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Section
|
||||||
|
title="KI-Assistent"
|
||||||
|
hint="Fragen zu Kunden, Projekten, Budgets und Auslastung – im Kopf der Anwendung, für Management und Administration"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" icon="refresh" busy={testing} onClick={test} disabled={!settings.ai.key_set && !ai.api_key}>
|
||||||
|
Verbindung testen
|
||||||
|
</Button>
|
||||||
|
<Button icon="check" busy={busy} onClick={() => save({ ai: { ...ai, api_key: ai.api_key || undefined } }, onSaved)}>
|
||||||
|
Speichern
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={`flex items-center gap-3 rounded-xl px-4 py-3 text-sm font-semibold ${settings.ai.enabled ? 'bg-brand-50 text-brand-800' : 'bg-ink-50 text-ink-600'}`}>
|
||||||
|
<Icon name={settings.ai.enabled ? 'check' : 'info'} className="h-5 w-5" />
|
||||||
|
{settings.ai.enabled
|
||||||
|
? 'Der Assistent ist aktiv – Management und Administration sehen oben rechts „Fragen“.'
|
||||||
|
: settings.ai.key_set
|
||||||
|
? 'Schlüssel hinterlegt, der Assistent ist noch nicht eingeschaltet.'
|
||||||
|
: 'Noch kein Schlüssel hinterlegt.'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="OpenAI API-Schlüssel"
|
||||||
|
hint={settings.ai.key_set ? 'Hinterlegt (verschlüsselt). Leer lassen = unverändert.' : 'Wird verschlüsselt gespeichert und nie wieder angezeigt.'}
|
||||||
|
>
|
||||||
|
{/* Kein type="password" - Passwortmanager sollen hier nicht anspringen. */}
|
||||||
|
<input
|
||||||
|
name="pulse-openai-credential"
|
||||||
|
autoComplete="off"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
className={`${inputClass} font-mono text-xs`}
|
||||||
|
style={{ WebkitTextSecurity: 'disc' } as React.CSSProperties}
|
||||||
|
value={ai.api_key}
|
||||||
|
onChange={(e) => set({ api_key: e.target.value })}
|
||||||
|
placeholder={settings.ai.key_set ? '••••••••••••' : 'sk-…'}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Modell"
|
||||||
|
hint={
|
||||||
|
modelsError ||
|
||||||
|
(models.length
|
||||||
|
? `${models.length} Modelle für diesen Schlüssel, neueste zuerst. Voreingestellt gpt-5.6-luna; ein anderes Modell muss Werkzeuge über die Responses-API unterstützen.`
|
||||||
|
: 'Die Liste kommt von OpenAI, sobald ein Schlüssel hinterlegt oder eingegeben ist.')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
className={`${inputClass} font-mono text-xs`}
|
||||||
|
value={ai.model}
|
||||||
|
onChange={(e) => set({ model: e.target.value })}
|
||||||
|
disabled={modelsBusy}
|
||||||
|
>
|
||||||
|
{modelOptions.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.id}
|
||||||
|
{m.created ? ` (${m.created})` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
icon="refresh"
|
||||||
|
busy={modelsBusy}
|
||||||
|
onClick={() => loadModels(ai.api_key)}
|
||||||
|
disabled={!settings.ai.key_set && !ai.api_key}
|
||||||
|
>
|
||||||
|
Liste laden
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Toggle
|
||||||
|
checked={ai.enabled}
|
||||||
|
onChange={(v) => set({ enabled: v })}
|
||||||
|
label="Assistent einschalten"
|
||||||
|
hint="Zeigt den Knopf „Fragen“ im Kopf – nur für Management und Administration."
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="rounded-xl border border-ink-200 bg-ink-50/60 px-4 py-3 text-xs leading-relaxed text-ink-500">
|
||||||
|
Jede Frage geht zusammen mit den dafür nötigen Zahlen aus Pulse an OpenAI. Übertragen werden Kunden-,
|
||||||
|
Projekt- und Mitarbeiternamen sowie Stunden und Beträge – keine Zugangsdaten. OpenAI speichert die Anfragen
|
||||||
|
nicht (die Schnittstelle wird mit „store: false“ aufgerufen). Der Assistent liest nur und ändert nichts.
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ type ImportRole = Exclude<Role, 'admin'>;
|
|||||||
|
|
||||||
const ZONES: { role: ImportRole; hint: string; icon: IconName; tone: string }[] = [
|
const ZONES: { role: ImportRole; hint: string; icon: IconName; tone: string }[] = [
|
||||||
{ role: 'user', hint: 'Bucht Zeiten auf zugewiesene Projekte', icon: 'user', tone: 'border-ink-300 bg-ink-50 text-ink-700' },
|
{ role: 'user', hint: 'Bucht Zeiten auf zugewiesene Projekte', icon: 'user', tone: 'border-ink-300 bg-ink-50 text-ink-700' },
|
||||||
|
// Gleiche Rechte wie Mitarbeiter; die Rolle trennt nur, wer zum Haus gehoert.
|
||||||
|
{ role: 'external', hint: 'Wie Mitarbeiter, gilt als externe Kraft', icon: 'globe', tone: 'border-amber-200 bg-amber-50 text-amber-700' },
|
||||||
{ role: 'lead', hint: 'Sieht und verwaltet sein Team', icon: 'team', tone: 'border-sky-200 bg-sky-50 text-sky-700' },
|
{ role: 'lead', hint: 'Sieht und verwaltet sein Team', icon: 'team', tone: 'border-sky-200 bg-sky-50 text-sky-700' },
|
||||||
{ role: 'management', hint: 'Sieht alles, verwaltet SSO', icon: 'shield', tone: 'border-violet-200 bg-violet-50 text-violet-700' },
|
{ role: 'management', hint: 'Sieht alles, verwaltet SSO', icon: 'shield', tone: 'border-violet-200 bg-violet-50 text-violet-700' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ const PATHS = {
|
|||||||
user: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
|
user: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
|
||||||
key: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
|
key: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z',
|
||||||
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
|
search: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z',
|
||||||
|
spark: 'M12 3l1.9 4.6L18.5 9.5l-4.6 1.9L12 16l-1.9-4.6L5.5 9.5l4.6-1.9L12 3zM18 15l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9L18 15z',
|
||||||
tag: 'M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z',
|
tag: 'M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z',
|
||||||
euro: 'M14.121 15.536c-1.171 1.952-3.07 1.952-4.242 0-1.172-1.953-1.172-5.119 0-7.072 1.171-1.952 3.07-1.952 4.242 0M8 10.5h4m-4 3h4m9-1.5a9 9 0 11-18 0 9 9 0 0118 0z',
|
euro: 'M14.121 15.536c-1.171 1.952-3.07 1.952-4.242 0-1.172-1.953-1.172-5.119 0-7.072 1.171-1.952 3.07-1.952 4.242 0M8 10.5h4m-4 3h4m9-1.5a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||||
pot: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4',
|
pot: 'M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4',
|
||||||
|
|||||||
+111
@@ -997,6 +997,117 @@ html.theme-aura.dark {
|
|||||||
--shadow-lift: 0 0 0 1px rgb(170 178 240 / 0.12), 0 14px 36px rgb(0 0 0 / 0.45);
|
--shadow-lift: 0 0 0 1px rgb(170 178 240 / 0.12), 0 14px 36px rgb(0 0 0 / 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===========================================================================
|
||||||
|
* Mobile
|
||||||
|
*
|
||||||
|
* Kein eigenes Motiv, sondern mehr Inhalt auf dem Handybildschirm. Farben und
|
||||||
|
* Gruen bleiben perinco; anders ist die Dichte.
|
||||||
|
*
|
||||||
|
* Tailwind v4 rechnet jeden Abstand, jede feste Hoehe und Breite aus
|
||||||
|
* --spacing (p-4 = 4 * --spacing). Den Grundwert von 0.25rem auf 0.21rem zu
|
||||||
|
* setzen, zieht also die ganze Oberflaeche um rund 16 % zusammen, ohne dass
|
||||||
|
* eine Komponente angefasst wird. Weiter nicht: ein h-10-Knopf hat dann noch
|
||||||
|
* knapp 34 px und laesst sich mit dem Daumen sicher treffen.
|
||||||
|
*
|
||||||
|
* Verkleinert werden nur die grossen Schriften (lg bis 5xl), also Ueberschriften
|
||||||
|
* und Kennzahlen. text-sm und text-base bleiben: darunter wird es auf dem Handy
|
||||||
|
* schwer lesbar, und iOS zoomt beim Tippen in jedes Feld unter 16 px.
|
||||||
|
*
|
||||||
|
* Hell ist reines Weiss auf hellgrauem Grund, dunkel echtes Schwarz - auf
|
||||||
|
* OLED-Displays schont das den Akku. Schatten fast flach: auf kleiner Flaeche
|
||||||
|
* trennen Rahmen ruhiger als Tiefe.
|
||||||
|
* =========================================================================== */
|
||||||
|
html.theme-mobile {
|
||||||
|
--spacing: 0.21rem;
|
||||||
|
--app-bar-height: 3.5rem;
|
||||||
|
|
||||||
|
--text-lg: 1.0625rem;
|
||||||
|
--text-xl: 1.1875rem;
|
||||||
|
--text-2xl: 1.375rem;
|
||||||
|
--text-3xl: 1.625rem;
|
||||||
|
--text-4xl: 2rem;
|
||||||
|
--text-5xl: 2.5rem;
|
||||||
|
|
||||||
|
--radius-sm: 0.2rem;
|
||||||
|
--radius-md: 0.3rem;
|
||||||
|
--radius-lg: 0.45rem;
|
||||||
|
--radius-xl: 0.6rem;
|
||||||
|
--radius-2xl: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-mobile:not(.dark) {
|
||||||
|
--color-canvas: #f2f2f2;
|
||||||
|
--color-surface: #ffffff;
|
||||||
|
|
||||||
|
--shadow-card: 0 1px 2px rgb(31 31 31 / 0.05);
|
||||||
|
--shadow-lift: 0 6px 18px rgb(31 31 31 / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-mobile.dark {
|
||||||
|
--color-canvas: #000000;
|
||||||
|
--color-surface: #121212;
|
||||||
|
|
||||||
|
--color-ink-50: #1a1a1a;
|
||||||
|
--color-ink-100: #242424;
|
||||||
|
--color-ink-200: #333333;
|
||||||
|
|
||||||
|
--shadow-card: 0 0 0 1px rgb(255 255 255 / 0.04);
|
||||||
|
--shadow-lift: 0 0 0 1px rgb(255 255 255 / 0.08), 0 10px 28px rgb(0 0 0 / 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Segmentierte Umschalter (Segmented in components/ui/controls.tsx - das
|
||||||
|
* einzige inline-grid der App mit aria-pressed-Knoepfen).
|
||||||
|
*
|
||||||
|
* In der Werkzeugleiste der Buchung stehen zwei davon mit flex-1 neben dem
|
||||||
|
* Mitarbeiter-Auswahlfeld. Ihre Spalten sind minmax(0, 1fr), sie haben also
|
||||||
|
* keine Mindestbreite - auf dem Handy wurden sie auf fast nichts gequetscht und
|
||||||
|
* die Beschriftungen liefen uebereinander. Mit max-content bleiben sie so breit
|
||||||
|
* wie ihr Inhalt, die Leiste bricht vorher um.
|
||||||
|
*
|
||||||
|
* Knoepfe mit Symbol (Kalender/Liste) zeigen nur das Symbol: so passen beide
|
||||||
|
* Umschalter in eine Zeile. Der Text bleibt fuer Screenreader erhalten, der
|
||||||
|
* Knopf traegt zusaetzlich einen title.
|
||||||
|
*
|
||||||
|
* Das Innenpolster steht fest auf 0.25rem, weil der gleitende Marker seine
|
||||||
|
* Breite im Markup mit genau diesem Wert rechnet (100% - 0.5rem); mit dem
|
||||||
|
* kleineren --spacing waere er sonst ein Stueck zu breit.
|
||||||
|
*/
|
||||||
|
html.theme-mobile .inline-grid:has(> button[aria-pressed]) {
|
||||||
|
min-width: max-content;
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-mobile .inline-grid:has(> button[aria-pressed]) > span[aria-hidden] {
|
||||||
|
top: 0.25rem;
|
||||||
|
bottom: 0.25rem;
|
||||||
|
left: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.theme-mobile .inline-grid > button[aria-pressed]:has(> svg) {
|
||||||
|
gap: 0;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Dasselbe fuer alle anderen Themes, aber nur auf dem Handy: gequetscht wird
|
||||||
|
* nur unterhalb der sm-Grenze (40rem), wo die Werkzeugleiste die Umschalter
|
||||||
|
* mit flex-1 auf volle Breite zieht. Ab sm stehen sie mit flex-none und
|
||||||
|
* behalten ihre Breite - Desktop und Tablet bleiben also, wie sie sind.
|
||||||
|
* Das Polster muss hier nicht korrigiert werden: ausserhalb von Mobile gilt
|
||||||
|
* --spacing 0.25rem, p-1 passt dort schon zum Marker.
|
||||||
|
*/
|
||||||
|
@media (width < 40rem) {
|
||||||
|
.inline-grid:has(> button[aria-pressed]) {
|
||||||
|
min-width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-grid > button[aria-pressed]:has(> svg) {
|
||||||
|
gap: 0;
|
||||||
|
font-size: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
/*
|
/*
|
||||||
|
|||||||
+72
-6
@@ -136,6 +136,8 @@ export const settingsApi = {
|
|||||||
all: () => get<AdminSettings>('/settings'),
|
all: () => get<AdminSettings>('/settings'),
|
||||||
save: (changes: Partial<AdminSettingsInput>) => send<{ ok: true }>('PUT', '/settings', changes),
|
save: (changes: Partial<AdminSettingsInput>) => send<{ ok: true }>('PUT', '/settings', changes),
|
||||||
ssoTest: () => send<{ ok: true; sample: string[]; checked: number; eligible: number }>('POST', '/settings/sso/test'),
|
ssoTest: () => send<{ ok: true; sample: string[]; checked: number; eligible: number }>('POST', '/settings/sso/test'),
|
||||||
|
aiTest: () => send<{ ok: true; model: string; answer: string }>('POST', '/settings/ai/test'),
|
||||||
|
aiModels: (apiKey?: string) => send<{ models: AiModel[] }>('POST', '/settings/ai/models', { api_key: apiKey || undefined }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ExportSettings {
|
export interface ExportSettings {
|
||||||
@@ -173,6 +175,8 @@ export interface HrworksSettings {
|
|||||||
type_map: Record<string, string>;
|
type_map: Record<string, string>;
|
||||||
holiday_task: string;
|
holiday_task: string;
|
||||||
half_day_hours: number;
|
half_day_hours: number;
|
||||||
|
/** Abwesenheitsarten ("absence:<key>") im Kalender, den alle sehen. */
|
||||||
|
calendar_types: string[];
|
||||||
last_run: string | null;
|
last_run: string | null;
|
||||||
last_result: { created: number; updated: number; removed: number; unchanged: number; hours: number; failed: number } | null;
|
last_result: { created: number; updated: number; removed: number; unchanged: number; hours: number; failed: number } | null;
|
||||||
}
|
}
|
||||||
@@ -188,6 +192,8 @@ export interface HrworksStatus {
|
|||||||
type_map: Record<string, string>;
|
type_map: Record<string, string>;
|
||||||
last_run: string | null;
|
last_run: string | null;
|
||||||
last_result: HrworksSettings['last_result'];
|
last_result: HrworksSettings['last_result'];
|
||||||
|
calendar_types: string[];
|
||||||
|
calendar: { last_run: string | null; vacation_days: number; holidays: number };
|
||||||
entries: number;
|
entries: number;
|
||||||
hours: number;
|
hours: number;
|
||||||
first_date: string | null;
|
first_date: string | null;
|
||||||
@@ -210,6 +216,8 @@ export interface HrworksReport {
|
|||||||
unmatched_persons: { person_id: string; name: string; email: string | null }[];
|
unmatched_persons: { person_id: string; name: string; email: string | null }[];
|
||||||
missing_tasks: string[];
|
missing_tasks: string[];
|
||||||
per_person: Record<string, Record<string, { hours: number; days: number }>>;
|
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 = {
|
export const hrworksApi = {
|
||||||
@@ -225,15 +233,64 @@ export const hrworksApi = {
|
|||||||
removeAll: () => send<{ deleted: number }>('DELETE', '/hrworks/entries'),
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
api_key: string;
|
||||||
|
key_set?: boolean;
|
||||||
|
configured?: boolean;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
export interface AdminSettings extends PublicSettings {
|
export interface AdminSettings extends PublicSettings {
|
||||||
export: ExportSettings;
|
export: ExportSettings;
|
||||||
sso: SsoSettings;
|
sso: SsoSettings;
|
||||||
hrworks: HrworksSettings;
|
hrworks: HrworksSettings;
|
||||||
|
ai: AiSettings;
|
||||||
}
|
}
|
||||||
export type AdminSettingsInput = Omit<AdminSettings, 'export' | 'sso' | 'hrworks'> & {
|
export type AdminSettingsInput = Omit<AdminSettings, 'export' | 'sso' | 'hrworks' | 'ai'> & {
|
||||||
export: Partial<ExportSettings>;
|
export: Partial<ExportSettings>;
|
||||||
sso: Partial<SsoSettings> & { clear_secret?: boolean };
|
sso: Partial<SsoSettings> & { clear_secret?: boolean };
|
||||||
hrworks: Partial<HrworksSettings> & { clear_secret?: boolean };
|
hrworks: Partial<HrworksSettings> & { clear_secret?: boolean };
|
||||||
|
ai: Partial<AiSettings> & { clear_key?: boolean };
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface AssistantAnswer {
|
||||||
|
answer: string;
|
||||||
|
model: string;
|
||||||
|
tools: { name: string; args: Record<string, unknown> }[];
|
||||||
|
}
|
||||||
|
export const assistantApi = {
|
||||||
|
status: () => get<{ available: boolean; enabled: boolean; model: string }>('/assistant/status'),
|
||||||
|
ask: (question: string, verlauf: { frage: string; antwort: string }[]) =>
|
||||||
|
send<AssistantAnswer>('POST', '/assistant/ask', { question, verlauf }),
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AdUser {
|
export interface AdUser {
|
||||||
@@ -290,7 +347,7 @@ export type PotInput = Partial<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
export const projectsApi = {
|
export const projectsApi = {
|
||||||
list: (scope?: 'mine') => get<Project[]>(`/projects${qs({ scope })}`),
|
list: (scope?: 'mine', userId?: number) => get<Project[]>(`/projects${qs({ scope, user_id: userId })}`),
|
||||||
get: (id: number) => get<Project>(`/projects/${id}`),
|
get: (id: number) => get<Project>(`/projects/${id}`),
|
||||||
create: (data: Record<string, unknown>) => send<Project>('POST', '/projects', data),
|
create: (data: Record<string, unknown>) => send<Project>('POST', '/projects', data),
|
||||||
update: (id: number, data: Record<string, unknown>) => send<Project>('PUT', `/projects/${id}`, data),
|
update: (id: number, data: Record<string, unknown>) => send<Project>('PUT', `/projects/${id}`, data),
|
||||||
@@ -403,6 +460,7 @@ export interface GroupRow {
|
|||||||
export interface TimeReport {
|
export interface TimeReport {
|
||||||
range: { from: string; to: string };
|
range: { from: string; to: string };
|
||||||
include_fixed: boolean;
|
include_fixed: boolean;
|
||||||
|
hide_non_billable: boolean;
|
||||||
summary: {
|
summary: {
|
||||||
minutes: number;
|
minutes: number;
|
||||||
billable_minutes: number;
|
billable_minutes: number;
|
||||||
@@ -431,6 +489,8 @@ export interface ProjectDetail {
|
|||||||
|
|
||||||
export interface CustomerReport {
|
export interface CustomerReport {
|
||||||
customer: Customer;
|
customer: Customer;
|
||||||
|
range: { from: string | null; to: string | null };
|
||||||
|
series_unit: 'day' | 'week' | 'month';
|
||||||
totals: {
|
totals: {
|
||||||
minutes: number;
|
minutes: number;
|
||||||
billable_minutes: number;
|
billable_minutes: number;
|
||||||
@@ -440,15 +500,21 @@ export interface CustomerReport {
|
|||||||
open: number;
|
open: number;
|
||||||
budget_value: 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'>>;
|
projects: Array<Omit<ProjectDetail, 'weekly'>>;
|
||||||
team: GroupRow[];
|
team: GroupRow[];
|
||||||
tasks: GroupRow[];
|
tasks: GroupRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const reportsApi = {
|
export const reportsApi = {
|
||||||
time: (params: { from: string; to: string; customer_id?: number | null; team?: number | null; include_fixed?: boolean }) =>
|
time: (params: { from: string; to: string; customer_id?: number | null; team?: number | null; include_fixed?: boolean; hide_non_billable?: boolean }) =>
|
||||||
get<TimeReport>(`/reports/time${qs({ ...params, include_fixed: params.include_fixed ? 1 : undefined })}`),
|
get<TimeReport>(
|
||||||
|
`/reports/time${qs({
|
||||||
|
...params,
|
||||||
|
include_fixed: params.include_fixed ? 1 : undefined,
|
||||||
|
hide_non_billable: params.hide_non_billable ? 1 : undefined,
|
||||||
|
})}`
|
||||||
|
),
|
||||||
project: (id: number) => get<ProjectDetail>(`/reports/project/${id}`),
|
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 })}`),
|
||||||
};
|
};
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ import { PixelRunner } from '../components/layout/PixelRunner';
|
|||||||
/*
|
/*
|
||||||
* Darstellung der Oberflaeche. Sie hat zwei unabhaengige Achsen:
|
* Darstellung der Oberflaeche. Sie hat zwei unabhaengige Achsen:
|
||||||
*
|
*
|
||||||
* Farbwelt (palette) perinco | pixel | outdoor | cyberpunk | rainy | snow | aura
|
* Farbwelt (palette) perinco | pixel | outdoor | cyberpunk | rainy | snow | aura | mobile
|
||||||
* -> Klasse theme-* am <html>
|
* -> Klasse theme-* am <html>
|
||||||
* Modus (mode) hell | dunkel -> Klasse dark am <html>
|
* Modus (mode) hell | dunkel -> Klasse dark am <html>
|
||||||
*
|
*
|
||||||
|
|||||||
+9
-7
@@ -1,4 +1,4 @@
|
|||||||
export const THEME_PALETTES = ['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow', 'aura'] as const;
|
export const THEME_PALETTES = ['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow', 'aura', 'mobile'] as const;
|
||||||
export type ThemePalette = (typeof THEME_PALETTES)[number];
|
export type ThemePalette = (typeof THEME_PALETTES)[number];
|
||||||
|
|
||||||
export const THEME_PALETTE_LABELS: Record<ThemePalette, { name: string; hint: string }> = {
|
export const THEME_PALETTE_LABELS: Record<ThemePalette, { name: string; hint: string }> = {
|
||||||
@@ -9,6 +9,7 @@ export const THEME_PALETTE_LABELS: Record<ThemePalette, { name: string; hint: st
|
|||||||
rainy: { name: 'Rainy', hint: 'Regenwetter: Nieselregen, Wolken mit Wetterleuchten, Schieferblau' },
|
rainy: { name: 'Rainy', hint: 'Regenwetter: Nieselregen, Wolken mit Wetterleuchten, Schieferblau' },
|
||||||
snow: { name: 'Snow', hint: 'Winter: Schneefall, der sich unten sammelt, Eisblau' },
|
snow: { name: 'Snow', hint: 'Winter: Schneefall, der sich unten sammelt, Eisblau' },
|
||||||
aura: { name: 'Aura', hint: 'Hell Sunshine mit Sonnenstrahlen, dunkel Mond mit Sternen - ziehende Wolken' },
|
aura: { name: 'Aura', hint: 'Hell Sunshine mit Sonnenstrahlen, dunkel Mond mit Sternen - ziehende Wolken' },
|
||||||
|
mobile: { name: 'Mobile', hint: 'Fürs Handy: kompakter, mehr Inhalt pro Bildschirm, dunkel echtes Schwarz' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Role = 'user' | 'external' | 'lead' | 'management' | 'admin';
|
export type Role = 'user' | 'external' | 'lead' | 'management' | 'admin';
|
||||||
@@ -178,7 +179,7 @@ export interface Project {
|
|||||||
owner_id: number | null;
|
owner_id: number | null;
|
||||||
owner_name: string | null;
|
owner_name: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
customer: { id: number; name: string; short_name: string; color: string | null };
|
customer: { id: number; name: string; short_name: string; color: string | null; is_active: boolean };
|
||||||
pots: BudgetPot[];
|
pots: BudgetPot[];
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
members: MemberRef[];
|
members: MemberRef[];
|
||||||
@@ -228,12 +229,13 @@ export interface PublicSettings {
|
|||||||
project_name_pattern: string;
|
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> = {
|
export const VIEW_LABELS: Record<ViewKey, string> = {
|
||||||
dashboard: 'Dashboard',
|
dashboard: 'Dashboard',
|
||||||
buchung: 'Buchung',
|
buchung: 'Buchung',
|
||||||
projekte: 'Projekte',
|
projekte: 'Projekte',
|
||||||
|
kalender: 'Kalender',
|
||||||
team: 'Team',
|
team: 'Team',
|
||||||
kunden: 'Kunden',
|
kunden: 'Kunden',
|
||||||
reports: 'Reports',
|
reports: 'Reports',
|
||||||
@@ -245,15 +247,15 @@ export function viewsFor(role: Role): ViewKey[] {
|
|||||||
switch (role) {
|
switch (role) {
|
||||||
case 'user':
|
case 'user':
|
||||||
case 'external':
|
case 'external':
|
||||||
return ['buchung', 'projekte'];
|
return ['buchung', 'projekte', 'kalender'];
|
||||||
case 'lead':
|
case 'lead':
|
||||||
return ['dashboard', 'buchung', 'projekte', 'team', 'kunden', 'reports'];
|
return ['dashboard', 'buchung', 'projekte', 'kalender', 'team', 'kunden', 'reports'];
|
||||||
case 'management':
|
case 'management':
|
||||||
// Einstellungen zeigen fuer Management nur die SSO-Verwaltung.
|
// 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':
|
case 'admin':
|
||||||
// Administratoren sind keine Mitarbeiter und buchen keine Zeiten.
|
// Administratoren sind keine Mitarbeiter und buchen keine Zeiten.
|
||||||
return ['dashboard', 'projekte', 'team', 'kunden', 'reports', 'einstellungen'];
|
return ['dashboard', 'projekte', 'kalender', 'team', 'kunden', 'reports', 'einstellungen'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user