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>
254 lines
10 KiB
JavaScript
254 lines
10 KiB
JavaScript
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();
|
||
|
||
// Fuer alle Angemeldeten: was die Oberflaeche zum Rechnen braucht.
|
||
router.get('/public', async (req, res, next) => {
|
||
try {
|
||
const s = await getSettings();
|
||
res.json({
|
||
weekly_hours: Number(s.weekly_hours),
|
||
hours_per_day: Number(s.hours_per_day),
|
||
budget_warn_percent: Number(s.budget_warn_percent),
|
||
budget_warn_days: Number(s.budget_warn_days),
|
||
company_name: s.company_name,
|
||
project_code_pattern: s.project_code_pattern,
|
||
project_name_pattern: s.project_name_pattern,
|
||
});
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
router.get('/', requireRole('admin'), async (req, res, next) => {
|
||
try {
|
||
const s = await getSettings();
|
||
// Geheimnisse werden nie ausgeliefert, nur die Information, dass sie stehen.
|
||
res.json({
|
||
...s,
|
||
sso: { ...s.sso, client_secret: '', client_secret_set: Boolean(s.sso.client_secret), configured: ssoConfigured(s.sso) },
|
||
hrworks: {
|
||
...s.hrworks,
|
||
secret_access_key: '',
|
||
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);
|
||
}
|
||
});
|
||
|
||
const text = (v, max) => String(v ?? '').slice(0, max);
|
||
|
||
router.put('/', requireRole('admin'), async (req, res, next) => {
|
||
try {
|
||
const body = req.body || {};
|
||
const current = await getSettings();
|
||
const errors = [];
|
||
|
||
const numbers = {
|
||
weekly_hours: [1, 80],
|
||
hours_per_day: [1, 24],
|
||
budget_warn_percent: [1, 100],
|
||
budget_warn_days: [0, 365],
|
||
};
|
||
for (const [key, [min, max]] of Object.entries(numbers)) {
|
||
if (body[key] === undefined) continue;
|
||
const value = Number(body[key]);
|
||
if (!(value >= min && value <= max)) errors.push(`${key}: Wert zwischen ${min} und ${max}.`);
|
||
else await saveSetting(key, value);
|
||
}
|
||
if (body.company_name !== undefined) await saveSetting('company_name', text(body.company_name, 160));
|
||
if (body.project_code_pattern !== undefined) {
|
||
const pattern = text(body.project_code_pattern, 60).trim();
|
||
if (!pattern.includes('{NR}')) errors.push('Das Codemuster braucht {NR}, sonst sind Codes nicht eindeutig.');
|
||
else await saveSetting('project_code_pattern', pattern);
|
||
}
|
||
if (body.project_name_pattern !== undefined) {
|
||
const pattern = text(body.project_name_pattern, 60).trim();
|
||
if (!pattern.includes('{PROJEKT}')) errors.push('Das Namensmuster braucht {PROJEKT}.');
|
||
else await saveSetting('project_name_pattern', pattern);
|
||
}
|
||
|
||
if (body.export) {
|
||
const next_ = { ...current.export };
|
||
for (const key of Object.keys(DEFAULTS.export)) {
|
||
if (body.export[key] === undefined) continue;
|
||
if (key === 'show_signature') next_[key] = Boolean(body.export[key]);
|
||
else if (key === 'logo') {
|
||
const logo = body.export.logo;
|
||
if (logo === null || logo === '') next_.logo = null;
|
||
else if (/^data:image\/(png|jpe?g);base64,/.test(logo) && logo.length < 2_500_000) next_.logo = logo;
|
||
else errors.push('Logo: PNG oder JPG bis ca. 1,8 MB.');
|
||
} else if (key === 'accent') {
|
||
if (/^#[0-9a-f]{6}$/i.test(body.export.accent)) next_.accent = body.export.accent;
|
||
else errors.push('Akzentfarbe ist ungültig.');
|
||
} else next_[key] = text(body.export[key], 300);
|
||
}
|
||
await saveSetting('export', next_);
|
||
}
|
||
|
||
if (body.hrworks) {
|
||
const next_ = { ...current.hrworks };
|
||
for (const key of ['access_key', 'project_code', 'holiday_task']) {
|
||
if (body.hrworks[key] !== undefined) next_[key] = text(body.hrworks[key], 200).trim();
|
||
}
|
||
if (body.hrworks.enabled !== undefined) next_.enabled = Boolean(body.hrworks.enabled);
|
||
if (body.hrworks.window_days !== undefined) {
|
||
const days = Number(body.hrworks.window_days);
|
||
if (days >= 1 && days <= 365) next_.window_days = Math.round(days);
|
||
else errors.push('HRworks: Fenster zwischen 1 und 365 Tagen.');
|
||
}
|
||
if (body.hrworks.half_day_hours !== undefined) {
|
||
const hours = Number(body.hrworks.half_day_hours);
|
||
if (hours >= 0 && hours <= 12) next_.half_day_hours = hours;
|
||
else errors.push('HRworks: Stunden für einen halben Tag zwischen 0 und 12.');
|
||
}
|
||
if (body.hrworks.type_map && typeof body.hrworks.type_map === 'object') {
|
||
const map = {};
|
||
for (const [key, value] of Object.entries(body.hrworks.type_map)) {
|
||
if (/^(absence|sick):[A-Za-z0-9_-]{1,20}$/.test(key)) map[key] = text(value, 160).trim();
|
||
}
|
||
next_.type_map = map;
|
||
}
|
||
// Leer = unveraendert; der geheime Teil wird nie ausgeliefert.
|
||
if (body.hrworks.secret_access_key) next_.secret_access_key = text(body.hrworks.secret_access_key, 500).trim();
|
||
if (body.hrworks.clear_secret) next_.secret_access_key = '';
|
||
if (next_.enabled && !(next_.access_key && next_.secret_access_key)) {
|
||
errors.push('HRworks kann erst aktiviert werden, wenn Access Key und Secret gesetzt sind.');
|
||
}
|
||
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']) {
|
||
if (body.sso[key] !== undefined) next_[key] = text(body.sso[key], 300).trim();
|
||
}
|
||
for (const key of ['enabled', 'local_login']) {
|
||
if (body.sso[key] !== undefined) next_[key] = Boolean(body.sso[key]);
|
||
}
|
||
// Leer = unveraendert lassen; das Secret wird nie ausgeliefert.
|
||
if (body.sso.client_secret) next_.client_secret = text(body.sso.client_secret, 500).trim();
|
||
if (body.sso.clear_secret) next_.client_secret = '';
|
||
if (next_.enabled && !ssoConfigured(next_)) errors.push('SSO kann erst aktiviert werden, wenn Tenant, Client-ID, Secret und Redirect-URI gesetzt sind.');
|
||
if (next_.enabled && !ssoConfigured(next_)) next_.enabled = false;
|
||
if (!next_.enabled) next_.local_login = true; // ohne SSO braucht es die normale Anmeldung
|
||
await saveSetting('sso', next_);
|
||
}
|
||
|
||
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
|
||
res.json({ ok: true });
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
/** 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();
|
||
if (!ssoConfigured(sso)) return res.status(400).json({ error: 'Tenant, Client-ID und Secret fehlen.' });
|
||
const result = await testConnection(sso);
|
||
res.json({ ok: true, ...result });
|
||
} catch (err) {
|
||
res.status(502).json({ error: `Verbindung fehlgeschlagen: ${err.message}` });
|
||
}
|
||
});
|
||
|
||
/** Beispiel-Stundennachweis mit Platzhalterdaten - zeigt Kopf und Fuss. */
|
||
router.get('/export-preview', requireRole('admin'), async (req, res, next) => {
|
||
try {
|
||
const settings = await getSettings();
|
||
const format = ['pdf', 'xlsx', 'csv'].includes(req.query.format) ? req.query.format : 'pdf';
|
||
const rows = [
|
||
['2026-09-01', 'Di', 'Projektleitung', 'Jour fixe und Statusbericht', 150],
|
||
['2026-09-02', 'Mi', 'Konzeption', 'Workshop Anforderungen', 420],
|
||
['2026-09-03', 'Do', 'Konzeption', 'Dokumentation Prozessmodell', 390],
|
||
['2026-09-04', 'Fr', 'Projektleitung', '', 240],
|
||
].map(([entry_date, weekday, task_name, note, minutes]) => ({
|
||
entry_date,
|
||
weekday,
|
||
project_display: 'Musterkunde - Beispielprojekt',
|
||
task_name,
|
||
note,
|
||
minutes,
|
||
duration: `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, '0')}`,
|
||
}));
|
||
const total = rows.reduce((s, r) => s + r.minutes, 0);
|
||
await sendDocument(
|
||
res,
|
||
{
|
||
title: settings.export.title || 'Stundennachweis',
|
||
subtitle: 'Vorschau',
|
||
filename: 'Vorschau Stundennachweis',
|
||
info: [
|
||
['Berater', 'Max Mustermann'],
|
||
['Kunde', 'Musterkunde GmbH'],
|
||
['Zeitraum', '01.09.2026 – 30.09.2026'],
|
||
['Projekt', 'Musterkunde - Beispielprojekt'],
|
||
],
|
||
kpis: [
|
||
['Gesamtstunden', (total / 60).toLocaleString('de-DE', { minimumFractionDigits: 2 })],
|
||
['Tage mit Leistung', '4'],
|
||
],
|
||
tables: [
|
||
{
|
||
columns: [
|
||
{ key: 'entry_date', label: 'Datum', type: 'date', width: 58 },
|
||
{ key: 'weekday', label: 'Tag', width: 26 },
|
||
{ key: 'project_display', label: 'Projekt', width: 120 },
|
||
{ key: 'task_name', label: 'Task', width: 80 },
|
||
{ key: 'note', label: 'Tätigkeit', type: 'text' },
|
||
{ key: 'duration', label: 'Dauer', align: 'right', width: 38 },
|
||
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 46 },
|
||
],
|
||
rows,
|
||
totals: { weekday: 'Summe', minutes: total },
|
||
},
|
||
],
|
||
signature: true,
|
||
},
|
||
format,
|
||
settings
|
||
);
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
export default router;
|