Compare commits

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:41:49 +02:00
schakal00783andClaude Opus 5.5 c0eb00faee feat(kalender): Kalender mit Urlauben und Feiertagen fuer alle
Neuer Menuepunkt "Kalender" fuer alle Rollen: genehmigte Urlaube aller
Mitarbeiter, auch in der Zukunft, dazu Feiertage. Monatsansicht (Standard)
und Zeitleiste, filterbar nach Team und Name.

- Eigene Tabelle pulse_calendar_days, gefuellt von lib/calendarSync.js im
  HRworks-Lauf (Fenster bis 400 Tage voraus). Buchungen entstehen weiter
  erst am jeweiligen Tag.
- Nur validatedOk und nur freigegebene Arten (calendar_types, Haken in den
  HRworks-Einstellungen). Krankmeldungen werden dafuer nicht abgefragt.
- GET /api/calendar fuer alle Angemeldeten, ohne Mails und Profilbilder.
- Timer laeuft zusaetzlich um 12:00 Uhr.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:37:26 +02:00
schakal00783andClaude Opus 5.5 6ae5a01bda fix(buchung): Buchen-Button in der Wochenansicht immer sichtbar
Bei Tagen mit Eintraegen war der Button nur beim Hovern sichtbar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 16:47:18 +02:00
schakal00783andClaude Opus 5.5 be5beabab0 fix(ui): Umschalter auf dem Handy in allen Themes nicht mehr gequetscht
Den Fix aus dem Theme Mobile unter der sm-Grenze (40rem) fuer alle Themes:
Segmented bekommt min-width: max-content, Knoepfe mit Symbol zeigen nur das
Symbol. Ab sm stehen die Umschalter mit flex-none, Desktop und Tablet
bleiben unveraendert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 11:00:35 +02:00
schakal00783andClaude Opus 5.5 d40331f5cb fix(theme): Mobile - Umschalter in der Buchung nicht mehr gequetscht
Segmented hat Spalten mit minmax(0, 1fr) und damit keine Mindestbreite;
neben dem Mitarbeiter-Auswahlfeld liefen die Beschriftungen auf dem Handy
uebereinander. Im Theme Mobile jetzt min-width: max-content, Knoepfe mit
Symbol nur als Symbol, Innenpolster fest 0.25rem passend zum Marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:56:44 +02:00
schakal00783andClaude Opus 5.5 bea758e651 feat(theme): Theme "Mobile" - kompakte Darstellung fuers Handy
Perinco-Farben, aber rund 16 % dichter: --spacing 0.21rem statt 0.25rem,
kleinere Ueberschriften (lg bis 5xl), Kopfleiste 3.5rem, flachere Schatten.
text-sm/base bleiben, damit iOS beim Tippen in Felder nicht zoomt.
Dunkel mit echtem Schwarz fuer OLED.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:47:26 +02:00
schakal00783andClaude Opus 5.5 2f9c58f202 feat(ki): Modell aus Liste der verfuegbaren OpenAI-Modelle waehlen
Bisher musste der Administrator den Modellnamen abtippen - ein Tippfehler
fiel erst beim Test oder bei der ersten Frage auf, und welche Modelle der
Schluessel ueberhaupt darf, war in Pulse nicht zu sehen.

Einstellungen -> KI-Assistent zeigt jetzt ein Auswahlfeld. Die Liste kommt
ueber POST /settings/ai/models live von OpenAI (/v1/models), neueste
zuerst; Embedding-, Bild-, Audio-, Realtime-, Such- und
Moderationsmodelle fliegen raus. "Liste laden" nimmt einen gerade
eingetippten, noch nicht gespeicherten Schluessel mit. Das eingestellte
Modell bleibt waehlbar, auch wenn OpenAI es nicht mehr listet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 10:25:21 +02:00
schakal00783andClaude Opus 5 8a8e433397 feat(kunden): Kundennachweis ueber alle Berater exportieren
Bisher ging ein Stundennachweis nur von einer Person aus. Fuer die
Rechnung an den Kunden ist aber der Auftrag die Klammer: wer alles auf
Auxil gebucht hat, steht nirgends auf einem Blatt.

Auf der Kundenseite oeffnet ein Knopf je Karte und Zeile den
Kundennachweis: Monat, Format und wahlweise einzelne Projekte des
Kunden; ohne Auswahl sind alle enthalten. Das Dokument zeigt je Zeile
den Berater und fasst am Ende nach Berater (Tage, Dauer, Stunden,
optional Betrag) und nach Projekt und Task zusammen.

Dafuer nimmt /exports/timesheet ein ?project_ids fuer mehrere Projekte,
und loadEntries filtert entsprechend. Die Berater-Zusammenfassung
erscheint nur, wenn mehr als eine Person beteiligt war.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8ZiAB96iSmQhDrK4Jpi2R
2026-09-22 13:38:43 +02:00
schakal00783andClaude Opus 5 dd42fba1cb feat(export): Nachweis fuer andere Berater mit deren Projekten
Wer in der Buchung einen Kollegen ausgewaehlt hatte und dann exportierte,
bekam im Dialog die eigenen Kunden und Projekte zur Auswahl - fuer einen
Nachweis, der jemand anderem gehoert.

Der Dialog laedt die Projekte jetzt zu der Person, fuer die der Nachweis
entsteht, und laedt neu, wenn man die Person darin wechselt; die alte
Kunden- und Projektauswahl wird dabei verworfen, weil sie nicht mehr
passt. Bei "Alle Mitarbeiter" stehen alle sichtbaren Projekte zur Wahl.

Dafuer nimmt GET /projects ein ?user_id an. Wer fremde Projekte sehen
darf, entscheidet dieselbe Sicht wie bei den Zeiten: Mitarbeiter bekommen
403, Vorgesetzte nur ihr Team, Management alle. Die Sammelauswahl heisst
fuer Management "Alle Mitarbeiter" statt "Alle meine Mitarbeiter" - sie
sieht ohnehin jeden.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 10:44:49 +02:00
schakal00783andClaude Opus 5 ff71cb40e1 docs: Sitzungsdauer und .env-Werte im README festhalten
Die Umstellung auf eine Woche stand bisher nur im Code und in der
Commit-Nachricht. Im README fehlte ueberhaupt eine Aussage dazu, wann
jemand ausgeloggt wird - eine Frage, die im Betrieb regelmaessig kommt.

Dazu die Werte, die nur in der Produktions-.env stehen und ein Deployment
ueberdauern, samt Hinweis, dass eine Aenderung dort einen Neustart des
Dienstes braucht.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:56:54 +02:00
schakal00783andClaude Opus 5 cba44b2d8d chore(auth): Sitzung laeuft nach einer Woche ab statt nach 12 Stunden
Zwoelf Stunden bedeuteten in der Praxis, dass sich jeder mindestens
einmal am Tag neu anmeldet - bei SSO ein zusaetzlicher Umweg ohne
Gewinn. Die Frist laeuft weiterhin ab der Anmeldung und verlaengert sich
nicht bei Aktivitaet.

Auf dem Server steht der Wert in der .env; hier ziehen Vorgabe und
Beispieldatei nach, damit eine neue Installation nicht wieder bei zwoelf
Stunden anfaengt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 21:54:21 +02:00
schakal00783andClaude Opus 5 4e46df813c fix(export): Stundennachweis nur fuer laufende Projekte auswaehlbar
Die Auswahl im Exportdialog kam aus allen zugewiesenen Projekten - also
auch aus archivierten und von stillgelegten Kunden. In der Liste stand
damit etwa "Top IT Services", wo seit 2025 nichts mehr laeuft.

Waehlbar sind jetzt nur laufende Projekte aktiver Kunden. Dafuer liefert
loadProjects den Status des Kunden mit; ohne ihn liesse sich "nur aktive
Kunden" in der Oberflaeche nicht entscheiden.

Die Zeiten aus archivierten Projekten bleiben erreichbar: ohne Filter
exportiert der Nachweis alles, was die Person im Monat gebucht hat, und
der Projektreport hat seinen eigenen Export.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:08:05 +02:00
schakal00783andClaude Opus 5 0cd426777d feat(kunden): Filter "Inaktiv" in Kundenliste und Kundenreport
Mit den 25 stillgelegten Kunden aus der Harvest-Historie war "Aktiv"
oder "Alle" zu grob: wer nachsehen will, wen wir stillgelegt haben,
musste ihn zwischen allen 63 suchen.

Kundenliste und Kundenreport haben jetzt drei Stufen - Aktiv, Inaktiv,
Alle. Im Kundenreport bleibt der gewaehlte Kunde in der Auswahl stehen,
auch wenn der Filter ihn sonst ausblenden wuerde; stillgelegte sind mit
"(inaktiv)" gekennzeichnet. Im Zeitreport stehen sie unter "Inaktiv" in
einer eigenen Gruppe, damit sie die Auswahl nicht zuwachsen lassen - die
Historie bleibt ja auswertbar.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:05:25 +02:00
schakal00783andClaude Opus 5 e908db2788 feat(assistant): Fragen an die Daten im Kopf der Anwendung
Management und Administration koennen oben rechts eine Frage in Worten
stellen - "wie viele Stunden im August", "welches Projekt hat noch
Budget", "wie ist die Auslastung".

Aufbau: das Modell bekommt eine Uebersicht (Kunden, laufende Projekte,
Eckdaten) und vier Werkzeuge - auswertung, projekte, buchungen,
personen. Damit fragt es gezielt nach, statt dass ihm der ganze Bestand
vorgelegt wird; 19.000 Buchungen passen in keinen Kontext. Die Werkzeuge
rechnen mit loadEntries und loadProjects, also mit denselben Funktionen
wie die Reports - der Assistent kann so keine zweite Wahrheit erzeugen.
Was einfliesst, begrenzt visibleUserIds wie ueberall sonst.

Benutzt wird die Responses-API von OpenAI: die neueren Modelle nehmen
Werkzeuge in /v1/chat/completions nur ohne Nachdenken an. Mit
"store: false" bleibt nichts bei OpenAI liegen, der Verlauf geht deshalb
je Runde komplett mit. Der Schluessel liegt verschluesselt in den
Einstellungen (AES-256-GCM wie das SSO-Secret) und wird nie ausgeliefert;
das Modell ist einstellbar, Vorgabe gpt-5.6-luna.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:31:51 +02:00
schakal00783andClaude Opus 5 c3aed8a3d3 fix(reports): lange Projektnamen umbrechen statt abschneiden
Die Reporttabelle lief mit table-auto: ein langer Projektname wie
"HAYS - Globales Template und Implementierung: ... _Fresenius -
01.10.2025-31.07.2026" zog die Namensspalte auf und schob Stunden und
Betrag aus dem sichtbaren Bereich. Das truncate am Namen half nicht,
weil die Spalte keine Breite hatte, an der es haette greifen koennen.

Die Tabelle steht jetzt auf table-fixed mit festen Breiten fuer die
Zahlenspalten; der Name bekommt den Rest und bricht um (break-words).
Die Zahlen selbst bleiben einzeilig.

Dazu die Kennzahlenkarte: ihre vier Spalten mit den grossen Betraegen
passten ab lg nicht mehr in die Breite, der rechte Rand wurde von
overflow-x-hidden abgeschnitten. Sie bricht jetzt auf zwei Spalten um
und zeigt vier erst ab xl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:16:13 +02:00
schakal00783andClaude Opus 5 80e46910fd feat(reports): nicht abrechenbare Projekte ausblenden
Im Zeitreport stehen interne Projekte und Abwesenheiten zwischen den
Kundenzahlen: 2025 sind das 19.451 von 45.762 Stunden, und die Auslastung
zeigt 146 Prozent, weil Urlaub und Krankheit mitzaehlen.

Ein Haken neben der Billable-Aufteilung blendet Projekte vom Typ
"nicht abrechenbar" aus. Er wirkt auf den ganzen Report - Summen,
Auslastung, alle vier Gruppierungen - und auf die Exporte, die denselben
Report bauen; im PDF-Kopf steht dann "Nicht abrechenbare Projekte:
ausgeblendet". Gefiltert wird in loadEntries, damit Kunden-, Projekt- und
Taskliste dieselbe Grundmenge sehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:07:37 +02:00
schakal00783andClaude Opus 5 2710baa615 fix(import): Sammelbuchungen und nicht abrechenbare Projekte anlegen
Beim Nachziehen der Harvest-Historie sind zwei Regeln im Weg gestanden,
die fuer eine Uebernahme nicht passen:

- Beim Anlegen verlangte die API auch fuer nicht abrechenbare Projekte
  einen Satz, obwohl validateProject ihn direkt danach verwirft. Nur
  Abruf rechnet ueber den Satz am Projekt ab, also gilt die Pflicht auch
  nur dort.
- Der Importweg liess hoechstens 24 Stunden je Tag zu. In Harvest stehen
  Sammelbuchungen ("Nachbuchung DORA September und Oktober", 80 Stunden
  auf einem Tag). Sie abzulehnen verliert die Stunden und verfaelscht das
  Restbudget, sie zu verteilen erfindet Leistungsdaten - deshalb kommen
  sie so herein, wie sie gebucht wurden. Eine Buchung darf jetzt bis zu
  31 Tage umfassen, damit Zahlendreher weiter auffallen. Im
  Buchungsdialog bleibt die Tagesgrenze bestehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 16:28:14 +02:00
schakal00783andClaude Opus 5 e962be41ea fix(historie): Uebernahme alter Zeiten, Rollenzone Externe, Reportfehler
Die Uebernahme der Harvest-Historie ist an drei Stellen haengen geblieben:

- resolvePot hat abgelaufene Toepfe grundsaetzlich gesperrt. Fuer eine
  historische Buchung ist das falsch - dort zaehlt, ob der Topf am
  Leistungstag lief. Der Importweg setzt jetzt `historic`, das Volumen
  wird weiter geprueft, damit Ueberbuchungen auffallen.
- In der SSO-Verwaltung fehlte die Ablagezone fuer die Rolle "Externe";
  Konten liessen sich dort nicht zuordnen.
- In buildProjectDetail und der Kundenauswertung stand ein Rest der
  Auslastungsaenderung (bisHeute/capTo) ohne Bezug im Scope. Beide
  Reportseiten liefen dadurch auf 500.

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