feat: perinco Pulse - Zeiterfassung fuer perinco

Erste Fassung, live unter https://pulse.perinco.de.

Frontend React 19 + TypeScript + Tailwind 4 (Vite), Backend Express mit
mysql2 auf MariaDB, Exporte als PDF (pdfkit), Excel (exceljs) und CSV.
Aufbau, Designsystem und die sieben Themes kommen aus dem perinco CRM.

Fachlich:
- Buchung mit Wochen-, Monats- und Jahresansicht, Zeiten als 1:23 oder 1,5
- Projekte mit n Budgettoepfen: Laufzeit, Volumen, Sperre bei Ablauf oder
  aufgebrauchtem Budget, Buchung nur auf den passenden Topf
- Kunden, Team mit Hierarchie, Reports im Harvest-Stil, Monatsexporte
- Rollen user/lead/management/admin, doppelt geprueft (Menue und Backend)
- SSO gegen Microsoft Entra ID, Uebernahme der AD-Benutzer per Drag & Drop

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-17 22:52:20 +02:00
co-authored by Claude Opus 5
commit ef676a95f4
104 changed files with 21030 additions and 0 deletions
+170
View File
@@ -0,0 +1,170 @@
import express from 'express';
import bcrypt from 'bcryptjs';
import crypto from 'node:crypto';
import { pool } from '../db.js';
import { publicUser, requireAuth, signShort, signToken, verifyShort } from '../middleware/auth.js';
import { getSettings } from '../lib/settings.js';
import { authorizeUrl, exchangeCode, ssoConfigured } from '../lib/sso.js';
const router = express.Router();
/*
* Bremse gegen Passwortraten: 10 Fehlversuche je IP und Kennung in 15 Minuten.
* Im Speicher reicht - es gibt genau eine Backend-Instanz.
*/
const attempts = new Map();
const WINDOW = 15 * 60000;
function isLimited(key) {
const now = Date.now();
const recent = (attempts.get(key) || []).filter((t) => now - t < WINDOW);
if (recent.length) attempts.set(key, recent);
else attempts.delete(key);
return recent.length >= 10;
}
function recordFailure(key) {
attempts.set(key, [...(attempts.get(key) || []), Date.now()]);
}
// Oeffentlich: was zeigt der Anmeldeschirm?
router.get('/config', async (req, res, next) => {
try {
const { sso } = await getSettings();
const ssoOn = Boolean(sso.enabled && ssoConfigured(sso));
res.json({ sso_enabled: ssoOn, sso_label: sso.button_label, local_login: ssoOn ? sso.local_login !== false : true });
} catch (err) {
next(err);
}
});
router.post('/login', async (req, res, next) => {
const identifier = String(req.body?.identifier || '').trim();
const password = String(req.body?.password || '');
if (!identifier || !password) {
return res.status(400).json({ error: 'Benutzername und Passwort sind erforderlich.' });
}
const key = `${req.ip}|${identifier.toLowerCase()}`;
if (isLimited(key)) {
return res.status(429).json({ error: 'Zu viele Fehlversuche. Bitte in 15 Minuten erneut versuchen.' });
}
try {
const [rows] = await pool.query('SELECT * FROM pulse_users WHERE username = ? OR email = ? LIMIT 1', [
identifier,
identifier.toLowerCase(),
]);
const user = rows[0];
// Identische Meldung fuer "gibt es nicht" und "falsches Passwort".
const ok = user?.password_hash ? await bcrypt.compare(password, user.password_hash) : false;
if (!ok) {
recordFailure(key);
return res.status(401).json({ error: 'Benutzername oder Passwort ist falsch.' });
}
if (!user.is_active) return res.status(403).json({ error: 'Dieses Benutzerkonto ist deaktiviert.' });
await pool.query('UPDATE pulse_users SET last_login_at = NOW() WHERE id = ?', [user.id]);
res.json({ token: signToken(user), user: publicUser(user) });
} catch (err) {
next(err);
}
});
router.get('/me', requireAuth, (req, res) => res.json({ user: publicUser(req.user) }));
// Eigene Darstellung: Design, Modus, Farbe.
router.patch('/me', requireAuth, async (req, res, next) => {
const updates = {};
if (req.body?.theme !== undefined) {
const theme = req.body.theme;
if (theme !== null && !['perinco', 'pixel', 'outdoor', 'cyberpunk', 'rainy', 'snow', 'aura'].includes(theme)) {
return res.status(400).json({ error: 'Unbekanntes Design.' });
}
updates.theme = theme === 'perinco' ? null : theme;
}
if (req.body?.theme_mode !== undefined) {
const mode = req.body.theme_mode;
if (mode !== null && mode !== 'light' && mode !== 'dark') return res.status(400).json({ error: 'Unbekannter Modus.' });
updates.theme_mode = mode;
}
if (req.body?.color !== undefined) {
const color = req.body.color;
if (color !== null && !/^#[0-9a-f]{6}$/i.test(color)) return res.status(400).json({ error: 'Ungültige Farbe.' });
updates.color = color;
}
const keys = Object.keys(updates);
if (keys.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
try {
await pool.query(`UPDATE pulse_users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [
...keys.map((k) => updates[k]),
req.user.id,
]);
res.json({ user: publicUser({ ...req.user, ...updates }) });
} catch (err) {
next(err);
}
});
router.post('/change-password', requireAuth, async (req, res, next) => {
const current = String(req.body?.current_password || '');
const wanted = String(req.body?.new_password || '');
if (wanted.length < 8) return res.status(400).json({ error: 'Das neue Passwort muss mindestens 8 Zeichen haben.' });
try {
const [rows] = await pool.query('SELECT password_hash FROM pulse_users WHERE id = ?', [req.user.id]);
const hash = rows[0]?.password_hash;
if (!hash) return res.status(400).json({ error: 'Dieses Konto meldet sich über SSO an und hat kein Passwort.' });
if (!(await bcrypt.compare(current, hash))) return res.status(400).json({ error: 'Das aktuelle Passwort ist falsch.' });
await pool.query('UPDATE pulse_users SET password_hash = ? WHERE id = ?', [await bcrypt.hash(wanted, 12), req.user.id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
/* ---------------------------------------------------------------- SSO -- */
router.get('/sso/start', async (req, res, next) => {
try {
const { sso } = await getSettings();
if (!sso.enabled || !ssoConfigured(sso)) {
return res.redirect('/?sso_error=' + encodeURIComponent('SSO ist nicht eingerichtet.'));
}
const state = signShort({ n: crypto.randomBytes(12).toString('hex'), t: 'sso' });
res.redirect(authorizeUrl(sso, state));
} catch (err) {
next(err);
}
});
router.get('/sso/callback', async (req, res) => {
const back = (message) => res.redirect('/?sso_error=' + encodeURIComponent(message));
try {
if (req.query.error) return back(String(req.query.error_description || req.query.error).split('\n')[0]);
try {
const state = verifyShort(String(req.query.state || ''));
if (state.t !== 'sso') throw new Error('state');
} catch {
return back('Anmeldung abgelaufen. Bitte erneut versuchen.');
}
const { sso } = await getSettings();
if (!sso.enabled || !ssoConfigured(sso)) return back('SSO ist nicht eingerichtet.');
const identity = await exchangeCode(sso, String(req.query.code || ''));
// Nur wer in der SSO-Verwaltung uebernommen wurde, kommt herein - verknuepft
// wird ueber die Objekt-ID, nicht ueber die Mailadresse.
const [rows] = await pool.query("SELECT * FROM pulse_users WHERE external_id = ? AND source = 'sso' LIMIT 1", [identity.oid]);
const user = rows[0];
if (!user) {
return back(`Für ${identity.email || 'dieses Konto'} ist Pulse noch nicht freigeschaltet. Bitte an einen Manager oder Administrator wenden.`);
}
if (!user.is_active) return back('Dieses Benutzerkonto ist deaktiviert.');
await pool.query('UPDATE pulse_users SET last_login_at = NOW() WHERE id = ?', [user.id]);
// Token im Fragment: landet weder im Server-Log noch im Referer.
res.redirect(`/#sso_token=${encodeURIComponent(signToken(user))}`);
} catch (err) {
console.error('[Pulse] SSO-Anmeldung fehlgeschlagen:', err.message);
back('Anmeldung über Microsoft fehlgeschlagen: ' + err.message);
}
});
export default router;
+161
View File
@@ -0,0 +1,161 @@
import express from 'express';
import { pool } from '../db.js';
import { getSettings } from '../lib/settings.js';
import { loadProjects } from '../lib/projects.js';
import { loadEntries } from '../lib/entries.js';
import { addDays, today } from '../lib/dates.js';
const router = express.Router();
const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
function normalize(body, creating) {
const errors = [];
const out = {};
if (creating || body.name !== undefined) {
out.name = str(body.name, 190);
if (!out.name) errors.push('Kundenname fehlt.');
}
if (creating || body.short_name !== undefined) {
out.short_name = str(body.short_name, 16)?.toUpperCase().replace(/[^A-Z0-9ÄÖÜ_-]/g, '') || null;
if (!out.short_name) errors.push('Kürzel fehlt (z. B. KUKA) - es steht im Projektcode.');
}
for (const [key, max] of [
['contact_person', 160],
['email', 190],
['phone', 60],
['street', 190],
['zip', 20],
['city', 120],
['country', 80],
['notes', 5000],
]) {
if (body[key] !== undefined) out[key] = str(body[key], max);
}
if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null;
if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0;
return { out, errors };
}
router.get('/', async (req, res, next) => {
try {
const [rows] = await pool.query(
`SELECT c.*, (SELECT COUNT(*) FROM pulse_projects p WHERE p.customer_id = c.id) AS project_count
FROM pulse_customers c ORDER BY c.name`
);
res.json(rows.map((r) => ({ ...r, is_active: Boolean(r.is_active), project_count: Number(r.project_count) })));
} catch (err) {
next(err);
}
});
/**
* Uebersicht: welche Kunden haben welche Projekte mit welchen Mitarbeitern,
* wie viel Umsatz ist erbracht und wie viel noch offen - plus die letzten 12
* Monate Umsatz fuer die Verlaufslinie.
*/
router.get('/overview', async (req, res, next) => {
try {
const settings = await getSettings();
const [customers] = await pool.query('SELECT * FROM pulse_customers ORDER BY name');
const projects = await loadProjects(settings);
const from = `${addDays(today(), -365).slice(0, 7)}-01`;
const entries = await loadEntries(settings, { from });
const months = [];
const cursor = new Date(`${from}T00:00:00Z`);
for (let i = 0; i < 13; i++) {
months.push(cursor.toISOString().slice(0, 7));
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
const result = customers.map((customer) => {
const own = projects.filter((p) => p.customer_id === customer.id);
const memberMap = new Map();
for (const project of own) for (const m of project.members) memberMap.set(m.id, m);
const monthly = months.map((month) =>
entries
.filter((e) => e.customer_id === customer.id && e.entry_date.startsWith(month))
.reduce((sum, e) => sum + e.amount + e.fixed_amount, 0)
);
return {
...customer,
is_active: Boolean(customer.is_active),
projects: own.map((p) => ({
id: p.id,
code: p.code,
name: p.name,
display_name: p.display_name,
type: p.type,
is_active: p.is_active,
used_minutes: p.used_minutes,
budget_hours: p.budget_hours,
earned: p.earned,
open: p.open,
budget_value: p.budget_value,
members: p.members,
end_date: p.end_date,
})),
members: [...memberMap.values()],
used_minutes: own.reduce((s, p) => s + p.used_minutes, 0),
earned: Math.round(own.reduce((s, p) => s + p.earned, 0) * 100) / 100,
open: Math.round(own.reduce((s, p) => s + p.open, 0) * 100) / 100,
monthly: monthly.map((v) => Math.round(v * 100) / 100),
};
});
res.json({ months, customers: result });
} catch (err) {
next(err);
}
});
router.post('/', async (req, res, next) => {
const { out, errors } = normalize(req.body || {}, true);
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
try {
const keys = Object.keys(out);
const [result] = await pool.query(
`INSERT INTO pulse_customers (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
keys.map((k) => out[k])
);
const [[row]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [result.insertId]);
res.status(201).json(row);
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Dieses Kürzel ist schon vergeben.' });
next(err);
}
});
router.put('/:id', async (req, res, next) => {
const { out, errors } = normalize(req.body || {}, false);
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
try {
const keys = Object.keys(out);
if (keys.length) {
const [result] = await pool.query(`UPDATE pulse_customers SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [
...keys.map((k) => out[k]),
Number(req.params.id),
]);
if (!result.affectedRows) return res.status(404).json({ error: 'Kunde nicht gefunden.' });
}
const [[row]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [Number(req.params.id)]);
res.json(row);
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Dieses Kürzel ist schon vergeben.' });
next(err);
}
});
router.delete('/:id', async (req, res, next) => {
try {
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_projects WHERE customer_id = ?', [Number(req.params.id)]);
if (Number(n) > 0) {
return res.status(409).json({ error: `Dem Kunden sind ${n} Projekte zugeordnet. Bitte deaktivieren statt löschen.` });
}
await pool.query('DELETE FROM pulse_customers WHERE id = ?', [Number(req.params.id)]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
export default router;
+158
View File
@@ -0,0 +1,158 @@
import express from 'express';
import { pool } from '../db.js';
import { visibleUserIds, userFilter } from '../lib/access.js';
import { getSettings } from '../lib/settings.js';
import { loadEntries } from '../lib/entries.js';
import { addDays, periodLabel, periodRange, previousStart, today, workdays } from '../lib/dates.js';
const router = express.Router();
const SERIES_LENGTH = { day: 14, week: 12, month: 12, quarter: 8, year: 5 };
export function capacityMinutes(user, settings, from, to) {
const weekly = user.weekly_hours == null ? Number(settings.weekly_hours) : Number(user.weekly_hours);
return Math.round((weekly / 5) * workdays(from, to) * 60);
}
function sum(entries) {
let minutes = 0;
let billable = 0;
let amount = 0;
for (const e of entries) {
minutes += e.minutes;
if (e.billable) billable += e.minutes;
amount += e.amount;
}
return { minutes, billable_minutes: billable, non_billable_minutes: minutes - billable, amount: Math.round(amount * 100) / 100 };
}
/**
* Dashboard fuer Fuehrungsrollen. Vorgesetzte sehen ihr Team (rekursiv),
* Management und Administratoren alle.
*
* ?granularity day|week|month|quarter|year - Diagramm und Kennzahlen
* ?rank days|week|month|quarter|year - Zeitraum der Mitarbeiterliste
*/
router.get('/', async (req, res, next) => {
try {
const granularity = SERIES_LENGTH[req.query.granularity] ? req.query.granularity : 'week';
const rank = ['days', 'week', 'month', 'quarter', 'year'].includes(req.query.rank) ? req.query.rank : 'week';
const settings = await getSettings();
const userIds = await visibleUserIds(req.user);
const now = today();
// Diagramm: die letzten N Zeitraeume bis einschliesslich des aktuellen.
const buckets = [];
let start = periodRange(granularity, now).from;
for (let i = 0; i < SERIES_LENGTH[granularity]; i++) {
buckets.unshift({ ...periodRange(granularity, start), label: periodLabel(granularity, start) });
start = previousStart(granularity, start);
}
const rankRange = rank === 'days' ? { from: addDays(now, -6), to: now } : periodRange(rank, now);
const earliest = [buckets[0].from, rankRange.from].sort()[0];
const entries = await loadEntries(settings, { from: earliest, to: buckets[buckets.length - 1].to, userIds });
const series = buckets.map((b) => ({
from: b.from,
to: b.to,
label: b.label,
...sum(entries.filter((e) => e.entry_date >= b.from && e.entry_date <= b.to)),
}));
const filter = userFilter(userIds, 'id');
const [users] = await pool.query(
`SELECT id, name, color, role, weekly_hours, is_external, company, job_title FROM pulse_users
WHERE is_active = 1 AND role <> 'admin' AND ${filter.sql} ORDER BY name`,
filter.params
);
const current = buckets[buckets.length - 1];
const previous = buckets[buckets.length - 2];
// Kapazitaet nur bis heute - sonst sieht die Auslastung am Montag schlecht aus.
const capTo = current.to > now ? now : current.to;
const capacity = users.reduce((s, u) => s + capacityMinutes(u, settings, current.from, capTo), 0);
const kpis = {
current: { ...series[series.length - 1], capacity_minutes: capacity },
previous: series[series.length - 2] ?? null,
period_label: current.label,
previous_label: previous?.label ?? null,
};
const rankTo = rankRange.to > now ? now : rankRange.to;
const ranking = users.map((u) => {
const own = entries.filter((e) => e.user_id === u.id && e.entry_date >= rankRange.from && e.entry_date <= rankRange.to);
const totals = sum(own);
return {
user_id: u.id,
name: u.name,
color: u.color,
job_title: u.job_title,
is_external: Boolean(u.is_external),
...totals,
entries: own.length,
capacity_minutes: capacityMinutes(u, settings, rankRange.from, rankTo),
};
});
// Projekte im aktuellen Zeitraum - fuer die Verteilung.
const byProject = new Map();
for (const e of entries.filter((e) => e.entry_date >= current.from && e.entry_date <= current.to)) {
const item = byProject.get(e.project_id) || {
project_id: e.project_id,
name: e.project_display,
customer_color: e.customer_color,
minutes: 0,
billable_minutes: 0,
};
item.minutes += e.minutes;
if (e.billable) item.billable_minutes += e.minutes;
byProject.set(e.project_id, item);
}
const recentFilter = userFilter(userIds, 'e.user_id');
const [recentIds] = await pool.query(
`SELECT e.id, e.entry_date FROM pulse_time_entries e WHERE ${recentFilter.sql} ORDER BY e.created_at DESC, e.id DESC LIMIT 12`,
recentFilter.params
);
let recent = [];
if (recentIds.length) {
const dates = recentIds.map((r) => r.entry_date).sort();
const pool_ = await loadEntries(settings, { from: dates[0], to: dates[dates.length - 1], userIds });
const byId = new Map(pool_.map((e) => [e.id, e]));
recent = recentIds
.map((r) => byId.get(r.id))
.filter(Boolean)
.map((e) => ({
id: e.id,
user_id: e.user_id,
user_name: e.user_name,
user_color: e.user_color,
project_display: e.project_display,
customer_color: e.customer_color,
task_name: e.task_name,
note: e.note,
entry_date: e.entry_date,
minutes: e.minutes,
billable: e.billable,
created_at: e.created_at,
}));
}
res.json({
granularity,
rank,
rank_range: rankRange,
scope: userIds === null ? 'all' : 'team',
team_size: users.length,
series,
kpis,
ranking,
projects: [...byProject.values()].sort((a, b) => b.minutes - a.minutes).slice(0, 8),
recent,
});
} catch (err) {
next(err);
}
});
export default router;
+88
View File
@@ -0,0 +1,88 @@
import express from 'express';
import { pool } from '../db.js';
import { getSettings } from '../lib/settings.js';
import { ALLOWED_DOMAINS, importUsers, listDirectory, ssoConfigured, syncUsers } from '../lib/sso.js';
/*
* SSO-Verwaltung: AD-Konten ansehen und nach Pulse uebernehmen.
* Zugriff ab Rolle Management (in server.js). Die Zugangsdaten selbst bleiben
* Administratoren vorbehalten (routes/settings.js).
*/
const router = express.Router();
const IMPORT_ROLES = ['user', 'lead', 'management'];
router.get('/ad-users', async (req, res) => {
const { sso } = await getSettings();
if (!ssoConfigured(sso)) {
return res.json({ configured: false, domains: ALLOWED_DOMAINS, users: [], fetched_at: null });
}
try {
const { users, fetched_at } = await listDirectory(sso, { refresh: req.query.refresh === '1' });
const [pulseUsers] = await pool.query('SELECT id, external_id, email, role, is_active, source FROM pulse_users');
const byExternal = new Map(pulseUsers.filter((u) => u.external_id).map((u) => [u.external_id, u]));
const byEmail = new Map(pulseUsers.filter((u) => u.email).map((u) => [u.email.toLowerCase(), u]));
res.json({
configured: true,
domains: ALLOWED_DOMAINS,
fetched_at,
users: users.map((ad) => {
const linked = byExternal.get(ad.id);
const sameMail = !linked ? byEmail.get(ad.email) : null;
return {
...ad,
pulse_user_id: linked?.id ?? null,
pulse_role: linked?.role ?? null,
pulse_active: linked ? Boolean(linked.is_active) : null,
// Lokales Konto mit derselben Mail - wird beim Uebernehmen verknuepft.
local_match_id: sameMail?.id ?? null,
};
}),
});
} catch (err) {
res.status(502).json({ error: `Microsoft Graph nicht erreichbar: ${err.message}` });
}
});
router.post('/import', async (req, res, next) => {
try {
const { sso } = await getSettings();
if (!ssoConfigured(sso)) return res.status(400).json({ error: 'SSO ist noch nicht eingerichtet.' });
const ids = Array.isArray(req.body?.ad_ids) ? [...new Set(req.body.ad_ids.map(String))].slice(0, 500) : [];
const role = req.body?.role;
if (!ids.length) return res.status(400).json({ error: 'Keine Benutzer ausgewählt.' });
if (!IMPORT_ROLES.includes(role)) return res.status(400).json({ error: 'Ungültige Rolle.' });
let managerId = req.body?.manager_id ? Number(req.body.manager_id) : null;
if (managerId) {
const [[manager]] = await pool.query('SELECT id, role, is_active FROM pulse_users WHERE id = ?', [managerId]);
if (!manager || !manager.is_active || !['lead', 'management'].includes(manager.role)) {
return res.status(400).json({ error: 'Der Vorgesetzte muss ein aktiver Vorgesetzter oder Manager sein.' });
}
}
// Ein Administrator in der Auswahl darf nicht herabgestuft werden.
const [admins] = await pool.query(
`SELECT external_id FROM pulse_users WHERE role = 'admin' AND external_id IN (${ids.map(() => '?').join(',')})`,
ids
);
if (admins.length) return res.status(400).json({ error: 'Administratoren können hier nicht geändert werden.' });
const result = await importUsers(sso, ids, { role, managerId });
res.json(result);
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Benutzername oder E-Mail ist schon vergeben.' });
if (/Graph|AADSTS|HTTP/.test(err.message)) return res.status(502).json({ error: `Microsoft Graph: ${err.message}` });
next(err);
}
});
router.post('/sync', async (req, res) => {
try {
const { sso } = await getSettings();
if (!ssoConfigured(sso)) return res.status(400).json({ error: 'SSO ist noch nicht eingerichtet.' });
res.json(await syncUsers(sso));
} catch (err) {
res.status(502).json({ error: `Abgleich fehlgeschlagen: ${err.message}` });
}
});
export default router;
+174
View File
@@ -0,0 +1,174 @@
import express from 'express';
import { pool, withTransaction } from '../db.js';
import { hasRole } from '../middleware/auth.js';
import { visibleUserIds } from '../lib/access.js';
import { getSettings } from '../lib/settings.js';
import { loadEntries } from '../lib/entries.js';
import { parseDate } from '../lib/dates.js';
import { BookingError, isBillable, loadProjects, resolvePot } from '../lib/projects.js';
const router = express.Router();
function publicEntry(entry, showAmounts) {
return {
id: entry.id,
user_id: entry.user_id,
user_name: entry.user_name,
project_id: entry.project_id,
project_name: entry.project_name,
project_code: entry.project_code,
project_display: entry.project_display,
project_type: entry.project_type,
customer_id: entry.customer_id,
customer_name: entry.customer_name,
customer_color: entry.customer_color,
task_id: entry.task_id,
task_name: entry.task_name,
pot_id: entry.pot_id,
pot_label: entry.pot_label,
entry_date: entry.entry_date,
minutes: entry.minutes,
note: entry.note,
billable: entry.billable,
amount: showAmounts ? entry.amount : null,
created_at: entry.created_at,
updated_at: entry.updated_at,
};
}
router.get('/', async (req, res, next) => {
try {
const { from, to } = req.query;
if (!parseDate(from) || !parseDate(to)) return res.status(400).json({ error: 'Zeitraum von/bis fehlt.' });
let userIds = [req.user.id];
if (req.query.user_id && Number(req.query.user_id) !== req.user.id) {
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 Zeiten.' });
userIds = [wanted];
}
const settings = await getSettings();
const entries = await loadEntries(settings, { from, to, userIds });
res.json(entries.map((e) => publicEntry(e, hasRole(req.user, 'lead'))));
} catch (err) {
next(err);
}
});
async function validate(conn, user, body, existing) {
const projectId = Number(body.project_id ?? existing?.project_id);
const date = body.entry_date ?? existing?.entry_date;
const minutes = Number(body.minutes ?? existing?.minutes);
const taskId = body.task_id !== undefined ? (body.task_id ? Number(body.task_id) : null) : existing?.task_id ?? null;
const note = body.note !== undefined ? String(body.note || '').trim().slice(0, 1000) || null : existing?.note ?? null;
if (user.role === 'admin' && !existing) throw new BookingError('Administratoren buchen keine Zeiten.');
if (!parseDate(date)) throw new BookingError('Datum fehlt.');
if (!Number.isInteger(minutes) || minutes < 1) throw new BookingError('Bitte eine Zeit größer 0:00 eingeben.');
if (minutes > 24 * 60) throw new BookingError('Mehr als 24 Stunden an einem Tag gehen nicht.');
const [projects] = await conn.query('SELECT * FROM pulse_projects WHERE id = ? LIMIT 1', [projectId]);
const project = projects[0];
if (!project) throw new BookingError('Projekt nicht gefunden.');
if (!project.is_active) throw new BookingError('Das Projekt ist archiviert und nicht mehr buchbar.');
// Gebucht wird nur auf zugewiesene Projekte - fuer jede Rolle.
const [member] = await conn.query('SELECT 1 FROM pulse_project_members WHERE project_id = ? AND user_id = ?', [
project.id,
existing?.user_id ?? user.id,
]);
if (!member.length) throw new BookingError('Dieses Projekt ist dir nicht zugewiesen.');
let task = null;
if (taskId) {
const [tasks] = await conn.query('SELECT * FROM pulse_tasks WHERE id = ? AND project_id = ?', [taskId, project.id]);
task = tasks[0];
if (!task) throw new BookingError('Der Task gehört nicht zu diesem Projekt.');
if (!task.is_active && task.id !== existing?.task_id) throw new BookingError('Der Task ist nicht mehr aktiv.');
}
const [[day]] = await conn.query(
'SELECT COALESCE(SUM(minutes), 0) AS total FROM pulse_time_entries WHERE user_id = ? AND entry_date = ? AND id <> ?',
[existing?.user_id ?? user.id, date, existing?.id || 0]
);
if (Number(day.total) + minutes > 24 * 60) throw new BookingError('An diesem Tag wären es mehr als 24 Stunden.');
const billable = isBillable(project, task);
const pot = await resolvePot(conn, project, date, minutes, existing?.id, billable);
return { project, task, pot, date, minutes, note, billable: billable ? 1 : 0 };
}
async function respond(req, res, id, status) {
const settings = await getSettings();
const [[row]] = await pool.query('SELECT entry_date, user_id, project_id, pot_id FROM pulse_time_entries WHERE id = ?', [id]);
const entries = await loadEntries(settings, { from: row.entry_date, to: row.entry_date, userIds: [row.user_id] });
const entry = entries.find((e) => e.id === id);
// Hinweis zum Topf gleich mitliefern, damit der Dialog warnen kann.
let warning = null;
if (row.pot_id) {
const [project] = await loadProjects(settings, { where: 'p.id = ?', params: [row.project_id] });
warning = project?.pots.find((p) => p.id === row.pot_id)?.warning ?? null;
}
res.status(status).json({ entry: publicEntry(entry, hasRole(req.user, 'lead')), warning });
}
router.post('/', async (req, res, next) => {
try {
const id = await withTransaction(async (conn) => {
const v = await validate(conn, req.user, req.body || {}, null);
const [result] = await conn.query(
`INSERT INTO pulse_time_entries (user_id, project_id, task_id, pot_id, entry_date, minutes, note, billable)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[req.user.id, v.project.id, v.task?.id ?? null, v.pot?.id ?? null, v.date, v.minutes, v.note, v.billable]
);
return result.insertId;
});
await respond(req, res, id, 201);
} catch (err) {
if (err instanceof BookingError) return res.status(422).json({ error: err.message });
next(err);
}
});
async function loadOwned(req) {
const [rows] = await pool.query('SELECT * FROM pulse_time_entries WHERE id = ? LIMIT 1', [Number(req.params.id)]);
const entry = rows[0];
if (!entry) return { error: [404, 'Buchung nicht gefunden.'] };
// Eigene Buchungen; Administratoren duerfen korrigieren.
if (entry.user_id !== req.user.id && req.user.role !== 'admin') {
return { error: [403, 'Nur eigene Buchungen können geändert werden.'] };
}
return { entry };
}
router.put('/:id', async (req, res, next) => {
try {
const { entry, error } = await loadOwned(req);
if (error) return res.status(error[0]).json({ error: error[1] });
await withTransaction(async (conn) => {
const v = await validate(conn, req.user, req.body || {}, entry);
await conn.query(
`UPDATE pulse_time_entries SET project_id = ?, task_id = ?, pot_id = ?, entry_date = ?, minutes = ?, note = ?, billable = ?
WHERE id = ?`,
[v.project.id, v.task?.id ?? null, v.pot?.id ?? null, v.date, v.minutes, v.note, v.billable, entry.id]
);
});
await respond(req, res, entry.id, 200);
} catch (err) {
if (err instanceof BookingError) return res.status(422).json({ error: err.message });
next(err);
}
});
router.delete('/:id', async (req, res, next) => {
try {
const { entry, error } = await loadOwned(req);
if (error) return res.status(error[0]).json({ error: error[1] });
await pool.query('DELETE FROM pulse_time_entries WHERE id = ?', [entry.id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
export default router;
+259
View File
@@ -0,0 +1,259 @@
import express from 'express';
import { pool } from '../db.js';
import { hasRole } from '../middleware/auth.js';
import { visibleUserIds } from '../lib/access.js';
import { getSettings } from '../lib/settings.js';
import { loadEntries } from '../lib/entries.js';
import { MONTHS_DE, formatDateDe, monthRange, parseDate } from '../lib/dates.js';
import { dateDe, durationDe, hoursDe, moneyDe, sendDocument } from '../export/render.js';
import { buildTimeReport } from './reports.js';
const router = express.Router();
const FORMATS = ['pdf', 'xlsx', 'csv'];
const WEEKDAYS = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
/**
* Stundennachweis eines Monats - die Vorlage fuer Kunden und Rechnungsstellung.
*
* ?month=2026-09 &format=pdf|xlsx|csv
* ?user_id=<id>|all eigener Nachweis (Standard), eines Teammitglieds oder aller sichtbaren
* ?customer_id / ?project_id auf einen Kunden bzw. ein Projekt einschraenken
* ?amounts=1 Satz und Betrag mit ausgeben (ab Rolle Vorgesetzter)
*/
router.get('/timesheet', async (req, res, next) => {
try {
const format = FORMATS.includes(req.query.format) ? req.query.format : 'pdf';
const range = monthRange(String(req.query.month || ''));
if (!range) return res.status(400).json({ error: 'Monat fehlt (JJJJ-MM).' });
let userIds = [req.user.id];
const wanted = req.query.user_id;
if (wanted && String(wanted) !== String(req.user.id)) {
const visible = await visibleUserIds(req.user);
if (wanted === 'all') {
if (!hasRole(req.user, 'lead')) return res.status(403).json({ error: 'Keine Einsicht in fremde Zeiten.' });
userIds = visible;
} else {
if (visible !== null && !visible.includes(Number(wanted))) {
return res.status(403).json({ error: 'Keine Einsicht in diese Zeiten.' });
}
userIds = [Number(wanted)];
}
}
const showAmounts = req.query.amounts === '1' && hasRole(req.user, 'lead');
const settings = await getSettings();
const entries = await loadEntries(settings, {
from: range.from,
to: range.to,
userIds,
customerId: req.query.customer_id,
projectId: req.query.project_id,
});
const [y, m] = req.query.month.split('-');
const monthName = `${MONTHS_DE[Number(m) - 1]} ${y}`;
const multiUser = userIds === null || userIds.length > 1;
let personName = 'Alle Mitarbeiter';
let company = '';
if (!multiUser) {
const [[person]] = await pool.query('SELECT name, company, is_external FROM pulse_users WHERE id = ?', [userIds[0]]);
personName = person?.name || '';
company = person?.is_external ? person.company || '' : settings.company_name;
}
let customerName = 'Alle Kunden';
let projectName = [...new Set(entries.map((e) => e.project_display))].join(', ') || '–';
if (req.query.customer_id) {
const [[c]] = await pool.query('SELECT name FROM pulse_customers WHERE id = ?', [Number(req.query.customer_id)]);
customerName = c?.name || '–';
} else {
const names = [...new Set(entries.map((e) => e.customer_name))];
if (names.length === 1) customerName = names[0];
}
if (projectName.length > 120) projectName = `${new Set(entries.map((e) => e.project_id)).size} Projekte`;
const total = entries.reduce((s, e) => s + e.minutes, 0);
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
const days = new Set(entries.map((e) => e.entry_date)).size;
const amount = entries.reduce((s, e) => s + e.amount, 0);
const columns = [
{ key: 'entry_date', label: 'Datum', type: 'date', width: 58, xlsWidth: 12 },
{ key: 'weekday', label: 'Tag', width: 26, xlsWidth: 6 },
...(multiUser ? [{ key: 'user_name', label: 'Mitarbeiter', width: 90, xlsWidth: 22 }] : []),
{ key: 'project_display', label: 'Projekt', width: 110, xlsWidth: 28 },
{ key: 'task_name', label: 'Task', width: 80, xlsWidth: 20 },
{ key: 'note', label: 'Tätigkeit', type: 'text', xlsWidth: 44 },
{ key: 'duration', label: 'Dauer', align: 'right', width: 38, xlsWidth: 9 },
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 46, xlsWidth: 10 },
...(showAmounts
? [
{ key: 'rate', label: 'Satz/h', type: 'money', align: 'right', width: 56, xlsWidth: 12 },
{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 66, xlsWidth: 14 },
]
: []),
];
const rows = entries.map((e) => ({
...e,
weekday: WEEKDAYS[new Date(`${e.entry_date}T00:00:00Z`).getUTCDay()],
duration: durationDe(e.minutes),
rate: e.amount && e.minutes ? Math.round((e.amount / (e.minutes / 60)) * 100) / 100 : null,
}));
// Zusammenfassung je Projekt und Task.
const summary = new Map();
for (const e of entries) {
const key = `${e.project_id}|${e.task_name || ''}`;
const item = summary.get(key) || { project_display: e.project_display, task_name: e.task_name || '–', minutes: 0, amount: 0 };
item.minutes += e.minutes;
item.amount += e.amount;
summary.set(key, item);
}
const doc = {
title: settings.export.title || 'Stundennachweis',
subtitle: monthName,
filename: `Stundennachweis ${y}-${m} ${multiUser ? customerName : personName}`,
landscape: showAmounts || multiUser,
info: [
[multiUser ? 'Mitarbeiter' : 'Berater', personName + (company && !multiUser ? ` (${company})` : '')],
['Kunde', customerName],
['Zeitraum', `${formatDateDe(range.from)} – ${formatDateDe(range.to)}`],
['Projekt', projectName],
],
kpis: [
['Gesamtstunden', hoursDe(total)],
['Dauer (h:mm)', durationDe(total)],
['Tage mit Leistung', String(days)],
...(showAmounts ? [['Betrag', moneyDe(amount)]] : [['davon abrechenbar', hoursDe(billable)]]),
],
tables: [
{
columns,
rows,
totals: { entry_date: null, weekday: 'Summe', duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) },
},
{
title: 'Zusammenfassung nach Projekt und Task',
columns: [
{ key: 'project_display', label: 'Projekt', xlsWidth: 28 },
{ key: 'task_name', label: 'Task', width: 150 },
{ key: 'duration', label: 'Dauer', align: 'right', width: 60 },
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 60 },
...(showAmounts ? [{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 80 }] : []),
],
rows: [...summary.values()].map((s) => ({ ...s, duration: durationDe(s.minutes) })),
totals: { project_display: 'Summe', duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) },
},
],
signature: true,
};
// CSV: nur die Buchungen - die Zusammenfassung stoert beim Weiterverarbeiten.
if (format === 'csv') doc.tables = [doc.tables[0]];
await sendDocument(res, doc, format, settings);
} catch (err) {
next(err);
}
});
const GROUPS = {
users: { title: 'Mitarbeiter', label: 'Mitarbeiter' },
projects: { title: 'Projekte', label: 'Projekt' },
clients: { title: 'Kunden', label: 'Kunde' },
tasks: { title: 'Tasks', label: 'Task' },
};
/** Export des Zeitreports in der gewaehlten Aufteilung oder als Detailliste. */
router.get('/report', async (req, res, next) => {
try {
if (!hasRole(req.user, 'lead')) return res.status(403).json({ error: 'Keine Berechtigung.' });
const format = FORMATS.includes(req.query.format) ? req.query.format : 'pdf';
if (!parseDate(req.query.from) || !parseDate(req.query.to)) return res.status(400).json({ error: 'Zeitraum fehlt.' });
const settings = await getSettings();
const report = await buildTimeReport(req.user, req.query);
const s = report.summary;
const groupKey = GROUPS[req.query.group] ? req.query.group : req.query.group === 'detail' ? 'detail' : 'users';
let customerName = null;
if (req.query.customer_id) {
const [[c]] = await pool.query('SELECT name FROM pulse_customers WHERE id = ?', [Number(req.query.customer_id)]);
customerName = c?.name;
}
let table;
if (groupKey === 'detail') {
table = {
columns: [
{ key: 'entry_date', label: 'Datum', type: 'date', width: 58, xlsWidth: 12 },
{ key: 'user_name', label: 'Mitarbeiter', width: 90, xlsWidth: 22 },
{ key: 'project_display', label: 'Projekt', width: 120, xlsWidth: 28 },
{ key: 'task_name', label: 'Task', width: 80, xlsWidth: 20 },
{ key: 'note', label: 'Tätigkeit', type: 'text', xlsWidth: 40 },
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 48, xlsWidth: 10 },
{ key: 'billable_label', label: 'Abr.', width: 30, xlsWidth: 6 },
{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 66, xlsWidth: 14 },
],
rows: report.entries.map((e) => ({
...e,
billable_label: e.billable ? 'ja' : 'nein',
amount: e.amount + (report.include_fixed ? e.fixed_amount : 0),
})),
totals: { user_name: 'Summe', minutes: s.minutes, amount: s.amount },
};
} else {
const rows = report[groupKey].map((row) => ({
...row,
billable_percent: row.minutes ? Math.round((row.billable_minutes / row.minutes) * 100) : 0,
}));
table = {
columns: [
{ key: 'name', label: GROUPS[groupKey].label, xlsWidth: 36 },
...(groupKey === 'projects' ? [{ key: 'customer_name', label: 'Kunde', width: 110, xlsWidth: 24 }] : []),
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 60, xlsWidth: 12 },
...(groupKey === 'users' ? [{ key: 'utilization', label: 'Auslastung', type: 'percent', align: 'right', width: 60, xlsWidth: 12 }] : []),
{ key: 'billable_minutes', label: 'Abrechenbar', type: 'hours', align: 'right', width: 66, xlsWidth: 13 },
{ key: 'billable_percent', label: 'Anteil', type: 'percent', align: 'right', width: 46, xlsWidth: 9 },
{ key: 'non_billable_minutes', label: 'Nicht abr.', type: 'hours', align: 'right', width: 60, xlsWidth: 12 },
{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 80, xlsWidth: 15 },
],
rows,
totals: {
name: 'Summe',
minutes: s.minutes,
billable_minutes: s.billable_minutes,
non_billable_minutes: s.non_billable_minutes,
amount: s.amount,
},
};
}
const title = groupKey === 'detail' ? 'Zeitreport – Detail' : `Zeitreport – ${GROUPS[groupKey].title}`;
await sendDocument(
res,
{
title,
subtitle: `${dateDe(report.range.from)} – ${dateDe(report.range.to)}`,
filename: `Zeitreport ${report.range.from} bis ${report.range.to}${customerName ? ` ${customerName}` : ''}`,
landscape: true,
sheetName: 'Zeitreport',
info: [
['Kunde', customerName || 'Alle Kunden'],
['Festpreisprojekte', report.include_fixed ? 'im Betrag enthalten' : 'nicht im Betrag enthalten'],
],
kpis: [
['Gesamtstunden', hoursDe(s.minutes)],
['Abrechenbar', `${hoursDe(s.billable_minutes)} (${s.minutes ? Math.round((s.billable_minutes / s.minutes) * 100) : 0} %)`],
['Nicht abrechenbar', hoursDe(s.non_billable_minutes)],
['Abrechenbarer Betrag', moneyDe(s.amount)],
],
tables: [table],
},
format,
settings
);
} catch (err) {
next(err);
}
});
export default router;
+385
View File
@@ -0,0 +1,385 @@
import express from 'express';
import { pool, withTransaction } from '../db.js';
import { hasRole, requireRole } from '../middleware/auth.js';
import { getSettings } from '../lib/settings.js';
import { parseDate } from '../lib/dates.js';
import {
deriveBudgetHours,
generateCode,
loadProjects,
reassignEntries,
stripAmounts,
} from '../lib/projects.js';
const router = express.Router();
const TYPES = ['billable', 'non_billable', 'fixed'];
const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
const num = (v) => (v === '' || v == null ? null : Number(v));
function validatePot(input, project, settings) {
const errors = [];
const pot = {
label: str(input.label, 160),
valid_from: input.valid_from,
valid_to: input.valid_to,
budget_amount: num(input.budget_amount),
rate: num(input.rate),
notes: str(input.notes, 500),
};
if (!pot.label) errors.push('Budgettopf: Bezeichnung fehlt.');
if (!parseDate(pot.valid_from) || !parseDate(pot.valid_to)) errors.push(`Budgettopf „${pot.label || ''}“: Laufzeit von/bis fehlt.`);
else if (pot.valid_from > pot.valid_to) errors.push(`Budgettopf „${pot.label}“: Laufzeit endet vor dem Beginn.`);
for (const key of ['budget_amount', 'rate']) {
if (pot[key] != null && !(Number.isFinite(pot[key]) && pot[key] >= 0)) errors.push(`Budgettopf „${pot.label}“: ungültiger Betrag.`);
}
if (project.type === 'billable') {
if (!pot.rate) errors.push(`Budgettopf „${pot.label}“: Stundensatz/Tagessatz fehlt.`);
if (pot.budget_amount == null && num(input.budget_hours) == null) {
errors.push(`Budgettopf „${pot.label}“: Budget in Euro oder Stunden angeben.`);
}
}
if (project.type === 'fixed' && !pot.budget_amount) errors.push(`Budgettopf „${pot.label}“: Festpreis fehlt.`);
if (project.type === 'non_billable') pot.rate = null;
pot.budget_hours = deriveBudgetHours({ ...input, budget_amount: pot.budget_amount, rate: pot.rate }, project, settings);
if (pot.budget_hours != null && !(pot.budget_hours > 0)) errors.push(`Budgettopf „${pot.label}“: Volumen muss größer 0 sein.`);
return { pot, errors };
}
function validateProject(body, { creating }) {
const errors = [];
const out = {};
if (creating || body.customer_id !== undefined) {
out.customer_id = Number(body.customer_id) || null;
if (!out.customer_id) errors.push('Kunde fehlt.');
}
if (creating || body.name !== undefined) {
out.name = str(body.name, 190);
if (!out.name) errors.push('Projektname fehlt.');
}
if (creating || body.type !== undefined) {
out.type = body.type || 'billable';
if (!TYPES.includes(out.type)) errors.push('Unbekannter Projekttyp.');
}
if (creating || body.billing_unit !== undefined) out.billing_unit = body.billing_unit === 'day' ? 'day' : 'hour';
if (creating || body.start_date !== undefined) {
out.start_date = body.start_date;
if (!parseDate(out.start_date)) errors.push('Startdatum fehlt.');
}
if (body.end_date !== undefined) {
out.end_date = body.end_date || null;
if (out.end_date && !parseDate(out.end_date)) errors.push('Enddatum ist ungültig.');
if (out.end_date && out.start_date && out.end_date < out.start_date) errors.push('Enddatum liegt vor dem Startdatum.');
}
if (body.tags !== undefined) {
const tags = Array.isArray(body.tags) ? body.tags : String(body.tags || '').split(',');
out.tags = JSON.stringify([...new Set(tags.map((t) => String(t).trim()).filter(Boolean))].slice(0, 20));
}
if (body.notes !== undefined) out.notes = str(body.notes, 5000);
if (body.owner_id !== undefined) out.owner_id = Number(body.owner_id) || null;
if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0;
return { out, errors };
}
async function findProject(id) {
const [rows] = await pool.query('SELECT * FROM pulse_projects WHERE id = ? LIMIT 1', [id]);
return rows[0] || null;
}
async function respondProject(req, res, id, status = 200) {
const settings = await getSettings();
const [project] = await loadProjects(settings, { where: 'p.id = ?', params: [id] });
res.status(status).json(hasRole(req.user, 'lead') ? project : stripAmounts(project));
}
async function setMembers(conn, projectId, userIds) {
let ids = [...new Set((userIds || []).map(Number).filter(Boolean))];
// Administratoren buchen keine Zeiten und werden deshalb nie zugewiesen.
if (ids.length) {
const [admins] = await conn.query(
`SELECT id FROM pulse_users WHERE role = 'admin' AND id IN (${ids.map(() => '?').join(',')})`,
ids
);
const blocked = new Set(admins.map((a) => a.id));
ids = ids.filter((id) => !blocked.has(id));
}
await conn.query('DELETE FROM pulse_project_members WHERE project_id = ?', [projectId]);
if (ids.length) {
await conn.query('INSERT INTO pulse_project_members (project_id, user_id) VALUES ?', [ids.map((id) => [projectId, id])]);
}
}
/** Abrechenbar-Merker aller Buchungen nach Aenderung von Projekttyp oder Task. */
async function refreshBillable(conn, projectId) {
await conn.query(
`UPDATE pulse_time_entries e
JOIN pulse_projects p ON p.id = e.project_id
LEFT JOIN pulse_tasks t ON t.id = e.task_id
SET e.billable = CASE WHEN p.type = 'non_billable' THEN 0 WHEN t.id IS NULL THEN 1 ELSE t.billable END
WHERE e.project_id = ?`,
[projectId]
);
}
// Liste. Mitarbeiter sehen nur zugewiesene Projekte, ohne Betraege.
// ?scope=mine liefert auch fuer Fuehrungsrollen nur die eigenen (Buchungsdialog).
router.get('/', async (req, res, next) => {
try {
const settings = await getSettings();
const mineOnly = req.user.role === 'user' || req.query.scope === 'mine';
const projects = await loadProjects(
settings,
mineOnly
? { where: 'p.id IN (SELECT project_id FROM pulse_project_members WHERE user_id = ?)', params: [req.user.id] }
: {}
);
res.json(hasRole(req.user, 'lead') ? projects : projects.map(stripAmounts));
} catch (err) {
next(err);
}
});
router.get('/:id', async (req, res, next) => {
try {
const id = Number(req.params.id);
if (req.user.role === 'user') {
const [rows] = await pool.query('SELECT 1 FROM pulse_project_members WHERE project_id = ? AND user_id = ?', [id, req.user.id]);
if (!rows.length) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
}
if (!(await findProject(id))) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
await respondProject(req, res, id);
} catch (err) {
next(err);
}
});
router.post('/', requireRole('lead'), async (req, res, next) => {
try {
const settings = await getSettings();
const body = req.body || {};
const { out, errors } = validateProject(body, { creating: true });
const pots = [];
const potInputs = Array.isArray(body.pots) ? body.pots : [];
if (potInputs.length === 0) errors.push('Mindestens ein Budgettopf ist erforderlich.');
for (const input of potInputs) {
const result = validatePot(input, out, settings);
errors.push(...result.errors);
pots.push(result.pot);
}
const [customers] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [out.customer_id || 0]);
if (out.customer_id && !customers[0]) errors.push('Kunde nicht gefunden.');
if (errors.length) return res.status(400).json({ error: [...new Set(errors)].join(' ') });
const id = await withTransaction(async (conn) => {
const { code, seq } = await generateCode(conn, settings, customers[0], out.start_date);
const fields = { ...out, code, seq, created_by: req.user.id, owner_id: out.owner_id ?? (req.user.role === 'admin' ? null : req.user.id) };
const keys = Object.keys(fields);
const [result] = await conn.query(
`INSERT INTO pulse_projects (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
keys.map((k) => fields[k])
);
const projectId = result.insertId;
for (const pot of pots) {
await conn.query(
`INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[projectId, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes]
);
}
const tasks = (Array.isArray(body.tasks) ? body.tasks : []).filter((t) => str(t?.name, 160));
for (const task of tasks) {
await conn.query('INSERT INTO pulse_tasks (project_id, name, billable) VALUES (?, ?, ?)', [
projectId,
str(task.name, 160),
out.type === 'non_billable' ? 0 : task.billable === false ? 0 : 1,
]);
}
await setMembers(conn, projectId, body.member_ids);
return projectId;
});
await respondProject(req, res, id, 201);
} catch (err) {
next(err);
}
});
router.put('/:id', requireRole('lead'), async (req, res, next) => {
try {
const project = await findProject(Number(req.params.id));
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
const body = req.body || {};
const { out, errors } = validateProject(
{ ...body, start_date: body.start_date ?? project.start_date },
{ creating: false }
);
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
await withTransaction(async (conn) => {
const keys = Object.keys(out);
if (keys.length) {
await conn.query(`UPDATE pulse_projects SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [
...keys.map((k) => out[k]),
project.id,
]);
}
if (Array.isArray(body.member_ids)) await setMembers(conn, project.id, body.member_ids);
if (out.type && out.type !== project.type) {
if (out.type === 'non_billable') await conn.query('UPDATE pulse_budget_pots SET rate = NULL WHERE project_id = ?', [project.id]);
await refreshBillable(conn, project.id);
await reassignEntries(conn, project.id);
}
});
await respondProject(req, res, project.id);
} catch (err) {
next(err);
}
});
router.delete('/:id', requireRole('lead'), async (req, res, next) => {
try {
const project = await findProject(Number(req.params.id));
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE project_id = ?', [project.id]);
if (Number(n) > 0) {
return res.status(409).json({ error: `Auf dem Projekt sind ${n} Buchungen. Bitte archivieren statt löschen.` });
}
await pool.query('DELETE FROM pulse_projects WHERE id = ?', [project.id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
router.put('/:id/members', requireRole('lead'), async (req, res, next) => {
try {
const project = await findProject(Number(req.params.id));
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
await withTransaction((conn) => setMembers(conn, project.id, req.body?.user_ids));
await respondProject(req, res, project.id);
} catch (err) {
next(err);
}
});
/* ------------------------------------------------------ Budgettoepfe -- */
router.post('/:id/pots', requireRole('lead'), async (req, res, next) => {
try {
const project = await findProject(Number(req.params.id));
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
const { pot, errors } = validatePot(req.body || {}, project, await getSettings());
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
await withTransaction(async (conn) => {
await conn.query(
`INSERT INTO pulse_budget_pots (project_id, label, valid_from, valid_to, budget_amount, rate, budget_hours, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[project.id, pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes]
);
await reassignEntries(conn, project.id);
});
await respondProject(req, res, project.id, 201);
} catch (err) {
next(err);
}
});
router.put('/:id/pots/:potId', requireRole('lead'), async (req, res, next) => {
try {
const project = await findProject(Number(req.params.id));
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
const [existing] = await pool.query('SELECT * FROM pulse_budget_pots WHERE id = ? AND project_id = ?', [
Number(req.params.potId),
project.id,
]);
if (!existing[0]) return res.status(404).json({ error: 'Budgettopf nicht gefunden.' });
const { pot, errors } = validatePot({ ...existing[0], ...req.body }, project, await getSettings());
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
await withTransaction(async (conn) => {
await conn.query(
`UPDATE pulse_budget_pots SET label = ?, valid_from = ?, valid_to = ?, budget_amount = ?, rate = ?, budget_hours = ?, notes = ?
WHERE id = ?`,
[pot.label, pot.valid_from, pot.valid_to, pot.budget_amount, pot.rate, pot.budget_hours, pot.notes, existing[0].id]
);
await reassignEntries(conn, project.id);
});
await respondProject(req, res, project.id);
} catch (err) {
next(err);
}
});
router.delete('/:id/pots/:potId', requireRole('lead'), async (req, res, next) => {
try {
const projectId = Number(req.params.id);
const potId = Number(req.params.potId);
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE pot_id = ?', [potId]);
if (Number(n) > 0) {
return res.status(409).json({ error: `Auf dem Topf sind ${n} Buchungen - er kann nicht gelöscht werden.` });
}
const [[{ pots }]] = await pool.query('SELECT COUNT(*) AS pots FROM pulse_budget_pots WHERE project_id = ?', [projectId]);
if (Number(pots) <= 1) return res.status(400).json({ error: 'Ein Projekt braucht mindestens einen Budgettopf.' });
await pool.query('DELETE FROM pulse_budget_pots WHERE id = ? AND project_id = ?', [potId, projectId]);
await respondProject(req, res, projectId);
} catch (err) {
next(err);
}
});
/* ------------------------------------------------------------- Tasks -- */
router.post('/:id/tasks', requireRole('lead'), async (req, res, next) => {
try {
const project = await findProject(Number(req.params.id));
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
const name = str(req.body?.name, 160);
if (!name) return res.status(400).json({ error: 'Taskname fehlt.' });
await pool.query('INSERT INTO pulse_tasks (project_id, name, billable) VALUES (?, ?, ?)', [
project.id,
name,
project.type === 'non_billable' ? 0 : req.body?.billable === false ? 0 : 1,
]);
await respondProject(req, res, project.id, 201);
} catch (err) {
next(err);
}
});
router.put('/:id/tasks/:taskId', requireRole('lead'), async (req, res, next) => {
try {
const projectId = Number(req.params.id);
const [rows] = await pool.query('SELECT * FROM pulse_tasks WHERE id = ? AND project_id = ?', [Number(req.params.taskId), projectId]);
if (!rows[0]) return res.status(404).json({ error: 'Task nicht gefunden.' });
const name = req.body?.name !== undefined ? str(req.body.name, 160) : rows[0].name;
if (!name) return res.status(400).json({ error: 'Taskname fehlt.' });
const billable = req.body?.billable !== undefined ? (req.body.billable ? 1 : 0) : rows[0].billable;
const active = req.body?.is_active !== undefined ? (req.body.is_active ? 1 : 0) : rows[0].is_active;
await withTransaction(async (conn) => {
await conn.query('UPDATE pulse_tasks SET name = ?, billable = ?, is_active = ? WHERE id = ?', [name, billable, active, rows[0].id]);
if (billable !== rows[0].billable) {
await refreshBillable(conn, projectId);
await reassignEntries(conn, projectId);
}
});
await respondProject(req, res, projectId);
} catch (err) {
next(err);
}
});
router.delete('/:id/tasks/:taskId', requireRole('lead'), async (req, res, next) => {
try {
const projectId = Number(req.params.id);
const taskId = Number(req.params.taskId);
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE task_id = ?', [taskId]);
if (Number(n) > 0) {
await pool.query('UPDATE pulse_tasks SET is_active = 0 WHERE id = ? AND project_id = ?', [taskId, projectId]);
} else {
await pool.query('DELETE FROM pulse_tasks WHERE id = ? AND project_id = ?', [taskId, projectId]);
}
await respondProject(req, res, projectId);
} catch (err) {
next(err);
}
});
export default router;
+234
View File
@@ -0,0 +1,234 @@
import express from 'express';
import { pool } from '../db.js';
import { teamOf, userFilter, visibleUserIds } from '../lib/access.js';
import { getSettings } from '../lib/settings.js';
import { loadEntries } from '../lib/entries.js';
import { loadProjects } from '../lib/projects.js';
import { addDays, mondayOf, parseDate, today, isoWeek } from '../lib/dates.js';
import { capacityMinutes } from './dashboard.js';
const router = express.Router();
const r2 = (n) => Math.round(n * 100) / 100;
/** Welche Benutzer fliessen ein: Sicht des Anfragenden, optional auf ein Team verengt. */
export async function reportUserIds(viewer, teamLeadId) {
const visible = await visibleUserIds(viewer);
if (!teamLeadId) return visible;
const team = [Number(teamLeadId), ...(await teamOf(Number(teamLeadId)))];
return visible === null ? team : team.filter((id) => visible.includes(id));
}
function group(entries, keyFn, labelFn, includeFixed) {
const map = new Map();
for (const e of entries) {
const key = keyFn(e);
const item = map.get(key) || { key, ...labelFn(e), minutes: 0, billable_minutes: 0, amount: 0 };
item.minutes += e.minutes;
if (e.billable) item.billable_minutes += e.minutes;
item.amount += e.amount + (includeFixed ? e.fixed_amount : 0);
map.set(key, item);
}
return [...map.values()]
.map((i) => ({ ...i, amount: r2(i.amount), non_billable_minutes: i.minutes - i.billable_minutes }))
.sort((a, b) => b.minutes - a.minutes);
}
/** Zeitreport wie in Harvest: Summen plus Aufteilung nach Kunde, Projekt, Task, Mitarbeiter. */
export async function buildTimeReport(viewer, query) {
const settings = await getSettings();
const from = query.from;
const to = query.to;
const includeFixed = query.include_fixed === '1' || query.include_fixed === 'true';
const userIds = await reportUserIds(viewer, query.team);
const entries = await loadEntries(settings, {
from,
to,
userIds,
customerId: query.customer_id,
projectId: query.project_id,
});
const filter = userFilter(userIds, 'id');
const [users] = await pool.query(
`SELECT id, name, color, weekly_hours, is_external, company, is_active FROM pulse_users WHERE role <> 'admin' AND ${filter.sql} ORDER BY name`,
filter.params
);
const capTo = to > today() ? today() : to;
const minutes = entries.reduce((s, e) => s + e.minutes, 0);
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
const amount = entries.reduce((s, e) => s + e.amount, 0);
const fixedAmount = entries.reduce((s, e) => s + e.fixed_amount, 0);
const userRows = group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color, is_external: e.is_external }), includeFixed);
// Mitarbeiter ohne Buchung trotzdem zeigen (nur aktive, nicht bei Kunden-/Projektfilter).
if (!query.customer_id && !query.project_id) {
for (const u of users) {
if (u.is_active && !userRows.some((row) => row.user_id === u.id)) {
userRows.push({ key: u.id, user_id: u.id, name: u.name, color: u.color, is_external: Boolean(u.is_external), minutes: 0, billable_minutes: 0, non_billable_minutes: 0, amount: 0 });
}
}
}
const byId = new Map(users.map((u) => [u.id, u]));
for (const row of userRows) {
const u = byId.get(row.user_id);
row.capacity_minutes = u && from <= capTo ? capacityMinutes(u, settings, from, capTo) : 0;
row.utilization = row.capacity_minutes ? Math.round((row.minutes / row.capacity_minutes) * 100) : null;
}
return {
range: { from, to },
include_fixed: includeFixed,
summary: {
minutes,
billable_minutes: billable,
non_billable_minutes: minutes - billable,
amount: r2(amount + (includeFixed ? fixedAmount : 0)),
amount_without_fixed: r2(amount),
fixed_amount: r2(fixedAmount),
entries: entries.length,
},
clients: group(entries, (e) => e.customer_id, (e) => ({ customer_id: e.customer_id, name: e.customer_name, color: e.customer_color }), includeFixed),
projects: group(
entries,
(e) => e.project_id,
(e) => ({ project_id: e.project_id, name: e.project_display, code: e.project_code, type: e.project_type, customer_name: e.customer_name, color: e.customer_color }),
includeFixed
),
tasks: group(entries, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), includeFixed),
users: userRows.sort((a, b) => a.name.localeCompare(b.name, 'de')),
entries,
};
}
router.get('/time', async (req, res, next) => {
try {
if (!parseDate(req.query.from) || !parseDate(req.query.to)) return res.status(400).json({ error: 'Zeitraum fehlt.' });
const report = await buildTimeReport(req.user, req.query);
delete report.entries;
res.json(report);
} catch (err) {
next(err);
}
});
/** Projektreport (Harvest-Projektseite): Verlauf, Budget, Tasks, Team, Toepfe. */
router.get('/project/:id', async (req, res, next) => {
try {
const settings = await getSettings();
const [project] = await loadProjects(settings, { where: 'p.id = ?', params: [Number(req.params.id)] });
if (!project) return res.status(404).json({ error: 'Projekt nicht gefunden.' });
const userIds = await visibleUserIds(req.user);
const entries = await loadEntries(settings, { projectId: project.id, userIds, from: req.query.from, to: req.query.to });
res.json(buildProjectDetail(project, entries));
} catch (err) {
next(err);
}
});
function buildProjectDetail(project, entries) {
const includeFixed = project.type === 'fixed';
// Wochenreihe vom Projektstart bis max(Ende, heute) - hoechstens 104 Wochen.
const end = [project.end_date || today(), today(), ...project.pots.map((p) => p.valid_to)].sort().pop();
const lastMonday = mondayOf(end);
let start = [mondayOf(project.start_date), addDays(lastMonday, -103 * 7)].sort().pop();
const weeks = [];
while (start <= lastMonday) {
weeks.push(start);
start = addDays(start, 7);
}
let cumulative = entries.filter((e) => e.entry_date < weeks[0]).reduce((s, e) => s + e.minutes, 0);
const weekly = weeks.map((monday) => {
const sunday = addDays(monday, 6);
const own = entries.filter((e) => e.entry_date >= monday && e.entry_date <= sunday);
const minutes = own.reduce((s, e) => s + e.minutes, 0);
const billable = own.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
cumulative += minutes;
return {
week_start: monday,
label: `KW ${isoWeek(monday).week}`,
minutes,
billable_minutes: billable,
cumulative_minutes: monday <= today() ? cumulative : null,
};
});
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 {
project,
totals: {
minutes,
billable_minutes: billable,
non_billable_minutes: minutes - billable,
amount: r2(entries.reduce((s, e) => s + e.amount + e.fixed_amount, 0)),
},
weekly,
tasks: group(entries, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), includeFixed),
team: group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color }), includeFixed),
pots: project.pots.map((pot) => {
const own = entries.filter((e) => e.pot_id === pot.id);
return {
...pot,
tasks: group(own, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), includeFixed),
team: group(own, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color }), includeFixed),
};
}),
};
}
/** Kundenreport: alle Projekte eines Kunden mit Budget, Verlauf und Team. */
router.get('/customer/:id', async (req, res, next) => {
try {
const settings = await getSettings();
const [[customer]] = await pool.query('SELECT * FROM pulse_customers WHERE id = ?', [Number(req.params.id)]);
if (!customer) return res.status(404).json({ error: 'Kunde nicht gefunden.' });
const projects = await loadProjects(settings, { where: 'p.customer_id = ?', params: [customer.id] });
const userIds = await visibleUserIds(req.user);
const entries = await loadEntries(settings, { customerId: customer.id, userIds, from: req.query.from, to: req.query.to });
// Monatsreihe ueber die ganze Kundenbeziehung, hoechstens 24 Monate.
const months = [...new Set(entries.map((e) => e.entry_date.slice(0, 7)))].sort();
const first = months[0] || today().slice(0, 7);
const series = [];
const cursor = new Date(`${first}-01T00:00:00Z`);
const last = today().slice(0, 7);
while (cursor.toISOString().slice(0, 7) <= last && series.length < 36) {
const month = cursor.toISOString().slice(0, 7);
const own = entries.filter((e) => e.entry_date.startsWith(month));
series.push({
month,
minutes: own.reduce((s, e) => s + e.minutes, 0),
billable_minutes: own.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0),
amount: r2(own.reduce((s, e) => s + e.amount + e.fixed_amount, 0)),
});
cursor.setUTCMonth(cursor.getUTCMonth() + 1);
}
const minutes = entries.reduce((s, e) => s + e.minutes, 0);
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
res.json({
customer,
totals: {
minutes,
billable_minutes: billable,
non_billable_minutes: minutes - billable,
amount: r2(entries.reduce((s, e) => s + e.amount + e.fixed_amount, 0)),
earned: r2(projects.reduce((s, p) => s + p.earned, 0)),
open: r2(projects.reduce((s, p) => s + p.open, 0)),
budget_value: r2(projects.reduce((s, p) => s + p.budget_value, 0)),
},
series: series.slice(-24),
projects: projects.map((p) => {
const own = entries.filter((e) => e.project_id === p.id);
return { ...buildProjectDetail(p, own), weekly: undefined };
}),
team: group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color }), true),
tasks: group(entries, (e) => e.task_name || '(ohne Task)', (e) => ({ name: e.task_name || '(ohne Task)' }), true),
});
} catch (err) {
next(err);
}
});
export default router;
+182
View File
@@ -0,0 +1,182 @@
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 { 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();
res.json({ ...s, sso: { ...s.sso, client_secret: '', client_secret_set: Boolean(s.sso.client_secret), configured: ssoConfigured(s.sso) } });
} 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.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);
}
});
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;
+229
View File
@@ -0,0 +1,229 @@
import express from 'express';
import bcrypt from 'bcryptjs';
import { pool } from '../db.js';
import { ROLES, USER_COLUMNS, publicUser, requireRole } from '../middleware/auth.js';
import { visibleUserIds } from '../lib/access.js';
const router = express.Router();
/*
* Wer darf wen verwalten?
* admin alle
* management alle ausser Administratoren, vergibt hoechstens "management"
* lead nur Mitarbeiter (Rolle user) im eigenen Team, vergibt nur "user"
*/
function assignableRoles(actor) {
if (actor.role === 'admin') return ROLES;
if (actor.role === 'management') return ['user', 'lead', 'management'];
return ['user'];
}
async function canManage(actor, target) {
if (actor.role === 'admin') return true;
if (actor.role === 'management') return target.role !== 'admin';
if (actor.role === 'lead') {
if (target.role !== 'user') return false;
const team = await visibleUserIds(actor);
return team.includes(target.id);
}
return false;
}
async function loadUser(id) {
const [rows] = await pool.query(`SELECT ${USER_COLUMNS} FROM pulse_users WHERE id = ? LIMIT 1`, [id]);
return rows[0] || null;
}
/** Verhindert, dass jemand (indirekt) sein eigener Vorgesetzter wird. */
async function createsCycle(userId, managerId) {
if (!managerId || !userId) return false;
const [rows] = await pool.query('SELECT id, manager_id FROM pulse_users');
const parent = new Map(rows.map((r) => [r.id, r.manager_id]));
let current = managerId;
for (let guard = 0; current && guard < 1000; guard++) {
if (current === userId) return true;
current = parent.get(current);
}
return false;
}
async function activeAdmins(excludeId) {
const [rows] = await pool.query(
"SELECT COUNT(*) AS n FROM pulse_users WHERE role = 'admin' AND is_active = 1 AND id <> ?",
[excludeId]
);
return Number(rows[0].n);
}
function normalize(body, { creating }) {
const errors = [];
const out = {};
const str = (v, max) => (v == null ? null : String(v).trim().slice(0, max) || null);
if (creating || body.name !== undefined) {
out.name = str(body.name, 160);
if (!out.name) errors.push('Name fehlt.');
}
if (creating || body.username !== undefined) {
out.username = str(body.username, 80);
if (!out.username || !/^[a-zA-Z0-9._@-]{2,80}$/.test(out.username)) errors.push('Benutzername: 2-80 Zeichen, keine Leerzeichen.');
}
if (body.email !== undefined) {
out.email = str(body.email, 190)?.toLowerCase() ?? null;
if (out.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(out.email)) errors.push('E-Mail-Adresse ist ungültig.');
}
if (body.role !== undefined || creating) {
out.role = body.role || 'user';
if (!ROLES.includes(out.role)) errors.push('Unbekannte Rolle.');
}
if (body.manager_id !== undefined) out.manager_id = body.manager_id ? Number(body.manager_id) : null;
if (body.job_title !== undefined) out.job_title = str(body.job_title, 120);
if (body.is_external !== undefined) out.is_external = body.is_external ? 1 : 0;
if (body.company !== undefined) out.company = str(body.company, 160);
if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null;
if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0;
if (body.weekly_hours !== undefined) {
out.weekly_hours = body.weekly_hours === '' || body.weekly_hours == null ? null : Number(body.weekly_hours);
if (out.weekly_hours != null && !(out.weekly_hours >= 0 && out.weekly_hours <= 80)) errors.push('Wochenstunden: 0-80.');
}
return { out, errors };
}
// Verzeichnis fuer alle: Namen, Farben und Hierarchie (ohne Kontaktdaten).
router.get('/directory', async (req, res, next) => {
try {
const [rows] = await pool.query(
'SELECT id, name, color, role, manager_id, is_external, company, job_title, is_active FROM pulse_users ORDER BY name'
);
res.json(rows.map((r) => ({ ...r, is_external: Boolean(r.is_external), is_active: Boolean(r.is_active) })));
} catch (err) {
next(err);
}
});
router.get('/', requireRole('lead'), async (req, res, next) => {
try {
const [rows] = await pool.query(
`SELECT ${USER_COLUMNS.split(',').map((c) => `u.${c.trim()}`).join(', ')},
(SELECT COUNT(*) FROM pulse_time_entries e WHERE e.user_id = u.id) AS entry_count,
(SELECT COUNT(*) FROM pulse_project_members m WHERE m.user_id = u.id) AS project_count
FROM pulse_users u ORDER BY u.name`
);
const team = await visibleUserIds(req.user);
const result = [];
for (const row of rows) {
result.push({
...publicUser(row),
entry_count: Number(row.entry_count),
project_count: Number(row.project_count),
can_manage: await canManage(req.user, row),
in_team: team === null || team.includes(row.id),
});
}
res.json({ users: result, assignable_roles: assignableRoles(req.user) });
} catch (err) {
next(err);
}
});
router.post('/', requireRole('lead'), async (req, res, next) => {
const { out, errors } = normalize(req.body || {}, { creating: true });
const password = String(req.body?.password || '');
if (password.length < 8) errors.push('Passwort: mindestens 8 Zeichen.');
if (!assignableRoles(req.user).includes(out.role)) errors.push('Diese Rolle darfst du nicht vergeben.');
if (req.user.role === 'lead') {
const team = await visibleUserIds(req.user);
if (!out.manager_id || !team.includes(out.manager_id)) out.manager_id = req.user.id;
}
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
try {
const hash = await bcrypt.hash(password, 12);
const fields = { ...out, password_hash: hash, source: 'local' };
const keys = Object.keys(fields);
const [result] = await pool.query(
`INSERT INTO pulse_users (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`,
keys.map((k) => fields[k])
);
res.status(201).json(publicUser(await loadUser(result.insertId)));
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Benutzername oder E-Mail ist schon vergeben.' });
next(err);
}
});
router.put('/:id', requireRole('lead'), async (req, res, next) => {
try {
const target = await loadUser(Number(req.params.id));
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden.' });
if (!(await canManage(req.user, target))) return res.status(403).json({ error: 'Diesen Benutzer darfst du nicht bearbeiten.' });
const { out, errors } = normalize(req.body || {}, { creating: false });
if (out.role && out.role !== target.role && !assignableRoles(req.user).includes(out.role)) {
errors.push('Diese Rolle darfst du nicht vergeben.');
}
// SSO-Benutzer: Stammdaten kommen aus dem AD.
if (target.source === 'sso') {
delete out.username;
delete out.email;
}
if (out.manager_id !== undefined && (await createsCycle(target.id, out.manager_id))) {
errors.push('Die Hierarchie darf keinen Kreis bilden.');
}
if (req.user.role === 'lead' && out.manager_id !== undefined) {
const team = await visibleUserIds(req.user);
if (!out.manager_id || !team.includes(out.manager_id)) errors.push('Vorgesetzter muss aus deinem Team kommen.');
}
if (target.id === req.user.id && (out.is_active === 0 || (out.role && out.role !== target.role))) {
errors.push('Die eigene Rolle und Aktivierung können nicht geändert werden.');
}
if (target.role === 'admin' && ((out.role && out.role !== 'admin') || out.is_active === 0) && (await activeAdmins(target.id)) === 0) {
errors.push('Der letzte aktive Administrator kann nicht herabgestuft oder deaktiviert werden.');
}
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
if (req.body?.password) {
if (String(req.body.password).length < 8) return res.status(400).json({ error: 'Passwort: mindestens 8 Zeichen.' });
out.password_hash = await bcrypt.hash(String(req.body.password), 12);
}
const keys = Object.keys(out);
if (keys.length) {
await pool.query(`UPDATE pulse_users SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`, [
...keys.map((k) => out[k]),
target.id,
]);
}
res.json(publicUser(await loadUser(target.id)));
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') return res.status(409).json({ error: 'Benutzername oder E-Mail ist schon vergeben.' });
next(err);
}
});
router.delete('/:id', requireRole('lead'), async (req, res, next) => {
try {
const target = await loadUser(Number(req.params.id));
if (!target) return res.status(404).json({ error: 'Benutzer nicht gefunden.' });
if (target.id === req.user.id) return res.status(400).json({ error: 'Das eigene Konto kann nicht gelöscht werden.' });
if (!(await canManage(req.user, target))) return res.status(403).json({ error: 'Diesen Benutzer darfst du nicht löschen.' });
if (target.source !== 'local') {
return res.status(400).json({ error: 'SSO-Benutzer werden über das AD verwaltet - hier nur deaktivieren.' });
}
if (target.role === 'admin' && (await activeAdmins(target.id)) === 0) {
return res.status(400).json({ error: 'Der letzte Administrator kann nicht gelöscht werden.' });
}
const [[{ n }]] = await pool.query('SELECT COUNT(*) AS n FROM pulse_time_entries WHERE user_id = ?', [target.id]);
if (Number(n) > 0) {
return res.status(409).json({
error: `Der Benutzer hat ${n} Buchungen. Damit Reports stimmen, bitte deaktivieren statt löschen.`,
});
}
await pool.query('UPDATE pulse_users SET manager_id = ? WHERE manager_id = ?', [target.manager_id, target.id]);
await pool.query('DELETE FROM pulse_users WHERE id = ?', [target.id]);
res.json({ ok: true });
} catch (err) {
next(err);
}
});
export default router;