feat(kunden): Kundennachweis ueber alle Berater exportieren

Bisher ging ein Stundennachweis nur von einer Person aus. Fuer die
Rechnung an den Kunden ist aber der Auftrag die Klammer: wer alles auf
Auxil gebucht hat, steht nirgends auf einem Blatt.

Auf der Kundenseite oeffnet ein Knopf je Karte und Zeile den
Kundennachweis: Monat, Format und wahlweise einzelne Projekte des
Kunden; ohne Auswahl sind alle enthalten. Das Dokument zeigt je Zeile
den Berater und fasst am Ende nach Berater (Tage, Dauer, Stunden,
optional Betrag) und nach Projekt und Task zusammen.

Dafuer nimmt /exports/timesheet ein ?project_ids fuer mehrere Projekte,
und loadEntries filtert entsprechend. Die Berater-Zusammenfassung
erscheint nur, wenn mehr als eine Person beteiligt war.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A8ZiAB96iSmQhDrK4Jpi2R
This commit is contained in:
schakal00783
2026-09-22 13:38:43 +02:00
co-authored by Claude Opus 5
parent dd42fba1cb
commit 8a8e433397
4 changed files with 216 additions and 3 deletions
+7 -1
View File
@@ -12,7 +12,7 @@ import { userFilter } from './access.js';
* (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, skipNonBillableProjects = false } = {}) {
export async function loadEntries(settings, { from, to, userIds = null, customerId, projectId, projectIds, taskId, skipNonBillableProjects = false } = {}) {
// Administratoren sind keine Mitarbeiter - ihre Zeiten zaehlen nirgends mit.
const where = ["u.role <> 'admin'"];
const params = [];
@@ -35,6 +35,12 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
where.push('e.project_id = ?');
params.push(Number(projectId));
}
// Mehrere Projekte: fuer den Kundennachweis, der eine Auswahl abdecken soll.
const ids = (Array.isArray(projectIds) ? projectIds : []).map(Number).filter(Number.isFinite);
if (ids.length) {
where.push(`e.project_id IN (${ids.map(() => '?').join(',')})`);
params.push(...ids);
}
if (taskId) {
where.push('e.task_id = ?');
params.push(Number(taskId));
+40
View File
@@ -42,12 +42,20 @@ router.get('/timesheet', async (req, res, next) => {
}
const showAmounts = req.query.amounts === '1' && hasRole(req.user, 'lead');
const settings = await getSettings();
// ?project_ids=1,2,3 fuer den Kundennachweis ueber eine Projektauswahl.
const projectIds = String(req.query.project_ids || '')
.split(',')
.map((v) => Number(v.trim()))
// Ohne Angabe bleibt die Liste leer - aus '' wuerde sonst eine 0 und
// damit ein Filter auf ein Projekt, das es nicht gibt.
.filter((v) => Number.isFinite(v) && v > 0);
const entries = await loadEntries(settings, {
from: range.from,
to: range.to,
userIds,
customerId: req.query.customer_id,
projectId: req.query.project_id,
projectIds,
});
const [y, m] = req.query.month.split('-');
@@ -110,6 +118,20 @@ router.get('/timesheet', async (req, res, next) => {
summary.set(key, item);
}
/*
* Wer war beteiligt? Auf einem Kundennachweis ueber mehrere Berater ist das
* die Frage, die als Erstes kommt - die Spalte je Zeile beantwortet sie
* erst nach dem Durchlesen.
*/
const perPerson = new Map();
for (const e of entries) {
const item = perPerson.get(e.user_id) || { user_name: e.user_name, days: new Set(), minutes: 0, amount: 0 };
item.days.add(e.entry_date);
item.minutes += e.minutes;
item.amount += e.amount;
perPerson.set(e.user_id, item);
}
const orderNumbers = [...new Set(entries.map((e) => e.pot_order_number).filter(Boolean))];
const doc = {
@@ -138,6 +160,24 @@ router.get('/timesheet', async (req, res, next) => {
rows,
totals: { entry_date: null, weekday: 'Summe', duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) },
},
...(multiUser && perPerson.size > 1
? [
{
title: 'Zusammenfassung nach Berater',
columns: [
{ key: 'user_name', label: 'Berater', xlsWidth: 28 },
{ key: 'days', label: 'Tage', align: 'right', width: 60 },
{ 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: [...perPerson.values()]
.sort((a, b) => b.minutes - a.minutes)
.map((r) => ({ ...r, days: String(r.days.size), duration: durationDe(r.minutes) })),
totals: { user_name: 'Summe', days: String(days), duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) },
},
]
: []),
{
title: 'Zusammenfassung nach Projekt und Task',
columns: [