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=|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;