Compare commits

...
10 Commits
Author SHA1 Message Date
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
27 changed files with 1141 additions and 86 deletions
+11
View File
@@ -28,6 +28,13 @@ Die Startseite gilt nach jeder Anmeldung (Passwort und SSO), auch wenn noch eine
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`.
@@ -240,6 +247,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.
+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).
+89
View File
@@ -0,0 +1,89 @@
/*
* 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 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;
}
/** 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);
}
+4 -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, taskId, skipNonBillableProjects = false } = {}) {
// Administratoren sind keine Mitarbeiter - ihre Zeiten zaehlen nirgends mit.
const where = ["u.role <> 'admin'"];
const params = [];
@@ -39,6 +39,9 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
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,
+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;
}
+12
View File
@@ -71,6 +71,16 @@ export const DEFAULTS = {
last_run: null,
last_result: null,
},
/*
* 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 +114,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 +123,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
View File
@@ -244,6 +244,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)],
+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;
+5 -17
View File
@@ -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 {
@@ -228,14 +224,6 @@ router.get('/customer/:id', async (req, res, next) => {
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
/*
* Auslastung nur bis heute. Die Kapazitaet endet ohnehin am heutigen Tag
* (capTo) - werden Zeiten im Voraus gebucht, etwa eine ganze Woche Urlaub am
* Montag, stehen sonst 40 gebuchte Stunden gegen 8 Stunden Kapazitaet und die
* Auslastung springt auf 500 Prozent. Die Spalte "Stunden" zeigt weiter
* alles, was im Zeitraum gebucht ist.
*/
const bisHeute = entries.filter((e) => e.entry_date <= capTo);
const minutes = entries.reduce((s, e) => s + e.minutes, 0);
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
+29
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 } 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);
@@ -125,6 +127,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 +162,22 @@ 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 });
}
});
router.post('/sso/test', requireRole('admin'), async (req, res) => {
try {
const { sso } = await getSettings();
+3
View File
@@ -15,6 +15,7 @@ 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';
const app = express();
const PORT = Number(process.env.PORT || 5010);
@@ -55,6 +56,8 @@ app.use('/api/directory', requireAuth, requireRole('management'), directoryRoute
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.' }));
+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>
);
};
+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}>
+12 -3
View File
@@ -17,7 +17,7 @@ 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);
@@ -26,7 +26,7 @@ export const Customers: React.FC = () => {
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 +74,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}
+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>
+22 -2
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';
@@ -17,17 +17,37 @@ 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]);
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' },
]}
/>
</div>
{!id ? (
<EmptyState icon="building" title="Kunde auswählen" text="Alle Projekte eines Kunden mit Budget, Umsatz, Verlauf und Team." />
) : report.error ? (
+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>
);
})}
+42 -10
View File
@@ -75,6 +75,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,8 +93,16 @@ 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) => {
@@ -101,7 +112,7 @@ const TimeReport: React.FC = () => {
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 +124,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,11 +147,23 @@ 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>
))}
</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>
<select className={`${inputClass} !w-auto`} value={period} onChange={(e) => setPeriod(e.target.value as Period)}>
<option value="week">Woche</option>
@@ -176,13 +199,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 +220,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 +242,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>
+93 -1
View File
@@ -10,12 +10,13 @@ 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,93 @@ 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]);
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="Voreingestellt gpt-5.6-luna. Ein anderes Modell muss Werkzeuge über die Responses-API unterstützen.">
<input className={`${inputClass} font-mono text-xs`} value={ai.model} onChange={(e) => set({ model: e.target.value })} />
</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',
+32 -4
View File
@@ -136,6 +136,7 @@ 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'),
};
export interface ExportSettings {
@@ -225,15 +226,35 @@ export const hrworksApi = {
removeAll: () => send<{ deleted: number }>('DELETE', '/hrworks/entries'),
};
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 +311,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 +424,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;
@@ -447,8 +469,14 @@ export interface CustomerReport {
}
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}`),
};
+1 -1
View File
@@ -178,7 +178,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[];