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