Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd42fba1cb | ||
|
|
ff71cb40e1 | ||
|
|
cba44b2d8d | ||
|
|
4e46df813c | ||
|
|
0cd426777d | ||
|
|
e908db2788 | ||
|
|
c3aed8a3d3 | ||
|
|
80e46910fd | ||
|
|
2710baa615 | ||
|
|
e962be41ea |
@@ -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
@@ -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).
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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)],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.' }));
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}>
|
||||
|
||||
@@ -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,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,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">
|
||||
<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) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<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>
|
||||
{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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name}
|
||||
</option>
|
||||
))}
|
||||
{/* 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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
@@ -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
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user