Files
perinco-Pulse/server/src/routes/exports.js
T
schakal00783andClaude Opus 5 80e46910fd feat(reports): nicht abrechenbare Projekte ausblenden
Im Zeitreport stehen interne Projekte und Abwesenheiten zwischen den
Kundenzahlen: 2025 sind das 19.451 von 45.762 Stunden, und die Auslastung
zeigt 146 Prozent, weil Urlaub und Krankheit mitzaehlen.

Ein Haken neben der Billable-Aufteilung blendet Projekte vom Typ
"nicht abrechenbar" aus. Er wirkt auf den ganzen Report - Summen,
Auslastung, alle vier Gruppierungen - und auf die Exporte, die denselben
Report bauen; im PDF-Kopf steht dann "Nicht abrechenbare Projekte:
ausgeblendet". Gefiltert wird in loadEntries, damit Kunden-, Projekt- und
Taskliste dieselbe Grundmenge sehen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 17:07:37 +02:00

266 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 orderNumbers = [...new Set(entries.map((e) => e.pot_order_number).filter(Boolean))];
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],
// Bestellnummern der beteiligten Budgettoepfe - viele Kunden verlangen
// sie auf dem Nachweis. Ohne Nummer bleibt die Zeile weg.
...(orderNumbers.length ? [[orderNumbers.length > 1 ? 'Bestellnummern' : 'Bestellnummer', orderNumbers.join(', ')]] : []),
],
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'],
...(report.hide_non_billable ? [['Nicht abrechenbare Projekte', 'ausgeblendet']] : []),
],
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;