From 8a8e4333974ffbd75375790235ea99eadb451d35 Mon Sep 17 00:00:00 2001
From: schakal00783
Date: Tue, 22 Sep 2026 13:38:43 +0200
Subject: [PATCH] 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)
Claude-Session: https://claude.ai/code/session_01A8ZiAB96iSmQhDrK4Jpi2R
---
server/src/lib/entries.js | 8 +-
server/src/routes/exports.js | 40 +++++
.../customers/CustomerExportDialog.tsx | 158 ++++++++++++++++++
src/components/customers/Customers.tsx | 13 +-
4 files changed, 216 insertions(+), 3 deletions(-)
create mode 100644 src/components/customers/CustomerExportDialog.tsx
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([]);
+
+ const projekte = useMemo(
+ () => [...customer.projects].sort((a, b) => Number(b.is_active) - Number(a.is_active) || a.display_name.localeCompare(b.display_name, 'de')),
+ [customer.projects]
+ );
+
+ const months = useMemo(() => {
+ const list: string[] = [];
+ const now = new Date();
+ for (let i = -2; i < 18; i++) {
+ const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
+ list.push(`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`);
+ }
+ return list;
+ }, []);
+
+ const umschalten = (id: number) =>
+ setGewaehlt((v) => (v.includes(id) ? v.filter((x) => x !== id) : [...v, id]));
+
+ const run = async () => {
+ setBusy(true);
+ try {
+ await download(
+ `/exports/timesheet${qs({
+ month,
+ format,
+ user_id: 'all',
+ customer_id: customer.id,
+ project_ids: gewaehlt.length ? gewaehlt.join(',') : undefined,
+ amounts: amounts ? 1 : undefined,
+ })}`,
+ `${customer.short_name}-${month}.${format}`
+ );
+ addToast('Export erstellt.', 'success');
+ } catch (err) {
+ addToast(err instanceof Error ? err.message : 'Export fehlgeschlagen.', 'error');
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
+
+ >
+ }
+ >
+
+
+ {FORMATS.map((f) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ {projekte.length === 0 &&
Für diesen Kunden gibt es noch kein Projekt.
}
+ {projekte.map((p) => (
+
+ ))}
+
+
+
+ {gewaehlt.length > 0 && (
+
+ )}
+
+ {atLeast(user, 'lead') && (
+
+ )}
+
+
+ );
+};
diff --git a/src/components/customers/Customers.tsx b/src/components/customers/Customers.tsx
index 5edc136..1e0fb31 100644
--- a/src/components/customers/Customers.tsx
+++ b/src/components/customers/Customers.tsx
@@ -9,6 +9,7 @@ import { useGrow, useLoader, usePersistentState } from '../../lib/hooks';
import { MONTHS, colorFor, compactEuro, formatEuro, formatHours } from '../../lib/format';
import { useRouter } from '../../lib/router';
import { useToast } from '../Toast';
+import { CustomerExportDialog } from './CustomerExportDialog';
import { TYPE_TONE } from '../projects/Projects';
import { PROJECT_TYPE_LABELS, type Customer } from '../../types';
@@ -21,6 +22,7 @@ export const Customers: React.FC = () => {
const [form, setForm] = useState<{ customer: Customer | null } | null>(null);
const [remove, setRemove] = useState(null);
const [expanded, setExpanded] = useState(null);
+ const [exportFor, setExportFor] = useState(null);
const [layout, setLayout] = usePersistentState<'cards' | 'list'>('pulse_customers_layout', 'cards');
const customers = useMemo(() => {
@@ -107,6 +109,7 @@ export const Customers: React.FC = () => {
onEdit={(c) => setForm({ customer: c })}
onDelete={setRemove}
onReport={(id) => navigate(`reports/kunde/${id}`)}
+ onExport={setExportFor}
/>
) : (
@@ -122,12 +125,14 @@ export const Customers: React.FC = () => {
onEdit={() => setForm({ customer: c })}
onDelete={() => setRemove(c)}
onReport={() => navigate(`reports/kunde/${c.id}`)}
+ onExport={() => setExportFor(c)}
onProject={(id) => navigate(`projekte/${id}`)}
/>
))}
)}
+ {exportFor && setExportFor(null)} />}
{form && setForm(null)} onSaved={reload} />}
{remove && (
void;
onEdit: () => void;
+ onExport: () => void;
onDelete: () => void;
onReport: () => void;
onProject: (id: number) => void;
-}> = ({ customer: c, months, index, expanded, onToggle, onEdit, onDelete, onReport, onProject }) => {
+}> = ({ customer: c, months, index, expanded, onToggle, onEdit, onExport, onDelete, onReport, onProject }) => {
const color = colorFor(c.id, c.color);
const total = c.earned + c.open;
const percent = total ? (c.earned / total) * 100 : 0;
@@ -248,6 +254,7 @@ const CustomerCard: React.FC<{
+
{c.projects.length === 0 &&
}
@@ -355,9 +362,10 @@ type CustomerSortKey = 'name' | 'city' | 'projects' | 'used_minutes' | 'earned'
const CustomerTable: React.FC<{
customers: CustomerOverview[];
onEdit: (customer: CustomerOverview) => void;
+ onExport: (customer: CustomerOverview) => void;
onDelete: (customer: CustomerOverview) => void;
onReport: (id: number) => void;
-}> = ({ customers, onEdit, onDelete, onReport }) => {
+}> = ({ customers, onEdit, onExport, onDelete, onReport }) => {
const [sort, setSort] = useState<{ key: CustomerSortKey; dir: 1 | -1 }>({ key: 'earned', dir: -1 });
const sorted = useMemo(() => {
@@ -448,6 +456,7 @@ const CustomerTable: React.FC<{
+ onExport(c)} />
onReport(c.id)} />
onEdit(c)} />
{c.projects.length === 0 && onDelete(c)} />}
|