diff --git a/server/src/lib/entries.js b/server/src/lib/entries.js
index c3258dd..2b93680 100644
--- a/server/src/lib/entries.js
+++ b/server/src/lib/entries.js
@@ -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));
diff --git a/server/src/routes/exports.js b/server/src/routes/exports.js
index b0b4d94..cd1b882 100644
--- a/server/src/routes/exports.js
+++ b/server/src/routes/exports.js
@@ -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: [
diff --git a/src/components/customers/CustomerExportDialog.tsx b/src/components/customers/CustomerExportDialog.tsx
new file mode 100644
index 0000000..c88442f
--- /dev/null
+++ b/src/components/customers/CustomerExportDialog.tsx
@@ -0,0 +1,158 @@
+import React, { useMemo, useState } from 'react';
+import { Modal } from '../ui/Modal';
+import { Button, Field, Toggle, inputClass } from '../ui/controls';
+import { Icon, type IconName } from '../ui/Icon';
+import { useAuth } from '../../lib/auth';
+import { download, qs, type CustomerOverview } from '../../lib/api';
+import { MONTHS, formatHours } from '../../lib/format';
+import { atLeast } from '../../types';
+import { useToast } from '../Toast';
+
+const FORMATS: { value: 'pdf' | 'xlsx' | 'csv'; label: string; hint: string; icon: IconName }[] = [
+ { value: 'pdf', label: 'PDF', hint: 'Nachweis mit Logo, Kopf und Fuß', icon: 'document' },
+ { value: 'xlsx', label: 'Excel', hint: 'Zum Weiterrechnen, mit Summenformeln', icon: 'grid' },
+ { value: 'csv', label: 'CSV', hint: 'Rohdaten, Semikolon, UTF-8', icon: 'list' },
+];
+
+/**
+ * Kundennachweis: alle Buchungen eines Kunden in einem Monat, ueber alle
+ * Berater hinweg. Anders als der Nachweis aus der Buchung geht es hier nicht um
+ * eine Person, sondern um den Auftrag - das Dokument zeigt deshalb je Zeile,
+ * wer gebucht hat, und fasst am Ende nach Berater und nach Projekt zusammen.
+ */
+export const CustomerExportDialog: React.FC<{ customer: CustomerOverview; onClose: () => void }> = ({ customer, onClose }) => {
+ const { user } = useAuth();
+ const { addToast } = useToast();
+ const [month, setMonth] = useState(() => new Date().toISOString().slice(0, 7));
+ const [format, setFormat] = useState<'pdf' | 'xlsx' | 'csv'>('pdf');
+ const [amounts, setAmounts] = useState(false);
+ const [busy, setBusy] = useState(false);
+ // Leere Auswahl = alle Projekte des Kunden.
+ const [gewaehlt, setGewaehlt] = useState Für diesen Kunden gibt es noch kein Projekt.