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,94 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import mysql from 'mysql2/promise';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
user: process.env.DB_USER || 'pulse',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_NAME || 'perinco_pulse',
|
||||
waitForConnections: true,
|
||||
// Klein gehalten: dieselbe MariaDB bedient weitere Anwendungen.
|
||||
connectionLimit: 8,
|
||||
charset: 'utf8mb4',
|
||||
dateStrings: true,
|
||||
decimalNumbers: true,
|
||||
});
|
||||
|
||||
/** Fuehrt schema.sql aus und legt bei leerer Benutzertabelle den Admin an. */
|
||||
export async function initDatabase() {
|
||||
const sql = await fs.readFile(path.join(__dirname, 'schema.sql'), 'utf8');
|
||||
// Split an Semikolon am Zeilenende - so braucht es kein multipleStatements.
|
||||
const statements = sql
|
||||
.split('\n')
|
||||
.filter((line) => !line.trim().startsWith('--'))
|
||||
.join('\n')
|
||||
.split(/;\s*$/m)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
for (const statement of statements) await conn.query(statement);
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
|
||||
await ensureAdminUser();
|
||||
await ensureInternalCustomer();
|
||||
}
|
||||
|
||||
async function ensureAdminUser() {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users');
|
||||
if (rows[0].n > 0) return;
|
||||
|
||||
const password = process.env.ADMIN_PASSWORD;
|
||||
if (!password) throw new Error('ADMIN_PASSWORD fehlt - erster Administrator kann nicht angelegt werden.');
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
await pool.query(
|
||||
`INSERT INTO pulse_users (username, email, name, password_hash, role)
|
||||
VALUES (?, ?, ?, ?, 'admin')`,
|
||||
[
|
||||
process.env.ADMIN_USERNAME || 'admin',
|
||||
(process.env.ADMIN_EMAIL || 'admin@perinco.de').toLowerCase(),
|
||||
process.env.ADMIN_NAME || 'Administrator',
|
||||
hash,
|
||||
]
|
||||
);
|
||||
console.log('[Pulse] Erster Administrator angelegt.');
|
||||
}
|
||||
|
||||
/*
|
||||
* perinco selbst ist der Kunde fuer alle internen Taetigkeiten ("perinco
|
||||
* intern"). Wird einmalig angelegt, damit direkt nach der Installation ein
|
||||
* Projekt fuer nicht abrechenbare Zeiten angelegt werden kann.
|
||||
*/
|
||||
async function ensureInternalCustomer() {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_customers');
|
||||
if (rows[0].n > 0) return;
|
||||
await pool.query(
|
||||
`INSERT INTO pulse_customers (name, short_name, notes, color) VALUES (?, ?, ?, ?)`,
|
||||
['perinco GmbH', 'PERINCO', 'Interne Tätigkeiten (nicht abrechenbar)', '#6ba04a']
|
||||
);
|
||||
}
|
||||
|
||||
/** Kleiner Helfer fuer Transaktionen. */
|
||||
export async function withTransaction(fn) {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
const result = await fn(conn);
|
||||
await conn.commit();
|
||||
return result;
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
throw err;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import ExcelJS from 'exceljs';
|
||||
|
||||
/*
|
||||
* Gemeinsamer Unterbau fuer alle Exporte. Ein Export wird als neutrales
|
||||
* Dokument beschrieben (Titel, Infoblock, Kennzahlen, Tabellen) und von hier
|
||||
* aus als PDF, Excel oder CSV geschrieben - so sehen Stundenzettel und Reports
|
||||
* in allen drei Formaten gleich aufgebaut aus und tragen denselben Kopf und Fuss
|
||||
* aus den Einstellungen.
|
||||
*
|
||||
* doc = {
|
||||
* title, subtitle, filename,
|
||||
* info: [[Label, Wert], ...],
|
||||
* kpis: [[Label, Wert], ...],
|
||||
* tables: [{ title?, columns: [{ key, label, width?, align?, type? }], rows: [...], totals?: {...} }],
|
||||
* signature: boolean,
|
||||
* landscape: boolean,
|
||||
* }
|
||||
* type: 'hours' (Minuten -> 1,50), 'duration' (Minuten -> 1:30), 'money', 'date', 'text', 'percent'
|
||||
*/
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DEFAULT_LOGO = path.join(__dirname, '..', '..', 'assets', 'logo.png');
|
||||
|
||||
export function hoursDe(minutes) {
|
||||
return (minutes / 60).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
export function durationDe(minutes) {
|
||||
const m = Math.round(minutes);
|
||||
return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}`;
|
||||
}
|
||||
export function moneyDe(value) {
|
||||
return `${Number(value || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} €`;
|
||||
}
|
||||
export function dateDe(value) {
|
||||
const [y, m, d] = String(value).slice(0, 10).split('-');
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
function formatCell(value, type) {
|
||||
if (value == null || value === '') return '';
|
||||
switch (type) {
|
||||
case 'hours':
|
||||
return hoursDe(value);
|
||||
case 'duration':
|
||||
return durationDe(value);
|
||||
case 'money':
|
||||
return moneyDe(value);
|
||||
case 'date':
|
||||
return dateDe(value);
|
||||
case 'percent':
|
||||
return `${value} %`;
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function logoBuffer(exportSettings) {
|
||||
const logo = exportSettings.logo;
|
||||
if (typeof logo === 'string' && logo.startsWith('data:image/')) {
|
||||
const match = logo.match(/^data:image\/(png|jpe?g);base64,(.+)$/);
|
||||
if (match) return Buffer.from(match[2], 'base64');
|
||||
}
|
||||
return fs.readFileSync(DEFAULT_LOGO);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ CSV -- */
|
||||
|
||||
export function renderCsv(doc) {
|
||||
const escape = (v) => {
|
||||
const s = String(v ?? '');
|
||||
return /[;"\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
|
||||
};
|
||||
const lines = [];
|
||||
doc.tables.forEach((table, index) => {
|
||||
if (index > 0) lines.push('');
|
||||
if (doc.tables.length > 1 && table.title) lines.push(escape(table.title));
|
||||
lines.push(table.columns.map((c) => escape(c.label)).join(';'));
|
||||
for (const row of table.rows) {
|
||||
lines.push(table.columns.map((c) => escape(formatCell(row[c.key], c.type))).join(';'));
|
||||
}
|
||||
if (table.totals) lines.push(table.columns.map((c) => escape(formatCell(table.totals[c.key], c.type))).join(';'));
|
||||
});
|
||||
// BOM, damit Excel die Umlaute als UTF-8 liest.
|
||||
return Buffer.from('' + lines.join('\r\n'), 'utf8');
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Excel -- */
|
||||
|
||||
export async function renderXlsx(doc, settings) {
|
||||
const ex = settings.export;
|
||||
const accent = (ex.accent || '#6BA04A').replace('#', '').toUpperCase();
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = settings.company_name || 'perinco';
|
||||
workbook.created = new Date();
|
||||
|
||||
const sheet = workbook.addWorksheet(doc.sheetName || 'Stundennachweis', {
|
||||
pageSetup: {
|
||||
paperSize: 9,
|
||||
orientation: doc.landscape ? 'landscape' : 'portrait',
|
||||
fitToPage: true,
|
||||
fitToWidth: 1,
|
||||
fitToHeight: 0,
|
||||
margins: { left: 0.5, right: 0.5, top: 0.6, bottom: 0.8, header: 0.3, footer: 0.3 },
|
||||
},
|
||||
headerFooter: {
|
||||
oddFooter: `&L&8${xlEsc(ex.footer_left)}&C&8${xlEsc(ex.footer_center)}&R&8${xlEsc(ex.footer_right)} · Seite &P von &N`,
|
||||
},
|
||||
views: [{ showGridLines: false }],
|
||||
});
|
||||
|
||||
const widest = doc.tables.reduce((m, t) => Math.max(m, t.columns.length), 2);
|
||||
// Spaltenbreiten aus der ersten (Haupt-)Tabelle.
|
||||
sheet.columns = doc.tables[0].columns.map((c) => ({ width: c.xlsWidth || Math.max(10, (c.width || 80) / 5.5) }));
|
||||
|
||||
const logo = workbook.addImage({ buffer: logoBuffer(ex), extension: /^data:image\/jpe?g/.test(ex.logo || '') ? 'jpeg' : 'png' });
|
||||
sheet.addImage(logo, { tl: { col: 0, row: 0 }, ext: { width: 170, height: 77 } });
|
||||
sheet.getRow(1).height = 22;
|
||||
sheet.getRow(2).height = 22;
|
||||
sheet.getRow(3).height = 22;
|
||||
const headerRight = sheet.getCell(1, widest);
|
||||
headerRight.value = ex.header_right || '';
|
||||
headerRight.alignment = { horizontal: 'right' };
|
||||
headerRight.font = { size: 9, color: { argb: 'FF6E6E6E' } };
|
||||
|
||||
let row = 5;
|
||||
const title = sheet.getCell(row, 1);
|
||||
title.value = doc.title;
|
||||
title.font = { size: 18, bold: true, color: { argb: `FF${accent}` } };
|
||||
row++;
|
||||
if (doc.subtitle) {
|
||||
sheet.getCell(row, 1).value = doc.subtitle;
|
||||
sheet.getCell(row, 1).font = { size: 11, color: { argb: 'FF6E6E6E' } };
|
||||
row++;
|
||||
}
|
||||
row++;
|
||||
|
||||
for (const [label, value] of doc.info || []) {
|
||||
sheet.getCell(row, 1).value = label;
|
||||
sheet.getCell(row, 1).font = { bold: true, size: 10, color: { argb: 'FF6E6E6E' } };
|
||||
sheet.getCell(row, 2).value = value;
|
||||
sheet.getCell(row, 2).font = { size: 10 };
|
||||
row++;
|
||||
}
|
||||
if (doc.kpis?.length) {
|
||||
row++;
|
||||
doc.kpis.forEach(([label, value], i) => {
|
||||
const col = 1 + i * 2;
|
||||
if (col > Math.max(widest, 8)) return;
|
||||
sheet.getCell(row, col).value = label;
|
||||
sheet.getCell(row, col).font = { size: 9, color: { argb: 'FF6E6E6E' } };
|
||||
sheet.getCell(row + 1, col).value = value;
|
||||
sheet.getCell(row + 1, col).font = { size: 14, bold: true };
|
||||
});
|
||||
row += 2;
|
||||
}
|
||||
|
||||
for (const table of doc.tables) {
|
||||
row++;
|
||||
if (table.title) {
|
||||
sheet.getCell(row, 1).value = table.title;
|
||||
sheet.getCell(row, 1).font = { bold: true, size: 12, color: { argb: `FF${accent}` } };
|
||||
row++;
|
||||
}
|
||||
const headRow = sheet.getRow(row);
|
||||
table.columns.forEach((c, i) => {
|
||||
const cell = headRow.getCell(i + 1);
|
||||
cell.value = c.label;
|
||||
cell.font = { bold: true, color: { argb: 'FFFFFFFF' }, size: 10 };
|
||||
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: `FF${accent}` } };
|
||||
cell.alignment = { horizontal: c.align === 'right' ? 'right' : 'left', vertical: 'middle' };
|
||||
});
|
||||
headRow.height = 20;
|
||||
row++;
|
||||
const first = row;
|
||||
table.rows.forEach((data, index) => {
|
||||
const r = sheet.getRow(row);
|
||||
table.columns.forEach((c, i) => writeXlsCell(r.getCell(i + 1), data[c.key], c, index % 2 === 1));
|
||||
row++;
|
||||
});
|
||||
if (table.totals) {
|
||||
const r = sheet.getRow(row);
|
||||
table.columns.forEach((c, i) => {
|
||||
const cell = r.getCell(i + 1);
|
||||
if ((c.type === 'hours' || c.type === 'money') && table.rows.length && table.totals[c.key] != null) {
|
||||
const letter = sheet.getColumn(i + 1).letter;
|
||||
const value = c.type === 'hours' ? table.totals[c.key] / 60 : table.totals[c.key];
|
||||
cell.value = { formula: `SUM(${letter}${first}:${letter}${row - 1})`, result: value };
|
||||
cell.numFmt = c.type === 'money' ? '#,##0.00 "€"' : '#,##0.00';
|
||||
} else {
|
||||
cell.value = formatCell(table.totals[c.key], c.type);
|
||||
}
|
||||
cell.font = { bold: true };
|
||||
cell.alignment = { horizontal: c.align === 'right' ? 'right' : 'left' };
|
||||
cell.border = { top: { style: 'medium', color: { argb: `FF${accent}` } } };
|
||||
});
|
||||
row++;
|
||||
}
|
||||
}
|
||||
|
||||
if (doc.signature && ex.show_signature) {
|
||||
row += 3;
|
||||
const half = Math.max(2, Math.floor(widest / 2));
|
||||
for (const [col, label] of [
|
||||
[1, ex.signature_left],
|
||||
[half + 1, ex.signature_right],
|
||||
]) {
|
||||
const cell = sheet.getCell(row, col);
|
||||
cell.border = { top: { style: 'thin', color: { argb: 'FF9A9A9A' } } };
|
||||
sheet.getCell(row, col + 1).border = { top: { style: 'thin', color: { argb: 'FF9A9A9A' } } };
|
||||
cell.value = label;
|
||||
cell.font = { size: 9, color: { argb: 'FF6E6E6E' } };
|
||||
}
|
||||
}
|
||||
|
||||
return Buffer.from(await workbook.xlsx.writeBuffer());
|
||||
}
|
||||
|
||||
function xlEsc(value) {
|
||||
return String(value || '').replaceAll('&', '&&');
|
||||
}
|
||||
|
||||
function writeXlsCell(cell, value, column, zebra) {
|
||||
switch (column.type) {
|
||||
case 'hours':
|
||||
cell.value = value == null ? null : Math.round((value / 60) * 100) / 100;
|
||||
cell.numFmt = '#,##0.00';
|
||||
break;
|
||||
case 'duration':
|
||||
cell.value = value == null ? null : durationDe(value);
|
||||
break;
|
||||
case 'money':
|
||||
cell.value = value == null ? null : Number(value);
|
||||
cell.numFmt = '#,##0.00 "€"';
|
||||
break;
|
||||
case 'date':
|
||||
cell.value = value ? new Date(`${String(value).slice(0, 10)}T00:00:00Z`) : null;
|
||||
cell.numFmt = 'dd.mm.yyyy';
|
||||
break;
|
||||
case 'percent':
|
||||
cell.value = value == null ? null : value / 100;
|
||||
cell.numFmt = '0%';
|
||||
break;
|
||||
default:
|
||||
cell.value = value ?? '';
|
||||
}
|
||||
cell.font = { size: 10 };
|
||||
cell.alignment = { horizontal: column.align === 'right' ? 'right' : 'left', vertical: 'top', wrapText: column.type === 'text' || !column.type };
|
||||
if (zebra) cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFF4F7F1' } };
|
||||
cell.border = { bottom: { style: 'hair', color: { argb: 'FFDCDCDC' } } };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ PDF -- */
|
||||
|
||||
export function renderPdf(doc, settings) {
|
||||
const ex = settings.export;
|
||||
const accent = ex.accent || '#6BA04A';
|
||||
const pdf = new PDFDocument({
|
||||
size: 'A4',
|
||||
layout: doc.landscape ? 'landscape' : 'portrait',
|
||||
margins: { top: 40, bottom: 70, left: 42, right: 42 },
|
||||
bufferPages: true,
|
||||
info: { Title: `${doc.title} ${doc.subtitle || ''}`.trim(), Author: settings.company_name || 'perinco' },
|
||||
});
|
||||
const chunks = [];
|
||||
pdf.on('data', (c) => chunks.push(c));
|
||||
const done = new Promise((resolve) => pdf.on('end', () => resolve(Buffer.concat(chunks))));
|
||||
|
||||
const left = pdf.page.margins.left;
|
||||
const width = pdf.page.width - left - pdf.page.margins.right;
|
||||
const logo = logoBuffer(ex);
|
||||
|
||||
const drawHeader = () => {
|
||||
pdf.image(logo, left, 30, { fit: [120, 46] });
|
||||
pdf.fontSize(8).fillColor('#6E6E6E').font('Helvetica');
|
||||
if (ex.subtitle) pdf.text(ex.subtitle, left, 34, { width, align: 'right' });
|
||||
if (ex.header_right) pdf.text(ex.header_right, left, 46, { width, align: 'right' });
|
||||
pdf.moveTo(left, 84).lineTo(left + width, 84).lineWidth(1.5).strokeColor(accent).stroke();
|
||||
pdf.x = left;
|
||||
pdf.y = 100;
|
||||
};
|
||||
|
||||
drawHeader();
|
||||
pdf.on('pageAdded', drawHeader);
|
||||
|
||||
// Titel
|
||||
pdf.font('Helvetica-Bold').fontSize(20).fillColor('#1F1F1F').text(doc.title, left, pdf.y);
|
||||
if (doc.subtitle) pdf.font('Helvetica').fontSize(11).fillColor(accent).text(doc.subtitle);
|
||||
pdf.moveDown(0.8);
|
||||
|
||||
// Infoblock zweispaltig
|
||||
if (doc.info?.length) {
|
||||
const colW = width / 2;
|
||||
const startY = pdf.y;
|
||||
let maxY = startY;
|
||||
doc.info.forEach(([label, value], i) => {
|
||||
const x = left + (i % 2) * colW;
|
||||
const y = startY + Math.floor(i / 2) * 30;
|
||||
pdf.font('Helvetica').fontSize(7.5).fillColor('#9A9A9A').text(label.toUpperCase(), x, y, { width: colW - 10, characterSpacing: 0.6 });
|
||||
pdf.font('Helvetica-Bold').fontSize(10).fillColor('#1F1F1F').text(String(value ?? '–'), x, y + 10, { width: colW - 10, height: 14, ellipsis: true });
|
||||
maxY = Math.max(maxY, y + 26);
|
||||
});
|
||||
pdf.y = maxY + 8;
|
||||
}
|
||||
|
||||
// Kennzahlen als Kacheln
|
||||
if (doc.kpis?.length) {
|
||||
const gap = 8;
|
||||
const boxW = (width - gap * (doc.kpis.length - 1)) / doc.kpis.length;
|
||||
const y = pdf.y;
|
||||
doc.kpis.forEach(([label, value], i) => {
|
||||
const x = left + i * (boxW + gap);
|
||||
pdf.roundedRect(x, y, boxW, 46, 6).fillColor('#F4F7F1').fill();
|
||||
pdf.rect(x, y, 3, 46).fillColor(accent).fill();
|
||||
pdf.font('Helvetica').fontSize(7.5).fillColor('#6E6E6E').text(label, x + 10, y + 8, { width: boxW - 16 });
|
||||
pdf.font('Helvetica-Bold').fontSize(14).fillColor('#1F1F1F').text(String(value), x + 10, y + 21, { width: boxW - 16 });
|
||||
});
|
||||
pdf.y = y + 60;
|
||||
}
|
||||
|
||||
for (const table of doc.tables) drawTable(pdf, table, { left, width, accent });
|
||||
|
||||
if (doc.signature && ex.show_signature) {
|
||||
if (pdf.y + 80 > pdf.page.height - pdf.page.margins.bottom) pdf.addPage();
|
||||
const y = pdf.y + 45;
|
||||
const w = (width - 40) / 2;
|
||||
for (const [x, label] of [
|
||||
[left, ex.signature_left],
|
||||
[left + w + 40, ex.signature_right],
|
||||
]) {
|
||||
pdf.moveTo(x, y).lineTo(x + w, y).lineWidth(0.6).strokeColor('#9A9A9A').stroke();
|
||||
pdf.font('Helvetica').fontSize(8).fillColor('#6E6E6E').text(label || '', x, y + 4, { width: w });
|
||||
}
|
||||
}
|
||||
|
||||
// Fuss auf jeder Seite, inkl. Seitenzahl.
|
||||
const range = pdf.bufferedPageRange();
|
||||
for (let i = 0; i < range.count; i++) {
|
||||
pdf.switchToPage(range.start + i);
|
||||
const bottom = pdf.page.height - 48;
|
||||
const margin = pdf.page.margins.bottom;
|
||||
pdf.page.margins.bottom = 0; // sonst bricht text() unten auf eine neue Seite um
|
||||
pdf.moveTo(left, bottom - 8).lineTo(left + width, bottom - 8).lineWidth(0.5).strokeColor('#DCDCDC').stroke();
|
||||
pdf.font('Helvetica').fontSize(7.5).fillColor('#6E6E6E');
|
||||
const third = width / 3;
|
||||
pdf.text(ex.footer_left || '', left, bottom, { width: third, align: 'left', lineGap: 1 });
|
||||
pdf.text(ex.footer_center || '', left + third, bottom, { width: third, align: 'center', lineGap: 1 });
|
||||
pdf.text(`${ex.footer_right || ''}${ex.footer_right ? '\n' : ''}Seite ${i + 1} von ${range.count}`, left + 2 * third, bottom, {
|
||||
width: third,
|
||||
align: 'right',
|
||||
lineGap: 1,
|
||||
});
|
||||
pdf.page.margins.bottom = margin;
|
||||
}
|
||||
|
||||
pdf.end();
|
||||
return done;
|
||||
}
|
||||
|
||||
function drawTable(pdf, table, { left, width, accent }) {
|
||||
const fixed = table.columns.reduce((s, c) => s + (c.width || 0), 0);
|
||||
const flex = table.columns.filter((c) => !c.width).length;
|
||||
const widths = table.columns.map((c) => c.width || Math.max(40, (width - fixed) / Math.max(1, flex)));
|
||||
const scale = width / widths.reduce((a, b) => a + b, 0);
|
||||
const cols = widths.map((w) => w * scale);
|
||||
const bottomLimit = () => pdf.page.height - pdf.page.margins.bottom;
|
||||
|
||||
const header = () => {
|
||||
const y = pdf.y;
|
||||
pdf.rect(left, y, width, 18).fillColor(accent).fill();
|
||||
let x = left;
|
||||
table.columns.forEach((c, i) => {
|
||||
pdf.font('Helvetica-Bold').fontSize(8).fillColor('#FFFFFF').text(c.label, x + 5, y + 5, {
|
||||
width: cols[i] - 10,
|
||||
align: c.align || 'left',
|
||||
lineBreak: false,
|
||||
ellipsis: true,
|
||||
});
|
||||
x += cols[i];
|
||||
});
|
||||
pdf.y = y + 18;
|
||||
};
|
||||
|
||||
if (pdf.y + 60 > bottomLimit()) pdf.addPage();
|
||||
if (table.title) {
|
||||
pdf.moveDown(0.6);
|
||||
pdf.font('Helvetica-Bold').fontSize(11).fillColor('#1F1F1F').text(table.title, left, pdf.y);
|
||||
pdf.moveDown(0.3);
|
||||
}
|
||||
header();
|
||||
|
||||
const drawRow = (data, { zebra = false, bold = false } = {}) => {
|
||||
const texts = table.columns.map((c) => formatCell(data[c.key], c.type));
|
||||
pdf.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8.5);
|
||||
const height = Math.max(16, ...texts.map((t, i) => pdf.heightOfString(t || ' ', { width: cols[i] - 10 }) + 8));
|
||||
if (pdf.y + height > bottomLimit()) {
|
||||
pdf.addPage();
|
||||
header();
|
||||
}
|
||||
const y = pdf.y;
|
||||
if (zebra) pdf.rect(left, y, width, height).fillColor('#F4F7F1').fill();
|
||||
if (bold) {
|
||||
pdf.moveTo(left, y).lineTo(left + width, y).lineWidth(1.2).strokeColor(accent).stroke();
|
||||
}
|
||||
let x = left;
|
||||
texts.forEach((text, i) => {
|
||||
pdf.font(bold ? 'Helvetica-Bold' : 'Helvetica').fontSize(8.5).fillColor('#1F1F1F').text(text, x + 5, y + 4, {
|
||||
width: cols[i] - 10,
|
||||
align: table.columns[i].align || 'left',
|
||||
});
|
||||
x += cols[i];
|
||||
});
|
||||
if (!bold) pdf.moveTo(left, y + height).lineTo(left + width, y + height).lineWidth(0.3).strokeColor('#E4E4E4').stroke();
|
||||
pdf.y = y + height;
|
||||
};
|
||||
|
||||
if (table.rows.length === 0) {
|
||||
pdf.font('Helvetica-Oblique').fontSize(9).fillColor('#9A9A9A').text('Keine Buchungen im Zeitraum.', left + 5, pdf.y + 6);
|
||||
pdf.y += 10;
|
||||
}
|
||||
table.rows.forEach((row, index) => drawRow(row, { zebra: index % 2 === 1 }));
|
||||
if (table.totals) drawRow(table.totals, { bold: true });
|
||||
pdf.x = left;
|
||||
pdf.moveDown(0.8);
|
||||
}
|
||||
|
||||
export async function sendDocument(res, doc, format, settings) {
|
||||
const safe = doc.filename.replace(/[^\w.\-äöüÄÖÜß ]+/g, '_');
|
||||
let buffer;
|
||||
let type;
|
||||
if (format === 'pdf') {
|
||||
buffer = await renderPdf(doc, settings);
|
||||
type = 'application/pdf';
|
||||
} else if (format === 'xlsx') {
|
||||
buffer = await renderXlsx(doc, settings);
|
||||
type = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
} else {
|
||||
buffer = renderCsv(doc);
|
||||
type = 'text/csv; charset=utf-8';
|
||||
}
|
||||
res.setHeader('Content-Type', type);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${safe.replace(/[^\x20-\x7e]/g, '_')}.${format}"; filename*=UTF-8''${encodeURIComponent(safe)}.${format}`
|
||||
);
|
||||
res.send(buffer);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { pool } from '../db.js';
|
||||
import { hasRole } from '../middleware/auth.js';
|
||||
|
||||
/**
|
||||
* Wessen Zeiten darf der Benutzer sehen?
|
||||
* management/admin: alle (null)
|
||||
* lead: sich selbst und alle, die direkt oder indirekt unter ihm haengen
|
||||
* user: nur sich selbst
|
||||
*/
|
||||
export async function visibleUserIds(user) {
|
||||
if (hasRole(user, 'management')) return null;
|
||||
if (user.role !== 'lead') return [user.id];
|
||||
|
||||
const [rows] = await pool.query('SELECT id, manager_id FROM pulse_users');
|
||||
const children = new Map();
|
||||
for (const row of rows) {
|
||||
if (row.manager_id == null) continue;
|
||||
if (!children.has(row.manager_id)) children.set(row.manager_id, []);
|
||||
children.get(row.manager_id).push(row.id);
|
||||
}
|
||||
const seen = new Set([user.id]);
|
||||
const queue = [user.id];
|
||||
while (queue.length) {
|
||||
const current = queue.shift();
|
||||
for (const child of children.get(current) || []) {
|
||||
if (seen.has(child)) continue; // Schutz gegen Zyklen
|
||||
seen.add(child);
|
||||
queue.push(child);
|
||||
}
|
||||
}
|
||||
return [...seen];
|
||||
}
|
||||
|
||||
/** Alle Unterstellten (rekursiv) eines Vorgesetzten, ohne ihn selbst. */
|
||||
export async function teamOf(leadId) {
|
||||
const ids = await visibleUserIds({ id: leadId, role: 'lead' });
|
||||
return ids.filter((id) => id !== leadId);
|
||||
}
|
||||
|
||||
/** SQL-Fragment fuer "user_id IN (...)" - bei null ohne Einschraenkung. */
|
||||
export function userFilter(ids, column = 'e.user_id') {
|
||||
if (ids === null) return { sql: '1=1', params: [] };
|
||||
if (ids.length === 0) return { sql: '1=0', params: [] };
|
||||
return { sql: `${column} IN (${ids.map(() => '?').join(',')})`, params: ids };
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Datumsrechnung ohne Zeitzonenfallen: alles laeuft ueber 'YYYY-MM-DD' und
|
||||
* UTC-Mitternacht, damit ein Tag nie durch die Sommerzeit verrutscht.
|
||||
*/
|
||||
export function parseDate(value) {
|
||||
if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return null;
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
return Number.isNaN(date.getTime()) || iso(date) !== value ? null : date;
|
||||
}
|
||||
|
||||
export function iso(date) {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function today() {
|
||||
const now = new Date();
|
||||
// Lokales Datum des Servers (Europe/Berlin), nicht UTC.
|
||||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
|
||||
return local.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function addDays(value, days) {
|
||||
const date = parseDate(value);
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return iso(date);
|
||||
}
|
||||
|
||||
export function daysBetween(from, to) {
|
||||
return Math.round((parseDate(to) - parseDate(from)) / 86400000);
|
||||
}
|
||||
|
||||
/** Arbeitstage Mo-Fr im geschlossenen Intervall. */
|
||||
export function workdays(from, to) {
|
||||
let count = 0;
|
||||
const end = parseDate(to);
|
||||
for (const d = parseDate(from); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
|
||||
const day = d.getUTCDay();
|
||||
if (day !== 0 && day !== 6) count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export function monthRange(month) {
|
||||
if (!/^\d{4}-\d{2}$/.test(month || '')) return null;
|
||||
const [y, m] = month.split('-').map(Number);
|
||||
if (m < 1 || m > 12) return null;
|
||||
const last = new Date(Date.UTC(y, m, 0)).getUTCDate();
|
||||
return { from: `${month}-01`, to: `${month}-${String(last).padStart(2, '0')}` };
|
||||
}
|
||||
|
||||
export function isoWeek(value) {
|
||||
const date = parseDate(value);
|
||||
const day = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() + 4 - day);
|
||||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||||
return { year: date.getUTCFullYear(), week: Math.ceil(((date - yearStart) / 86400000 + 1) / 7) };
|
||||
}
|
||||
|
||||
export function mondayOf(value) {
|
||||
const date = parseDate(value);
|
||||
const day = date.getUTCDay() || 7;
|
||||
date.setUTCDate(date.getUTCDate() - day + 1);
|
||||
return iso(date);
|
||||
}
|
||||
|
||||
export const MONTHS_DE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
|
||||
|
||||
export function formatDateDe(value) {
|
||||
const [y, m, d] = String(value).slice(0, 10).split('-');
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
/** Zeitraum, in dem `value` liegt: day | week | month | quarter | year. */
|
||||
export function periodRange(granularity, value) {
|
||||
const [y, m] = value.split('-').map(Number);
|
||||
switch (granularity) {
|
||||
case 'day':
|
||||
return { from: value, to: value };
|
||||
case 'week': {
|
||||
const from = mondayOf(value);
|
||||
return { from, to: addDays(from, 6) };
|
||||
}
|
||||
case 'month':
|
||||
return monthRange(value.slice(0, 7));
|
||||
case 'quarter': {
|
||||
const q = Math.floor((m - 1) / 3);
|
||||
const startMonth = String(q * 3 + 1).padStart(2, '0');
|
||||
const end = monthRange(`${y}-${String(q * 3 + 3).padStart(2, '0')}`);
|
||||
return { from: `${y}-${startMonth}-01`, to: end.to };
|
||||
}
|
||||
case 'year':
|
||||
return { from: `${y}-01-01`, to: `${y}-12-31` };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Beginn des vorherigen Zeitraums (fuer Reihen und Vergleich). */
|
||||
export function previousStart(granularity, from) {
|
||||
const d = parseDate(from);
|
||||
if (granularity === 'day') d.setUTCDate(d.getUTCDate() - 1);
|
||||
if (granularity === 'week') d.setUTCDate(d.getUTCDate() - 7);
|
||||
if (granularity === 'month') d.setUTCMonth(d.getUTCMonth() - 1);
|
||||
if (granularity === 'quarter') d.setUTCMonth(d.getUTCMonth() - 3);
|
||||
if (granularity === 'year') d.setUTCFullYear(d.getUTCFullYear() - 1);
|
||||
return iso(d);
|
||||
}
|
||||
|
||||
export function periodLabel(granularity, from) {
|
||||
const [y, m, d] = from.split('-');
|
||||
if (granularity === 'day') return `${d}.${m}.`;
|
||||
if (granularity === 'week') return `KW ${isoWeek(from).week}`;
|
||||
if (granularity === 'month') return `${MONTHS_DE[Number(m) - 1].slice(0, 3)} ${y.slice(2)}`;
|
||||
if (granularity === 'quarter') return `Q${Math.floor((Number(m) - 1) / 3) + 1} ${y.slice(2)}`;
|
||||
return y;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { pool } from '../db.js';
|
||||
import { displayName, hourlyRate } from './projects.js';
|
||||
import { userFilter } from './access.js';
|
||||
|
||||
/*
|
||||
* Buchungen fuer Auswertungen laden - mit allem, was Dashboard, Reports,
|
||||
* Kundenansicht und Exporte brauchen, und mit dem Betrag je Buchung:
|
||||
*
|
||||
* amount abrechenbarer Betrag (Stunden x Stundensatz des Topfs);
|
||||
* nur fuer Projekte vom Typ "billable"
|
||||
* fixed_amount Festpreisprojekte: Stunden x virtueller Stundensatz
|
||||
* (Topfbudget / alle auf dem Topf gebuchten Stunden). Nur
|
||||
* intern, in Reports per Schalter zuschaltbar.
|
||||
*/
|
||||
export async function loadEntries(settings, { from, to, userIds = null, customerId, projectId, taskId } = {}) {
|
||||
// Administratoren sind keine Mitarbeiter - ihre Zeiten zaehlen nirgends mit.
|
||||
const where = ["u.role <> 'admin'"];
|
||||
const params = [];
|
||||
if (from) {
|
||||
where.push('e.entry_date >= ?');
|
||||
params.push(from);
|
||||
}
|
||||
if (to) {
|
||||
where.push('e.entry_date <= ?');
|
||||
params.push(to);
|
||||
}
|
||||
const users = userFilter(userIds);
|
||||
where.push(users.sql);
|
||||
params.push(...users.params);
|
||||
if (customerId) {
|
||||
where.push('p.customer_id = ?');
|
||||
params.push(Number(customerId));
|
||||
}
|
||||
if (projectId) {
|
||||
where.push('e.project_id = ?');
|
||||
params.push(Number(projectId));
|
||||
}
|
||||
if (taskId) {
|
||||
where.push('e.task_id = ?');
|
||||
params.push(Number(taskId));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT e.id, e.user_id, e.project_id, e.task_id, e.pot_id, e.entry_date, e.minutes, e.note, e.billable,
|
||||
e.created_at, e.updated_at,
|
||||
u.name AS user_name, u.color AS user_color, u.is_external, u.company AS user_company, u.weekly_hours,
|
||||
u.manager_id,
|
||||
p.name AS project_name, p.code AS project_code, p.type AS project_type, p.billing_unit,
|
||||
c.id AS customer_id, c.name AS customer_name, c.short_name AS customer_short, c.color AS customer_color,
|
||||
t.name AS task_name,
|
||||
b.label AS pot_label, b.rate AS pot_rate, b.budget_amount AS pot_amount
|
||||
FROM pulse_time_entries e
|
||||
JOIN pulse_users u ON u.id = e.user_id
|
||||
JOIN pulse_projects p ON p.id = e.project_id
|
||||
JOIN pulse_customers c ON c.id = p.customer_id
|
||||
LEFT JOIN pulse_tasks t ON t.id = e.task_id
|
||||
LEFT JOIN pulse_budget_pots b ON b.id = e.pot_id
|
||||
WHERE ${where.join(' AND ')}
|
||||
ORDER BY e.entry_date, e.id`,
|
||||
params
|
||||
);
|
||||
|
||||
// Virtueller Stundensatz braucht die Stunden des ganzen Topfs, nicht nur des Zeitraums.
|
||||
const fixedPots = [...new Set(rows.filter((r) => r.project_type === 'fixed' && r.pot_id).map((r) => r.pot_id))];
|
||||
const potMinutes = new Map();
|
||||
if (fixedPots.length) {
|
||||
const [usage] = await pool.query(
|
||||
`SELECT pot_id, SUM(minutes) AS minutes FROM pulse_time_entries WHERE pot_id IN (${fixedPots.map(() => '?').join(',')}) GROUP BY pot_id`,
|
||||
fixedPots
|
||||
);
|
||||
for (const u of usage) potMinutes.set(u.pot_id, Number(u.minutes));
|
||||
}
|
||||
|
||||
return rows.map((r) => {
|
||||
const project = { name: r.project_name, code: r.project_code, billing_unit: r.billing_unit };
|
||||
const hours = r.minutes / 60;
|
||||
let amount = 0;
|
||||
let fixedAmount = 0;
|
||||
if (r.billable && r.project_type === 'billable') {
|
||||
const rate = hourlyRate({ rate: r.pot_rate }, project, settings);
|
||||
amount = rate ? hours * rate : 0;
|
||||
}
|
||||
if (r.billable && r.project_type === 'fixed' && r.pot_amount && potMinutes.get(r.pot_id)) {
|
||||
fixedAmount = (Number(r.pot_amount) / (potMinutes.get(r.pot_id) / 60)) * hours;
|
||||
}
|
||||
return {
|
||||
...r,
|
||||
billable: Boolean(r.billable),
|
||||
is_external: Boolean(r.is_external),
|
||||
project_display: displayName(settings, project, { name: r.customer_name, short_name: r.customer_short }),
|
||||
amount: Math.round(amount * 100) / 100,
|
||||
fixed_amount: Math.round(fixedAmount * 100) / 100,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { pool } from '../db.js';
|
||||
import { daysBetween, parseDate, today } from './dates.js';
|
||||
|
||||
/*
|
||||
* Fachlogik rund um Projekte und Budgettoepfe - an einer Stelle, weil Buchung,
|
||||
* Projektansicht, Kunden und Reports dieselben Zahlen zeigen muessen.
|
||||
*/
|
||||
|
||||
const round2 = (n) => Math.round(n * 100) / 100;
|
||||
|
||||
export function parseTags(value) {
|
||||
if (!value) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed.map(String) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Anzeigename nach dem Muster aus den Einstellungen, z. B. "KUKA - PMO". */
|
||||
export function displayName(settings, project, customer) {
|
||||
const pattern = settings.project_name_pattern || '{KUNDE} - {PROJEKT}';
|
||||
const kunde = customer?.short_name && customer.short_name !== customer.name ? prettyShort(customer) : customer?.name || '';
|
||||
return pattern
|
||||
.replaceAll('{KUNDE}', kunde)
|
||||
.replaceAll('{KUNDENNAME}', customer?.name || '')
|
||||
.replaceAll('{PROJEKT}', project.name)
|
||||
.replaceAll('{CODE}', project.code || '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Kuerzel in Grossbuchstaben wirken in Namen laut - "KUKA" bleibt, "PERINCO" wird "perinco". */
|
||||
function prettyShort(customer) {
|
||||
if (customer.short_name.toUpperCase() === 'PERINCO') return 'perinco';
|
||||
return customer.short_name;
|
||||
}
|
||||
|
||||
/** Stundensatz je Stunde, auch wenn das Projekt nach Tagen abrechnet. */
|
||||
export function hourlyRate(pot, project, settings) {
|
||||
if (pot?.rate == null) return null;
|
||||
const rate = Number(pot.rate);
|
||||
return project.billing_unit === 'day' ? rate / Number(settings.hours_per_day || 8) : rate;
|
||||
}
|
||||
|
||||
/** Buchbares Volumen eines Topfs in Stunden (null = unbegrenzt). */
|
||||
export function deriveBudgetHours(input, project, settings) {
|
||||
const amount = input.budget_amount == null || input.budget_amount === '' ? null : Number(input.budget_amount);
|
||||
const rate = input.rate == null || input.rate === '' ? null : Number(input.rate);
|
||||
if (project.type === 'billable' && amount != null && rate) {
|
||||
const perUnit = project.billing_unit === 'day' ? Number(settings.hours_per_day || 8) : 1;
|
||||
return round2((amount / rate) * perUnit);
|
||||
}
|
||||
if (input.budget_hours == null || input.budget_hours === '') return null;
|
||||
return round2(Number(input.budget_hours));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kennzahlen eines Topfs. `usedMinutes` sind alle Buchungen auf diesem Topf.
|
||||
* status: upcoming | active | expired | exhausted
|
||||
* earned: bereits erbrachter Umsatz, open: noch offener Umsatz
|
||||
*/
|
||||
export function potStats(pot, project, usedMinutes, settings) {
|
||||
const now = today();
|
||||
const usedHours = usedMinutes / 60;
|
||||
const budgetHours = pot.budget_hours == null ? null : Number(pot.budget_hours);
|
||||
const amount = pot.budget_amount == null ? null : Number(pot.budget_amount);
|
||||
const rateHour = hourlyRate(pot, project, settings);
|
||||
|
||||
let status = 'active';
|
||||
if (now < pot.valid_from) status = 'upcoming';
|
||||
else if (now > pot.valid_to) status = 'expired';
|
||||
else if (budgetHours != null && usedHours >= budgetHours - 1e-9) status = 'exhausted';
|
||||
|
||||
let earned = 0;
|
||||
if (project.type === 'billable') {
|
||||
earned = rateHour ? usedHours * rateHour : 0;
|
||||
} else if (project.type === 'fixed' && amount) {
|
||||
if (budgetHours) {
|
||||
earned = amount * Math.min(1, usedHours / budgetHours);
|
||||
} else {
|
||||
// Ohne Stundenkontingent: Festpreis anteilig ueber die Laufzeit.
|
||||
const total = Math.max(1, daysBetween(pot.valid_from, pot.valid_to) + 1);
|
||||
const elapsed = now < pot.valid_from ? 0 : Math.min(total, daysBetween(pot.valid_from, now) + 1);
|
||||
earned = amount * (elapsed / total);
|
||||
}
|
||||
if (status === 'expired') earned = amount;
|
||||
}
|
||||
|
||||
const budgetValue = project.type === 'billable' ? (amount ?? (budgetHours && rateHour ? budgetHours * rateHour : null)) : amount;
|
||||
const open = budgetValue == null || status === 'expired' ? 0 : Math.max(0, budgetValue - earned);
|
||||
const daysLeft = daysBetween(now, pot.valid_to);
|
||||
|
||||
return {
|
||||
used_minutes: usedMinutes,
|
||||
used_hours: round2(usedHours),
|
||||
remaining_hours: budgetHours == null ? null : round2(Math.max(0, budgetHours - usedHours)),
|
||||
percent_used: budgetHours ? Math.round((usedHours / budgetHours) * 1000) / 10 : null,
|
||||
hourly_rate: rateHour == null ? null : round2(rateHour),
|
||||
// Festpreis: Budget geteilt durch gebuchte Stunden - nur intern.
|
||||
virtual_rate: project.type === 'fixed' && amount && usedHours > 0 ? round2(amount / usedHours) : null,
|
||||
budget_value: budgetValue == null ? null : round2(budgetValue),
|
||||
earned: round2(earned),
|
||||
open: round2(open),
|
||||
days_left: daysLeft,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ist der Topf zu leer oder laeuft er bald ab? */
|
||||
export function potWarning(pot, stats, settings) {
|
||||
if (stats.status === 'expired') return `Budgettopf „${pot.label}“ ist abgelaufen.`;
|
||||
if (stats.status === 'exhausted') return `Budgettopf „${pot.label}“ ist aufgebraucht.`;
|
||||
const parts = [];
|
||||
if (stats.percent_used != null && stats.percent_used >= Number(settings.budget_warn_percent || 80)) {
|
||||
parts.push(`${Math.round(stats.percent_used)} % verbraucht`);
|
||||
}
|
||||
if (stats.status === 'active' && stats.days_left <= Number(settings.budget_warn_days || 14)) {
|
||||
parts.push(stats.days_left === 0 ? 'läuft heute ab' : `läuft in ${stats.days_left} Tagen ab`);
|
||||
}
|
||||
return parts.length ? `Budgettopf „${pot.label}“: ${parts.join(', ')}.` : null;
|
||||
}
|
||||
|
||||
/** Laufende Nummer je Kunde und Jahr, eingesetzt in das Codemuster. */
|
||||
export async function generateCode(conn, settings, customer, startDate) {
|
||||
const year = String(startDate).slice(0, 4);
|
||||
const pattern = settings.project_code_pattern || '{KUERZEL}-{JAHR}-{NR}';
|
||||
const [rows] = await conn.query(
|
||||
'SELECT COALESCE(MAX(seq), 0) AS n FROM pulse_projects WHERE customer_id = ? AND YEAR(start_date) = ?',
|
||||
[customer.id, Number(year)]
|
||||
);
|
||||
let seq = Number(rows[0].n) + 1;
|
||||
// Gegen Kollisionen, falls das Muster geaendert wurde.
|
||||
for (let attempt = 0; attempt < 500; attempt++, seq++) {
|
||||
const code = pattern
|
||||
.replaceAll('{KUERZEL}', customer.short_name.toUpperCase())
|
||||
.replaceAll('{JAHR}', year)
|
||||
.replaceAll('{JJ}', year.slice(2))
|
||||
.replaceAll('{NR}', String(seq).padStart(3, '0'))
|
||||
.slice(0, 40);
|
||||
const [exists] = await conn.query('SELECT id FROM pulse_projects WHERE code = ? LIMIT 1', [code]);
|
||||
if (exists.length === 0) return { code, seq };
|
||||
}
|
||||
throw new Error('Projektcode konnte nicht erzeugt werden.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Was verbraucht Budget? Bei Billable-Projekten ist das Volumen aus Betrag und
|
||||
* Satz abgeleitet - also verkaufte Stunden. Nicht abrechenbare Task-Stunden
|
||||
* (z. B. Reisezeit) verbrauchen es nicht. Bei Festpreis und intern zaehlt alles.
|
||||
*/
|
||||
export function countsAgainstBudget(project) {
|
||||
return project.type === 'billable' ? 'billable' : 'all';
|
||||
}
|
||||
|
||||
/** Abrechenbar ist eine Buchung, wenn Projekt UND Task es sind. */
|
||||
export function isBillable(project, task) {
|
||||
if (project.type === 'non_billable') return false;
|
||||
return task ? Boolean(task.billable) : true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Laedt Projekte samt Kunde, Toepfen (mit Kennzahlen), Tasks und Mitgliedern.
|
||||
* `where` schraenkt pulse_projects (Alias p) ein.
|
||||
*/
|
||||
export async function loadProjects(settings, { where = '1=1', params = [] } = {}) {
|
||||
const [projects] = await pool.query(
|
||||
`SELECT p.*, c.name AS customer_name, c.short_name AS customer_short, c.color AS customer_color,
|
||||
u.name AS owner_name
|
||||
FROM pulse_projects p
|
||||
JOIN pulse_customers c ON c.id = p.customer_id
|
||||
LEFT JOIN pulse_users u ON u.id = p.owner_id
|
||||
WHERE ${where}
|
||||
ORDER BY c.name, p.name`,
|
||||
params
|
||||
);
|
||||
if (projects.length === 0) return [];
|
||||
const ids = projects.map((p) => p.id);
|
||||
const inList = ids.map(() => '?').join(',');
|
||||
|
||||
const [[pots], [tasks], [members], [potUsage], [projectUsage], [taskUsage]] = await Promise.all([
|
||||
pool.query(`SELECT * FROM pulse_budget_pots WHERE project_id IN (${inList}) ORDER BY valid_from, id`, ids),
|
||||
pool.query(`SELECT * FROM pulse_tasks WHERE project_id IN (${inList}) ORDER BY name`, ids),
|
||||
pool.query(
|
||||
`SELECT m.project_id, u.id, u.name, u.color, u.is_external
|
||||
FROM pulse_project_members m JOIN pulse_users u ON u.id = m.user_id
|
||||
WHERE m.project_id IN (${inList}) AND u.role <> 'admin' ORDER BY u.name`,
|
||||
ids
|
||||
),
|
||||
pool.query(
|
||||
`SELECT pot_id, SUM(minutes) AS minutes, SUM(CASE WHEN billable = 1 THEN minutes ELSE 0 END) AS billable_minutes
|
||||
FROM pulse_time_entries
|
||||
WHERE project_id IN (${inList}) AND pot_id IS NOT NULL GROUP BY pot_id`,
|
||||
ids
|
||||
),
|
||||
pool.query(
|
||||
`SELECT project_id, SUM(minutes) AS minutes, SUM(CASE WHEN billable = 1 THEN minutes ELSE 0 END) AS billable_minutes,
|
||||
MAX(entry_date) AS last_entry
|
||||
FROM pulse_time_entries WHERE project_id IN (${inList}) GROUP BY project_id`,
|
||||
ids
|
||||
),
|
||||
pool.query(
|
||||
`SELECT task_id, SUM(minutes) AS minutes FROM pulse_time_entries
|
||||
WHERE project_id IN (${inList}) AND task_id IS NOT NULL GROUP BY task_id`,
|
||||
ids
|
||||
),
|
||||
]);
|
||||
|
||||
const potMinutes = new Map(potUsage.map((r) => [r.pot_id, r]));
|
||||
const projectMinutes = new Map(projectUsage.map((r) => [r.project_id, r]));
|
||||
const taskMinutes = new Map(taskUsage.map((r) => [r.task_id, Number(r.minutes)]));
|
||||
|
||||
return projects.map((p) => {
|
||||
const project = { ...p, tags: parseTags(p.tags), is_active: Boolean(p.is_active) };
|
||||
const customer = { id: p.customer_id, name: p.customer_name, short_name: p.customer_short };
|
||||
const projectPots = pots
|
||||
.filter((pot) => pot.project_id === p.id)
|
||||
.map((pot) => {
|
||||
const usage = potMinutes.get(pot.id);
|
||||
const booked = Number(usage?.minutes || 0);
|
||||
const counted = countsAgainstBudget(project) === 'billable' ? Number(usage?.billable_minutes || 0) : booked;
|
||||
const stats = potStats(pot, project, counted, settings);
|
||||
return { ...pot, ...stats, booked_minutes: booked, warning: potWarning(pot, stats, settings) };
|
||||
});
|
||||
const usage = projectMinutes.get(p.id);
|
||||
const totalBudgetHours = projectPots.every((pot) => pot.budget_hours != null) && projectPots.length
|
||||
? round2(projectPots.reduce((s, pot) => s + Number(pot.budget_hours), 0))
|
||||
: null;
|
||||
const usedMinutes = Number(usage?.minutes || 0);
|
||||
|
||||
return {
|
||||
...project,
|
||||
display_name: displayName(settings, project, customer),
|
||||
customer: { id: p.customer_id, name: p.customer_name, short_name: p.customer_short, color: p.customer_color },
|
||||
pots: projectPots,
|
||||
tasks: tasks
|
||||
.filter((t) => t.project_id === p.id)
|
||||
.map((t) => ({ ...t, billable: Boolean(t.billable), is_active: Boolean(t.is_active), used_minutes: taskMinutes.get(t.id) || 0 })),
|
||||
members: members
|
||||
.filter((m) => m.project_id === p.id)
|
||||
.map((m) => ({ id: m.id, name: m.name, color: m.color, is_external: Boolean(m.is_external) })),
|
||||
used_minutes: usedMinutes,
|
||||
billable_minutes: Number(usage?.billable_minutes || 0),
|
||||
// Was gegen das Budget zaehlt (bei Billable nur abrechenbare Stunden).
|
||||
budget_used_minutes: projectPots.reduce((s, pot) => s + pot.used_minutes, 0),
|
||||
last_entry: usage?.last_entry || null,
|
||||
budget_hours: totalBudgetHours,
|
||||
remaining_hours:
|
||||
totalBudgetHours == null ? null : round2(Math.max(0, totalBudgetHours - projectPots.reduce((s, pot) => s + pot.used_minutes, 0) / 60)),
|
||||
budget_value: round2(projectPots.reduce((s, pot) => s + (pot.budget_value || 0), 0)),
|
||||
earned: round2(projectPots.reduce((s, pot) => s + pot.earned, 0)),
|
||||
open: round2(projectPots.reduce((s, pot) => s + pot.open, 0)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Betraege weg fuer die Rolle "user" - Mitarbeiter sehen Stunden, keine Preise. */
|
||||
export function stripAmounts(project) {
|
||||
const clean = { ...project, budget_value: null, earned: null, open: null };
|
||||
clean.pots = project.pots.map((pot) => ({
|
||||
...pot,
|
||||
budget_amount: null,
|
||||
rate: null,
|
||||
hourly_rate: null,
|
||||
virtual_rate: null,
|
||||
budget_value: null,
|
||||
earned: null,
|
||||
open: null,
|
||||
}));
|
||||
return clean;
|
||||
}
|
||||
|
||||
export class BookingError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.status = 422;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet den Topf fuer eine Buchung.
|
||||
*
|
||||
* Regeln (aus dem Anforderungscall):
|
||||
* - gebucht wird nur auf einen Topf, dessen Laufzeit das Leistungsdatum enthaelt
|
||||
* - ist das Volumen erreicht oder das Ablaufdatum ueberschritten: nicht buchbar
|
||||
* - ueberlappen mehrere Toepfe, zuerst der, der am fruehesten endet
|
||||
* - Projekte ohne Topf (z. B. intern, unbegrenzt) sind frei buchbar
|
||||
*/
|
||||
export async function resolvePot(conn, project, date, minutes, excludeEntryId = null, billable = true) {
|
||||
const onlyBillable = countsAgainstBudget(project) === 'billable';
|
||||
// Nicht abrechenbare Stunden in einem Billable-Projekt verbrauchen kein Volumen.
|
||||
const needed = onlyBillable && !billable ? 0 : minutes;
|
||||
if (date < project.start_date) {
|
||||
throw new BookingError(`Das Projekt beginnt erst am ${de(project.start_date)}.`);
|
||||
}
|
||||
if (project.end_date && date > project.end_date) {
|
||||
throw new BookingError(`Das Projekt ist am ${de(project.end_date)} beendet.`);
|
||||
}
|
||||
|
||||
const [pots] = await conn.query(
|
||||
'SELECT * FROM pulse_budget_pots WHERE project_id = ? ORDER BY valid_to, id FOR UPDATE',
|
||||
[project.id]
|
||||
);
|
||||
if (pots.length === 0) return null;
|
||||
|
||||
const covering = pots.filter((pot) => pot.valid_from <= date && pot.valid_to >= date);
|
||||
if (covering.length === 0) {
|
||||
throw new BookingError(
|
||||
`Kein Budgettopf deckt den ${de(date)} ab. Buchbar ist nur innerhalb der Topf-Laufzeiten ` +
|
||||
`(${pots.map((p) => `${p.label}: ${de(p.valid_from)}–${de(p.valid_to)}`).join('; ')}).`
|
||||
);
|
||||
}
|
||||
let lastShortage = null;
|
||||
for (const pot of covering) {
|
||||
if (pot.valid_to < today()) {
|
||||
lastShortage = `Budgettopf „${pot.label}“ ist seit ${de(pot.valid_to)} abgelaufen.`;
|
||||
continue;
|
||||
}
|
||||
if (pot.budget_hours == null) return pot;
|
||||
const [rows] = await conn.query(
|
||||
`SELECT COALESCE(SUM(minutes), 0) AS used FROM pulse_time_entries
|
||||
WHERE pot_id = ? AND id <> ? ${onlyBillable ? 'AND billable = 1' : ''}`,
|
||||
[pot.id, excludeEntryId || 0]
|
||||
);
|
||||
const free = Number(pot.budget_hours) * 60 - Number(rows[0].used);
|
||||
// Ein aufgebrauchter Topf ist fuer alles gesperrt, auch fuer Reisezeit.
|
||||
if (free > 1e-9 && needed <= free + 1e-9) return pot;
|
||||
lastShortage =
|
||||
free <= 0
|
||||
? `Budgettopf „${pot.label}“ ist aufgebraucht.`
|
||||
: `Budgettopf „${pot.label}“ hat nur noch ${fmtMinutes(free)} h frei.`;
|
||||
}
|
||||
throw new BookingError(`${lastShortage} Buchung nicht möglich – bitte Projektverantwortlichen informieren.`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Nach Aenderung an Toepfen: bestehende Buchungen neu zuordnen. Historische
|
||||
* Buchungen werden nicht abgewiesen - sie landen im passenden Topf, auch wenn
|
||||
* dieser dadurch ueberbucht ist (das zeigt die Projektansicht dann rot).
|
||||
*/
|
||||
export async function reassignEntries(conn, projectId) {
|
||||
const [pots] = await conn.query('SELECT * FROM pulse_budget_pots WHERE project_id = ? ORDER BY valid_to, id', [projectId]);
|
||||
const [[project]] = await conn.query('SELECT type FROM pulse_projects WHERE id = ?', [projectId]);
|
||||
const onlyBillable = project && countsAgainstBudget(project) === 'billable';
|
||||
const [entries] = await conn.query(
|
||||
'SELECT id, entry_date, minutes, pot_id, billable FROM pulse_time_entries WHERE project_id = ? ORDER BY entry_date, id',
|
||||
[projectId]
|
||||
);
|
||||
const used = new Map(pots.map((p) => [p.id, 0]));
|
||||
for (const entry of entries) {
|
||||
const needed = onlyBillable && !entry.billable ? 0 : entry.minutes;
|
||||
const covering = pots.filter((p) => p.valid_from <= entry.entry_date && p.valid_to >= entry.entry_date);
|
||||
const target =
|
||||
covering.find((p) => p.budget_hours == null || used.get(p.id) + needed <= Number(p.budget_hours) * 60) ||
|
||||
covering[0] ||
|
||||
null;
|
||||
if (target) used.set(target.id, used.get(target.id) + needed);
|
||||
const targetId = target ? target.id : null;
|
||||
if (targetId !== entry.pot_id) {
|
||||
await conn.query('UPDATE pulse_time_entries SET pot_id = ? WHERE id = ?', [targetId, entry.id]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function de(value) {
|
||||
const [y, m, d] = String(value).slice(0, 10).split('-');
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
export function fmtMinutes(minutes) {
|
||||
const total = Math.max(0, Math.round(minutes));
|
||||
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export { parseDate };
|
||||
@@ -0,0 +1,101 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { pool } from '../db.js';
|
||||
|
||||
/*
|
||||
* Einstellungen liegen als JSON je Schluessel in pulse_settings. Fehlt ein
|
||||
* Schluessel, gilt der Standard von hier - so muss beim Hinzufuegen neuer
|
||||
* Einstellungen nichts migriert werden.
|
||||
*/
|
||||
export const DEFAULTS = {
|
||||
weekly_hours: 40,
|
||||
hours_per_day: 8,
|
||||
// Platzhalter: {KUERZEL} Kundenkuerzel, {JAHR} vierstellig, {NR} laufende
|
||||
// Nummer je Kunde und Jahr (dreistellig).
|
||||
project_code_pattern: '{KUERZEL}-{JAHR}-{NR}',
|
||||
// Anzeigename der Projekte: {KUNDE} Kundenkuerzel oder -name, {PROJEKT} Name, {CODE}.
|
||||
project_name_pattern: '{KUNDE} - {PROJEKT}',
|
||||
budget_warn_percent: 80,
|
||||
budget_warn_days: 14,
|
||||
company_name: 'perinco GmbH',
|
||||
export: {
|
||||
title: 'Stundennachweis',
|
||||
subtitle: 'Performance Intelligence Consulting',
|
||||
accent: '#6BA04A',
|
||||
logo: null, // Data-URL; null = perinco Logo
|
||||
header_right: 'www.perinco.de',
|
||||
footer_left: 'perinco GmbH',
|
||||
footer_center: '',
|
||||
footer_right: 'www.perinco.de',
|
||||
show_signature: true,
|
||||
signature_left: 'Datum, Unterschrift Berater',
|
||||
signature_right: 'Datum, Unterschrift Kunde',
|
||||
},
|
||||
sso: {
|
||||
enabled: false,
|
||||
button_label: 'Mit Microsoft anmelden',
|
||||
tenant_id: '',
|
||||
client_id: '',
|
||||
client_secret: '',
|
||||
redirect_uri: 'https://pulse.perinco.de/api/auth/sso/callback',
|
||||
// Uebernommen wird nur von Hand in der SSO-Verwaltung; Rollen vergibt Pulse.
|
||||
local_login: true,
|
||||
},
|
||||
};
|
||||
|
||||
export async function getSettings() {
|
||||
const [rows] = await pool.query('SELECT k, v FROM pulse_settings');
|
||||
const stored = {};
|
||||
for (const row of rows) {
|
||||
try {
|
||||
stored[row.k] = JSON.parse(row.v);
|
||||
} catch {
|
||||
/* kaputter Wert - Standard gilt */
|
||||
}
|
||||
}
|
||||
const result = {};
|
||||
for (const [key, def] of Object.entries(DEFAULTS)) {
|
||||
const value = stored[key];
|
||||
if (def && typeof def === 'object' && !Array.isArray(def)) {
|
||||
result[key] = { ...def, ...(value && typeof value === 'object' ? value : {}) };
|
||||
} else {
|
||||
result[key] = value ?? def;
|
||||
}
|
||||
}
|
||||
if (result.sso.client_secret) result.sso.client_secret = decrypt(result.sso.client_secret);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function saveSetting(key, value) {
|
||||
if (!(key in DEFAULTS)) throw new Error(`Unbekannte Einstellung: ${key}`);
|
||||
let stored = value;
|
||||
if (key === 'sso' && value.client_secret) stored = { ...value, client_secret: encrypt(value.client_secret) };
|
||||
await pool.query(
|
||||
'INSERT INTO pulse_settings (k, v) VALUES (?, ?) ON DUPLICATE KEY UPDATE v = VALUES(v)',
|
||||
[key, JSON.stringify(stored)]
|
||||
);
|
||||
}
|
||||
|
||||
/* Client Secret verschluesselt ablegen (AES-256-GCM, Schluessel aus SETTINGS_KEY). */
|
||||
function key() {
|
||||
const base = process.env.SETTINGS_KEY || process.env.JWT_SECRET || '';
|
||||
return crypto.createHash('sha256').update(base).digest();
|
||||
}
|
||||
|
||||
function encrypt(plain) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key(), iv);
|
||||
const data = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
|
||||
return `enc:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${data.toString('base64')}`;
|
||||
}
|
||||
|
||||
function decrypt(value) {
|
||||
if (typeof value !== 'string' || !value.startsWith('enc:')) return value;
|
||||
try {
|
||||
const [, iv, tag, data] = value.split(':');
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key(), Buffer.from(iv, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||
return Buffer.concat([decipher.update(Buffer.from(data, 'base64')), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { pool } from '../db.js';
|
||||
|
||||
/*
|
||||
* Microsoft Entra ID (M365).
|
||||
*
|
||||
* Anmeldung: OpenID Connect, Authorization Code Flow als vertrauliche App
|
||||
* (Client Secret). Das id_token kommt direkt vom Token-Endpunkt ueber TLS -
|
||||
* nach OIDC Core 3.1.3.7 reicht dann die Pruefung von aud/tid/exp.
|
||||
*
|
||||
* Verzeichnis: Client Credentials gegen Microsoft Graph (Anwendungsberechtigung
|
||||
* User.Read.All). Angezeigt werden nur echte Mitarbeiterkonten - siehe
|
||||
* isEligible(). Uebernommen wird von Hand in der SSO-Verwaltung; anmelden kann
|
||||
* sich nur, wer uebernommen wurde. Rollen vergibt ausschliesslich Pulse, AD-
|
||||
* Gruppen spielen keine Rolle.
|
||||
*/
|
||||
|
||||
const LOGIN = 'https://login.microsoftonline.com';
|
||||
|
||||
/** Erlaubte Maildomains fuer Mitarbeiterkonten. */
|
||||
export const ALLOWED_DOMAINS = ['perinco.de', 'icon-experts.de'];
|
||||
|
||||
/*
|
||||
* Genau zwei Namensteile: vorname.nachname@domain. Bindestriche innerhalb eines
|
||||
* Teils sind erlaubt (anna-lena.mueller), weitere Punkte nicht - damit fallen
|
||||
* Admin-Konten wie adm.sascha.wolf@ und Funktionsadressen wie info@ heraus.
|
||||
*/
|
||||
const MAIL_PATTERN = new RegExp(
|
||||
`^[a-z]+(?:-[a-z]+)*\\.[a-z]+(?:-[a-z]+)*@(?:${ALLOWED_DOMAINS.map((d) => d.replace('.', '\\.')).join('|')})$`
|
||||
);
|
||||
|
||||
export function ssoConfigured(sso) {
|
||||
return Boolean(sso.tenant_id && sso.client_id && sso.client_secret && sso.redirect_uri);
|
||||
}
|
||||
|
||||
/** Primaere Mailadresse eines AD-Benutzers (mail, sonst UPN), klein geschrieben. */
|
||||
export function adEmail(ad) {
|
||||
return String(ad.mail || ad.userPrincipalName || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** Technische Praefixe, die nie eine Person sind (adm.sascha.wolf, sa.hrworks, srv.pa). */
|
||||
const TECHNICAL_PREFIXES = ['adm', 'admin', 'sa', 'srv', 'svc', 'service', 'test'];
|
||||
|
||||
/** "Mülln" -> "muelln", "René" -> "rene", "Grote-Aksu" -> "groteaksu". */
|
||||
export function normalizeName(value) {
|
||||
return String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, 'ae')
|
||||
.replace(/ö/g, 'oe')
|
||||
.replace(/ü/g, 'ue')
|
||||
.replace(/ß/g, 'ss')
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '')
|
||||
.replace(/[^a-z]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist das ein echtes Mitarbeiterkonto?
|
||||
* - Mail im Format vorname.nachname@perinco.de bzw. @icon-experts.de
|
||||
* - kein technisches Praefix (sa., srv., adm. ...)
|
||||
* - Nachname im AD passt zum Teil hinter dem Punkt - Dienstkonten wie
|
||||
* sa.hrworks ("Service Account") oder srv.pa ("SRV Flow") fallen so heraus.
|
||||
* Der Vorname wird bewusst nicht verglichen: Tippfehler im AD kommen vor.
|
||||
* - kein Gast
|
||||
* - Konto aktiv (freigegebene Postfaecher sind in Entra deaktiviert)
|
||||
* - mindestens eine Lizenz (freigegebene Postfaecher und Dienstkonten haben keine)
|
||||
*/
|
||||
export function isEligible(ad) {
|
||||
if (ad.userType === 'Guest') return false;
|
||||
if (ad.accountEnabled === false) return false;
|
||||
if (Array.isArray(ad.assignedLicenses) && ad.assignedLicenses.length === 0) return false;
|
||||
const email = adEmail(ad);
|
||||
if (!MAIL_PATTERN.test(email)) return false;
|
||||
const [first, last] = email.split('@')[0].split('.');
|
||||
if (TECHNICAL_PREFIXES.includes(first)) return false;
|
||||
const surname = normalizeName(ad.surname);
|
||||
return surname.length > 0 && normalizeName(last) === surname;
|
||||
}
|
||||
|
||||
export function authorizeUrl(sso, state) {
|
||||
const params = new URLSearchParams({
|
||||
client_id: sso.client_id,
|
||||
response_type: 'code',
|
||||
redirect_uri: sso.redirect_uri,
|
||||
response_mode: 'query',
|
||||
scope: 'openid profile email',
|
||||
state,
|
||||
prompt: 'select_account',
|
||||
});
|
||||
return `${LOGIN}/${encodeURIComponent(sso.tenant_id)}/oauth2/v2.0/authorize?${params}`;
|
||||
}
|
||||
|
||||
async function tokenRequest(sso, body) {
|
||||
const response = await fetch(`${LOGIN}/${encodeURIComponent(sso.tenant_id)}/oauth2/v2.0/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ client_id: sso.client_id, client_secret: sso.client_secret, ...body }),
|
||||
});
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(String(payload.error_description || payload.error || `HTTP ${response.status}`).split('\n')[0]);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export async function exchangeCode(sso, code) {
|
||||
const tokens = await tokenRequest(sso, {
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: sso.redirect_uri,
|
||||
scope: 'openid profile email',
|
||||
});
|
||||
const [, body] = String(tokens.id_token || '').split('.');
|
||||
if (!body) throw new Error('Kein id_token erhalten.');
|
||||
const claims = JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
|
||||
if (claims.aud !== sso.client_id) throw new Error('id_token gehört zu einer anderen Anwendung.');
|
||||
if (claims.exp * 1000 < Date.now()) throw new Error('id_token ist abgelaufen.');
|
||||
if (/^[0-9a-f-]{36}$/i.test(sso.tenant_id) && claims.tid !== sso.tenant_id) throw new Error('Falscher Mandant.');
|
||||
return {
|
||||
oid: claims.oid,
|
||||
email: String(claims.email || claims.preferred_username || '').toLowerCase(),
|
||||
name: claims.name || claims.preferred_username,
|
||||
};
|
||||
}
|
||||
|
||||
async function graphToken(sso) {
|
||||
const tokens = await tokenRequest(sso, {
|
||||
grant_type: 'client_credentials',
|
||||
scope: 'https://graph.microsoft.com/.default',
|
||||
});
|
||||
return tokens.access_token;
|
||||
}
|
||||
|
||||
const SELECT = 'id,displayName,givenName,surname,mail,userPrincipalName,jobTitle,department,accountEnabled,userType,assignedLicenses';
|
||||
|
||||
async function graphGetAll(token, url, maxPages = 100) {
|
||||
const items = [];
|
||||
let next = url;
|
||||
let pages = 0;
|
||||
while (next && pages++ < maxPages) {
|
||||
const response = await fetch(next, { headers: { Authorization: `Bearer ${token}` } });
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(payload.error?.message || `Graph HTTP ${response.status}`);
|
||||
items.push(...(payload.value || []));
|
||||
next = payload['@odata.nextLink'];
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function testConnection(sso) {
|
||||
const token = await graphToken(sso);
|
||||
const users = await graphGetAll(token, `https://graph.microsoft.com/v1.0/users?$select=${SELECT}&$top=50`, 1);
|
||||
const eligible = users.filter(isEligible);
|
||||
return { sample: eligible.slice(0, 5).map((u) => u.displayName), checked: users.length, eligible: eligible.length };
|
||||
}
|
||||
|
||||
/*
|
||||
* Verzeichnis-Zwischenspeicher. Die SSO-Verwaltung filtert und sucht im
|
||||
* Browser; Graph wird hoechstens alle 5 Minuten (oder auf Knopfdruck) gefragt.
|
||||
* Der Schluessel haengt an Tenant und Client, damit ein Wechsel der
|
||||
* Zugangsdaten nie alte Daten zeigt.
|
||||
*/
|
||||
const CACHE_MS = 5 * 60 * 1000;
|
||||
let cache = { key: null, at: 0, users: null, raw: null };
|
||||
|
||||
function mapUser(ad) {
|
||||
return {
|
||||
id: ad.id,
|
||||
name: ad.displayName || adEmail(ad),
|
||||
given_name: ad.givenName || null,
|
||||
surname: ad.surname || null,
|
||||
email: adEmail(ad),
|
||||
job_title: ad.jobTitle || null,
|
||||
department: ad.department || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Alle AD-Konten (roh) - fuer den Abgleich, der auch Deaktivierungen sehen muss. */
|
||||
async function fetchAll(sso, refresh) {
|
||||
const key = `${sso.tenant_id}|${sso.client_id}`;
|
||||
if (!refresh && cache.key === key && cache.raw && Date.now() - cache.at < CACHE_MS) return cache.raw;
|
||||
const token = await graphToken(sso);
|
||||
const raw = await graphGetAll(token, `https://graph.microsoft.com/v1.0/users?$select=${SELECT}&$top=999`);
|
||||
cache = { key, at: Date.now(), raw, users: raw.filter(isEligible).map(mapUser).sort((a, b) => a.name.localeCompare(b.name, 'de')) };
|
||||
return raw;
|
||||
}
|
||||
|
||||
/** Gefilterte Mitarbeiterkonten aus dem AD. */
|
||||
export async function listDirectory(sso, { refresh = false } = {}) {
|
||||
await fetchAll(sso, refresh);
|
||||
return { users: cache.users, fetched_at: new Date(cache.at).toISOString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Uebernimmt AD-Konten nach Pulse. Existiert schon ein Benutzer mit derselben
|
||||
* Objekt-ID oder Mailadresse (z. B. ein vorher lokal angelegtes Konto), wird er
|
||||
* verknuepft statt doppelt angelegt. Die Rolle wird in beiden Faellen gesetzt.
|
||||
*/
|
||||
export async function importUsers(sso, adIds, { role, managerId }) {
|
||||
const { users } = await listDirectory(sso);
|
||||
const byId = new Map(users.map((u) => [u.id, u]));
|
||||
const result = { created: [], linked: [], skipped: [] };
|
||||
|
||||
for (const adId of adIds) {
|
||||
const ad = byId.get(adId);
|
||||
if (!ad) {
|
||||
result.skipped.push(adId);
|
||||
continue;
|
||||
}
|
||||
const [rows] = await pool.query('SELECT id FROM pulse_users WHERE external_id = ? OR email = ? LIMIT 1', [ad.id, ad.email]);
|
||||
if (rows[0]) {
|
||||
await pool.query(
|
||||
`UPDATE pulse_users
|
||||
SET external_id = ?, source = 'sso', name = ?, email = ?, job_title = COALESCE(?, job_title),
|
||||
role = ?, manager_id = COALESCE(?, manager_id), is_active = 1, is_external = 0
|
||||
WHERE id = ?`,
|
||||
[ad.id, ad.name, ad.email, ad.job_title, role, managerId ?? null, rows[0].id]
|
||||
);
|
||||
result.linked.push(rows[0].id);
|
||||
} else {
|
||||
const [insert] = await pool.query(
|
||||
`INSERT INTO pulse_users (username, email, name, role, manager_id, source, external_id, job_title)
|
||||
VALUES (?, ?, ?, ?, ?, 'sso', ?, ?)`,
|
||||
[await freeUsername(ad.email), ad.email, ad.name, role, managerId ?? null, ad.id, ad.job_title]
|
||||
);
|
||||
result.created.push(insert.insertId);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abgleich der uebernommenen Benutzer: Name, Mail, Jobtitel aus dem AD.
|
||||
* Wer im AD geloescht, deaktiviert oder nicht mehr zulaessig ist, wird in Pulse
|
||||
* deaktiviert. Neue Konten legt der Abgleich nie an - das geschieht nur in der
|
||||
* SSO-Verwaltung.
|
||||
*/
|
||||
export async function syncUsers(sso) {
|
||||
const raw = await fetchAll(sso, true);
|
||||
const byId = new Map(raw.map((u) => [u.id, u]));
|
||||
const [linked] = await pool.query("SELECT id, external_id, is_active FROM pulse_users WHERE source = 'sso' AND external_id IS NOT NULL");
|
||||
|
||||
let updated = 0;
|
||||
let deactivated = 0;
|
||||
for (const user of linked) {
|
||||
const ad = byId.get(user.external_id);
|
||||
if (!ad || !isEligible(ad)) {
|
||||
if (user.is_active) {
|
||||
await pool.query('UPDATE pulse_users SET is_active = 0 WHERE id = ?', [user.id]);
|
||||
deactivated++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
await pool.query('UPDATE pulse_users SET name = ?, email = ?, job_title = COALESCE(?, job_title) WHERE id = ?', [
|
||||
ad.displayName || adEmail(ad),
|
||||
adEmail(ad),
|
||||
ad.jobTitle || null,
|
||||
user.id,
|
||||
]);
|
||||
updated++;
|
||||
}
|
||||
return { total: raw.length, eligible: cache.users.length, linked: linked.length, updated, deactivated };
|
||||
}
|
||||
|
||||
export async function freeUsername(email) {
|
||||
const base = email.split('@')[0].replace(/[^a-z0-9._-]/gi, '').toLowerCase().slice(0, 60) || 'user';
|
||||
let candidate = base;
|
||||
for (let i = 2; i < 1000; i++) {
|
||||
const [rows] = await pool.query('SELECT id FROM pulse_users WHERE username = ? LIMIT 1', [candidate]);
|
||||
if (rows.length === 0) return candidate;
|
||||
candidate = `${base}${i}`;
|
||||
}
|
||||
return `${base}-${crypto.randomBytes(3).toString('hex')}`;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { pool } from '../db.js';
|
||||
|
||||
export const ROLES = ['user', 'lead', 'management', 'admin'];
|
||||
const RANK = { user: 0, lead: 1, management: 2, admin: 3 };
|
||||
|
||||
function secret() {
|
||||
const value = process.env.JWT_SECRET;
|
||||
if (!value || value.length < 16) throw new Error('JWT_SECRET fehlt oder ist zu kurz (min. 16 Zeichen).');
|
||||
return value;
|
||||
}
|
||||
|
||||
export function signToken(user) {
|
||||
return jwt.sign({ sub: user.id, role: user.role }, secret(), {
|
||||
expiresIn: process.env.JWT_EXPIRES_IN || '12h',
|
||||
});
|
||||
}
|
||||
|
||||
export function signShort(payload, expiresIn = '10m') {
|
||||
return jwt.sign(payload, secret(), { expiresIn });
|
||||
}
|
||||
|
||||
export function verifyShort(token) {
|
||||
return jwt.verify(token, secret());
|
||||
}
|
||||
|
||||
export const USER_COLUMNS = `id, username, email, name, role, manager_id, job_title, is_external, company,
|
||||
source, external_id, weekly_hours, color, theme, theme_mode, is_active, last_login_at, created_at, updated_at`;
|
||||
|
||||
/**
|
||||
* Prueft das Bearer-Token und laedt den Benutzer bei jeder Anfrage frisch aus
|
||||
* der Datenbank - ein deaktiviertes Konto ist sofort draussen und eine
|
||||
* geaenderte Rolle gilt sofort.
|
||||
*/
|
||||
export async function requireAuth(req, res, next) {
|
||||
const header = req.headers.authorization || '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
|
||||
if (!token) return res.status(401).json({ error: 'Nicht angemeldet.' });
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = jwt.verify(token, secret());
|
||||
} catch {
|
||||
return res.status(401).json({ error: 'Sitzung abgelaufen. Bitte neu anmelden.' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await pool.query(`SELECT ${USER_COLUMNS} FROM pulse_users WHERE id = ? LIMIT 1`, [payload.sub]);
|
||||
const user = rows[0];
|
||||
if (!user) return res.status(401).json({ error: 'Benutzer existiert nicht mehr.' });
|
||||
if (!user.is_active) return res.status(401).json({ error: 'Benutzerkonto ist deaktiviert.' });
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
export function hasRole(user, minimum) {
|
||||
return RANK[user?.role] >= RANK[minimum];
|
||||
}
|
||||
|
||||
/** Mindestrolle: lead < management < admin. */
|
||||
export function requireRole(minimum) {
|
||||
return (req, res, next) => {
|
||||
if (!hasRole(req.user, minimum)) {
|
||||
return res.status(403).json({ error: 'Für diese Aktion fehlen die Berechtigungen.' });
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function publicUser(row) {
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
email: row.email,
|
||||
name: row.name,
|
||||
role: row.role,
|
||||
manager_id: row.manager_id ?? null,
|
||||
job_title: row.job_title ?? null,
|
||||
is_external: Boolean(row.is_external),
|
||||
company: row.company ?? null,
|
||||
source: row.source,
|
||||
weekly_hours: row.weekly_hours == null ? null : Number(row.weekly_hours),
|
||||
color: row.color ?? null,
|
||||
theme: row.theme ?? null,
|
||||
theme_mode: row.theme_mode ?? null,
|
||||
is_active: Boolean(row.is_active),
|
||||
last_login_at: row.last_login_at ?? null,
|
||||
created_at: row.created_at,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,147 @@
|
||||
-- perinco Pulse - Datenbankschema (MariaDB)
|
||||
-- Wird bei jedem Start ausgefuehrt; alle Statements sind idempotent.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pulse_users (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
username VARCHAR(80) NOT NULL,
|
||||
email VARCHAR(190) NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
password_hash VARCHAR(100) NULL,
|
||||
role ENUM('user','lead','management','admin') NOT NULL DEFAULT 'user',
|
||||
manager_id INT UNSIGNED NULL,
|
||||
job_title VARCHAR(120) NULL,
|
||||
is_external TINYINT(1) NOT NULL DEFAULT 0,
|
||||
company VARCHAR(160) NULL,
|
||||
source ENUM('local','sso') NOT NULL DEFAULT 'local',
|
||||
external_id VARCHAR(64) NULL,
|
||||
weekly_hours DECIMAL(5,2) NULL,
|
||||
color VARCHAR(7) NULL,
|
||||
theme VARCHAR(20) NULL,
|
||||
theme_mode VARCHAR(10) NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
last_login_at DATETIME NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_pulse_users_username (username),
|
||||
UNIQUE KEY uq_pulse_users_email (email),
|
||||
UNIQUE KEY uq_pulse_users_external (external_id),
|
||||
KEY idx_pulse_users_manager (manager_id),
|
||||
CONSTRAINT fk_pulse_users_manager FOREIGN KEY (manager_id) REFERENCES pulse_users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pulse_customers (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
name VARCHAR(190) NOT NULL,
|
||||
short_name VARCHAR(16) NOT NULL,
|
||||
contact_person VARCHAR(160) NULL,
|
||||
email VARCHAR(190) NULL,
|
||||
phone VARCHAR(60) NULL,
|
||||
street VARCHAR(190) NULL,
|
||||
zip VARCHAR(20) NULL,
|
||||
city VARCHAR(120) NULL,
|
||||
country VARCHAR(80) NULL,
|
||||
notes TEXT NULL,
|
||||
color VARCHAR(7) NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_pulse_customers_short (short_name)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pulse_projects (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
customer_id INT UNSIGNED NOT NULL,
|
||||
code VARCHAR(40) NOT NULL,
|
||||
seq INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
name VARCHAR(190) NOT NULL,
|
||||
type ENUM('billable','non_billable','fixed') NOT NULL DEFAULT 'billable',
|
||||
billing_unit ENUM('hour','day') NOT NULL DEFAULT 'hour',
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NULL,
|
||||
tags TEXT NULL,
|
||||
notes TEXT NULL,
|
||||
owner_id INT UNSIGNED NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_by INT UNSIGNED NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_pulse_projects_code (code),
|
||||
KEY idx_pulse_projects_customer (customer_id),
|
||||
CONSTRAINT fk_pulse_projects_customer FOREIGN KEY (customer_id) REFERENCES pulse_customers (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_pulse_projects_owner FOREIGN KEY (owner_id) REFERENCES pulse_users (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- Budgettopf. rate ist immer in der Abrechnungseinheit des Projekts (Stunde
|
||||
-- oder Tag). budget_hours ist das buchbare Volumen: bei Betrag + Satz
|
||||
-- abgeleitet, sonst direkt eingegeben. NULL = unbegrenzt.
|
||||
CREATE TABLE IF NOT EXISTS pulse_budget_pots (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
project_id INT UNSIGNED NOT NULL,
|
||||
label VARCHAR(160) NOT NULL,
|
||||
valid_from DATE NOT NULL,
|
||||
valid_to DATE NOT NULL,
|
||||
budget_amount DECIMAL(12,2) NULL,
|
||||
rate DECIMAL(10,2) NULL,
|
||||
budget_hours DECIMAL(10,2) NULL,
|
||||
notes VARCHAR(500) NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_pulse_pots_project (project_id, valid_from, valid_to),
|
||||
CONSTRAINT fk_pulse_pots_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pulse_tasks (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
project_id INT UNSIGNED NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
billable TINYINT(1) NOT NULL DEFAULT 1,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_pulse_tasks_project (project_id),
|
||||
CONSTRAINT fk_pulse_tasks_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pulse_project_members (
|
||||
project_id INT UNSIGNED NOT NULL,
|
||||
user_id INT UNSIGNED NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (project_id, user_id),
|
||||
KEY idx_pulse_members_user (user_id),
|
||||
CONSTRAINT fk_pulse_members_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_pulse_members_user FOREIGN KEY (user_id) REFERENCES pulse_users (id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pulse_time_entries (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
user_id INT UNSIGNED NOT NULL,
|
||||
project_id INT UNSIGNED NOT NULL,
|
||||
task_id INT UNSIGNED NULL,
|
||||
pot_id INT UNSIGNED NULL,
|
||||
entry_date DATE NOT NULL,
|
||||
minutes INT UNSIGNED NOT NULL,
|
||||
note VARCHAR(1000) NULL,
|
||||
billable TINYINT(1) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_pulse_entries_user_date (user_id, entry_date),
|
||||
KEY idx_pulse_entries_project_date (project_id, entry_date),
|
||||
KEY idx_pulse_entries_pot (pot_id),
|
||||
KEY idx_pulse_entries_date (entry_date),
|
||||
CONSTRAINT fk_pulse_entries_user FOREIGN KEY (user_id) REFERENCES pulse_users (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_pulse_entries_project FOREIGN KEY (project_id) REFERENCES pulse_projects (id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_pulse_entries_task FOREIGN KEY (task_id) REFERENCES pulse_tasks (id) ON DELETE SET NULL,
|
||||
CONSTRAINT fk_pulse_entries_pot FOREIGN KEY (pot_id) REFERENCES pulse_budget_pots (id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pulse_settings (
|
||||
k VARCHAR(80) NOT NULL,
|
||||
v MEDIUMTEXT NULL,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (k)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,72 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { initDatabase, pool } from './db.js';
|
||||
import { requireAuth, requireRole } from './middleware/auth.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import userRoutes from './routes/users.js';
|
||||
import customerRoutes from './routes/customers.js';
|
||||
import projectRoutes from './routes/projects.js';
|
||||
import entryRoutes from './routes/entries.js';
|
||||
import dashboardRoutes from './routes/dashboard.js';
|
||||
import reportRoutes from './routes/reports.js';
|
||||
import exportRoutes from './routes/exports.js';
|
||||
import settingsRoutes from './routes/settings.js';
|
||||
import directoryRoutes from './routes/directory.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT || 5010);
|
||||
const HOST = process.env.HOST || '127.0.0.1';
|
||||
|
||||
// Apache steht davor - fuer req.ip (Login-Bremse) die echte Client-IP nehmen.
|
||||
app.set('trust proxy', 'loopback');
|
||||
app.disable('x-powered-by');
|
||||
// Logos kommen als Data-URL.
|
||||
app.use(express.json({ limit: '4mb' }));
|
||||
|
||||
app.use(
|
||||
cors({
|
||||
origin: (process.env.CORS_ORIGINS || 'http://localhost:3110,http://127.0.0.1:3110').split(','),
|
||||
})
|
||||
);
|
||||
|
||||
app.get('/api/health', async (req, res) => {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
res.json({ ok: true, db: 'up' });
|
||||
} catch {
|
||||
res.status(503).json({ ok: false, db: 'down' });
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/users', requireAuth, userRoutes);
|
||||
app.use('/api/customers', requireAuth, requireRole('lead'), customerRoutes);
|
||||
app.use('/api/projects', requireAuth, projectRoutes);
|
||||
app.use('/api/entries', requireAuth, entryRoutes);
|
||||
app.use('/api/dashboard', requireAuth, requireRole('lead'), dashboardRoutes);
|
||||
app.use('/api/reports', requireAuth, requireRole('lead'), reportRoutes);
|
||||
app.use('/api/exports', requireAuth, exportRoutes);
|
||||
app.use('/api/settings', requireAuth, settingsRoutes);
|
||||
app.use('/api/directory', requireAuth, requireRole('management'), directoryRoutes);
|
||||
|
||||
app.use('/api', (req, res) => res.status(404).json({ error: 'Endpunkt nicht gefunden.' }));
|
||||
|
||||
app.use((err, req, res, next) => {
|
||||
if (res.headersSent) return next(err);
|
||||
if (err?.type === 'entity.parse.failed') return res.status(400).json({ error: 'Ungültige Anfrage.' });
|
||||
if (err?.type === 'entity.too.large') return res.status(413).json({ error: 'Die Anfrage ist zu groß.' });
|
||||
if (err?.code === 'ER_ROW_IS_REFERENCED_2') {
|
||||
return res.status(409).json({ error: 'Der Datensatz wird noch verwendet und kann nicht gelöscht werden.' });
|
||||
}
|
||||
console.error('[Pulse] Unbehandelter Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.' });
|
||||
});
|
||||
|
||||
try {
|
||||
await initDatabase();
|
||||
app.listen(PORT, HOST, () => console.log(`[Pulse] Backend läuft auf http://${HOST}:${PORT}`));
|
||||
} catch (err) {
|
||||
console.error('[Pulse] Start fehlgeschlagen:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user