feat: perinco Pulse - Zeiterfassung fuer perinco

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-17 22:52:20 +02:00
co-authored by Claude Opus 5
commit ef676a95f4
104 changed files with 21030 additions and 0 deletions
+259
View File
@@ -0,0 +1,259 @@
import express from 'express';
import { pool } from '../db.js';
import { hasRole } from '../middleware/auth.js';
import { visibleUserIds } from '../lib/access.js';
import { getSettings } from '../lib/settings.js';
import { loadEntries } from '../lib/entries.js';
import { MONTHS_DE, formatDateDe, monthRange, parseDate } from '../lib/dates.js';
import { dateDe, durationDe, hoursDe, moneyDe, sendDocument } from '../export/render.js';
import { buildTimeReport } from './reports.js';
const router = express.Router();
const FORMATS = ['pdf', 'xlsx', 'csv'];
const WEEKDAYS = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
/**
* Stundennachweis eines Monats - die Vorlage fuer Kunden und Rechnungsstellung.
*
* ?month=2026-09 &format=pdf|xlsx|csv
* ?user_id=<id>|all eigener Nachweis (Standard), eines Teammitglieds oder aller sichtbaren
* ?customer_id / ?project_id auf einen Kunden bzw. ein Projekt einschraenken
* ?amounts=1 Satz und Betrag mit ausgeben (ab Rolle Vorgesetzter)
*/
router.get('/timesheet', async (req, res, next) => {
try {
const format = FORMATS.includes(req.query.format) ? req.query.format : 'pdf';
const range = monthRange(String(req.query.month || ''));
if (!range) return res.status(400).json({ error: 'Monat fehlt (JJJJ-MM).' });
let userIds = [req.user.id];
const wanted = req.query.user_id;
if (wanted && String(wanted) !== String(req.user.id)) {
const visible = await visibleUserIds(req.user);
if (wanted === 'all') {
if (!hasRole(req.user, 'lead')) return res.status(403).json({ error: 'Keine Einsicht in fremde Zeiten.' });
userIds = visible;
} else {
if (visible !== null && !visible.includes(Number(wanted))) {
return res.status(403).json({ error: 'Keine Einsicht in diese Zeiten.' });
}
userIds = [Number(wanted)];
}
}
const showAmounts = req.query.amounts === '1' && hasRole(req.user, 'lead');
const settings = await getSettings();
const entries = await loadEntries(settings, {
from: range.from,
to: range.to,
userIds,
customerId: req.query.customer_id,
projectId: req.query.project_id,
});
const [y, m] = req.query.month.split('-');
const monthName = `${MONTHS_DE[Number(m) - 1]} ${y}`;
const multiUser = userIds === null || userIds.length > 1;
let personName = 'Alle Mitarbeiter';
let company = '';
if (!multiUser) {
const [[person]] = await pool.query('SELECT name, company, is_external FROM pulse_users WHERE id = ?', [userIds[0]]);
personName = person?.name || '';
company = person?.is_external ? person.company || '' : settings.company_name;
}
let customerName = 'Alle Kunden';
let projectName = [...new Set(entries.map((e) => e.project_display))].join(', ') || '–';
if (req.query.customer_id) {
const [[c]] = await pool.query('SELECT name FROM pulse_customers WHERE id = ?', [Number(req.query.customer_id)]);
customerName = c?.name || '–';
} else {
const names = [...new Set(entries.map((e) => e.customer_name))];
if (names.length === 1) customerName = names[0];
}
if (projectName.length > 120) projectName = `${new Set(entries.map((e) => e.project_id)).size} Projekte`;
const total = entries.reduce((s, e) => s + e.minutes, 0);
const billable = entries.filter((e) => e.billable).reduce((s, e) => s + e.minutes, 0);
const days = new Set(entries.map((e) => e.entry_date)).size;
const amount = entries.reduce((s, e) => s + e.amount, 0);
const columns = [
{ key: 'entry_date', label: 'Datum', type: 'date', width: 58, xlsWidth: 12 },
{ key: 'weekday', label: 'Tag', width: 26, xlsWidth: 6 },
...(multiUser ? [{ key: 'user_name', label: 'Mitarbeiter', width: 90, xlsWidth: 22 }] : []),
{ key: 'project_display', label: 'Projekt', width: 110, xlsWidth: 28 },
{ key: 'task_name', label: 'Task', width: 80, xlsWidth: 20 },
{ key: 'note', label: 'Tätigkeit', type: 'text', xlsWidth: 44 },
{ key: 'duration', label: 'Dauer', align: 'right', width: 38, xlsWidth: 9 },
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 46, xlsWidth: 10 },
...(showAmounts
? [
{ key: 'rate', label: 'Satz/h', type: 'money', align: 'right', width: 56, xlsWidth: 12 },
{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 66, xlsWidth: 14 },
]
: []),
];
const rows = entries.map((e) => ({
...e,
weekday: WEEKDAYS[new Date(`${e.entry_date}T00:00:00Z`).getUTCDay()],
duration: durationDe(e.minutes),
rate: e.amount && e.minutes ? Math.round((e.amount / (e.minutes / 60)) * 100) / 100 : null,
}));
// Zusammenfassung je Projekt und Task.
const summary = new Map();
for (const e of entries) {
const key = `${e.project_id}|${e.task_name || ''}`;
const item = summary.get(key) || { project_display: e.project_display, task_name: e.task_name || '–', minutes: 0, amount: 0 };
item.minutes += e.minutes;
item.amount += e.amount;
summary.set(key, item);
}
const doc = {
title: settings.export.title || 'Stundennachweis',
subtitle: monthName,
filename: `Stundennachweis ${y}-${m} ${multiUser ? customerName : personName}`,
landscape: showAmounts || multiUser,
info: [
[multiUser ? 'Mitarbeiter' : 'Berater', personName + (company && !multiUser ? ` (${company})` : '')],
['Kunde', customerName],
['Zeitraum', `${formatDateDe(range.from)} – ${formatDateDe(range.to)}`],
['Projekt', projectName],
],
kpis: [
['Gesamtstunden', hoursDe(total)],
['Dauer (h:mm)', durationDe(total)],
['Tage mit Leistung', String(days)],
...(showAmounts ? [['Betrag', moneyDe(amount)]] : [['davon abrechenbar', hoursDe(billable)]]),
],
tables: [
{
columns,
rows,
totals: { entry_date: null, weekday: 'Summe', duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) },
},
{
title: 'Zusammenfassung nach Projekt und Task',
columns: [
{ key: 'project_display', label: 'Projekt', xlsWidth: 28 },
{ key: 'task_name', label: 'Task', width: 150 },
{ key: 'duration', label: 'Dauer', align: 'right', width: 60 },
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 60 },
...(showAmounts ? [{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 80 }] : []),
],
rows: [...summary.values()].map((s) => ({ ...s, duration: durationDe(s.minutes) })),
totals: { project_display: 'Summe', duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) },
},
],
signature: true,
};
// CSV: nur die Buchungen - die Zusammenfassung stoert beim Weiterverarbeiten.
if (format === 'csv') doc.tables = [doc.tables[0]];
await sendDocument(res, doc, format, settings);
} catch (err) {
next(err);
}
});
const GROUPS = {
users: { title: 'Mitarbeiter', label: 'Mitarbeiter' },
projects: { title: 'Projekte', label: 'Projekt' },
clients: { title: 'Kunden', label: 'Kunde' },
tasks: { title: 'Tasks', label: 'Task' },
};
/** Export des Zeitreports in der gewaehlten Aufteilung oder als Detailliste. */
router.get('/report', async (req, res, next) => {
try {
if (!hasRole(req.user, 'lead')) return res.status(403).json({ error: 'Keine Berechtigung.' });
const format = FORMATS.includes(req.query.format) ? req.query.format : 'pdf';
if (!parseDate(req.query.from) || !parseDate(req.query.to)) return res.status(400).json({ error: 'Zeitraum fehlt.' });
const settings = await getSettings();
const report = await buildTimeReport(req.user, req.query);
const s = report.summary;
const groupKey = GROUPS[req.query.group] ? req.query.group : req.query.group === 'detail' ? 'detail' : 'users';
let customerName = null;
if (req.query.customer_id) {
const [[c]] = await pool.query('SELECT name FROM pulse_customers WHERE id = ?', [Number(req.query.customer_id)]);
customerName = c?.name;
}
let table;
if (groupKey === 'detail') {
table = {
columns: [
{ key: 'entry_date', label: 'Datum', type: 'date', width: 58, xlsWidth: 12 },
{ key: 'user_name', label: 'Mitarbeiter', width: 90, xlsWidth: 22 },
{ key: 'project_display', label: 'Projekt', width: 120, xlsWidth: 28 },
{ key: 'task_name', label: 'Task', width: 80, xlsWidth: 20 },
{ key: 'note', label: 'Tätigkeit', type: 'text', xlsWidth: 40 },
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 48, xlsWidth: 10 },
{ key: 'billable_label', label: 'Abr.', width: 30, xlsWidth: 6 },
{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 66, xlsWidth: 14 },
],
rows: report.entries.map((e) => ({
...e,
billable_label: e.billable ? 'ja' : 'nein',
amount: e.amount + (report.include_fixed ? e.fixed_amount : 0),
})),
totals: { user_name: 'Summe', minutes: s.minutes, amount: s.amount },
};
} else {
const rows = report[groupKey].map((row) => ({
...row,
billable_percent: row.minutes ? Math.round((row.billable_minutes / row.minutes) * 100) : 0,
}));
table = {
columns: [
{ key: 'name', label: GROUPS[groupKey].label, xlsWidth: 36 },
...(groupKey === 'projects' ? [{ key: 'customer_name', label: 'Kunde', width: 110, xlsWidth: 24 }] : []),
{ key: 'minutes', label: 'Stunden', type: 'hours', align: 'right', width: 60, xlsWidth: 12 },
...(groupKey === 'users' ? [{ key: 'utilization', label: 'Auslastung', type: 'percent', align: 'right', width: 60, xlsWidth: 12 }] : []),
{ key: 'billable_minutes', label: 'Abrechenbar', type: 'hours', align: 'right', width: 66, xlsWidth: 13 },
{ key: 'billable_percent', label: 'Anteil', type: 'percent', align: 'right', width: 46, xlsWidth: 9 },
{ key: 'non_billable_minutes', label: 'Nicht abr.', type: 'hours', align: 'right', width: 60, xlsWidth: 12 },
{ key: 'amount', label: 'Betrag', type: 'money', align: 'right', width: 80, xlsWidth: 15 },
],
rows,
totals: {
name: 'Summe',
minutes: s.minutes,
billable_minutes: s.billable_minutes,
non_billable_minutes: s.non_billable_minutes,
amount: s.amount,
},
};
}
const title = groupKey === 'detail' ? 'Zeitreport – Detail' : `Zeitreport – ${GROUPS[groupKey].title}`;
await sendDocument(
res,
{
title,
subtitle: `${dateDe(report.range.from)} – ${dateDe(report.range.to)}`,
filename: `Zeitreport ${report.range.from} bis ${report.range.to}${customerName ? ` ${customerName}` : ''}`,
landscape: true,
sheetName: 'Zeitreport',
info: [
['Kunde', customerName || 'Alle Kunden'],
['Festpreisprojekte', report.include_fixed ? 'im Betrag enthalten' : 'nicht im Betrag enthalten'],
],
kpis: [
['Gesamtstunden', hoursDe(s.minutes)],
['Abrechenbar', `${hoursDe(s.billable_minutes)} (${s.minutes ? Math.round((s.billable_minutes / s.minutes) * 100) : 0} %)`],
['Nicht abrechenbar', hoursDe(s.non_billable_minutes)],
['Abrechenbarer Betrag', moneyDe(s.amount)],
],
tables: [table],
},
format,
settings
);
} catch (err) {
next(err);
}
});
export default router;