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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user