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 * (Topfbudget / alle auf dem Topf gebuchten Stunden). Nur
* intern, in Reports per Schalter zuschaltbar. * 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. // Administratoren sind keine Mitarbeiter - ihre Zeiten zaehlen nirgends mit.
const where = ["u.role <> 'admin'"]; const where = ["u.role <> 'admin'"];
const params = []; const params = [];
@@ -35,6 +35,12 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
where.push('e.project_id = ?'); where.push('e.project_id = ?');
params.push(Number(projectId)); 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) { if (taskId) {
where.push('e.task_id = ?'); where.push('e.task_id = ?');
params.push(Number(taskId)); 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 showAmounts = req.query.amounts === '1' && hasRole(req.user, 'lead');
const settings = await getSettings(); 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, { const entries = await loadEntries(settings, {
from: range.from, from: range.from,
to: range.to, to: range.to,
userIds, userIds,
customerId: req.query.customer_id, customerId: req.query.customer_id,
projectId: req.query.project_id, projectId: req.query.project_id,
projectIds,
}); });
const [y, m] = req.query.month.split('-'); const [y, m] = req.query.month.split('-');
@@ -110,6 +118,20 @@ router.get('/timesheet', async (req, res, next) => {
summary.set(key, item); 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 orderNumbers = [...new Set(entries.map((e) => e.pot_order_number).filter(Boolean))];
const doc = { const doc = {
@@ -138,6 +160,24 @@ router.get('/timesheet', async (req, res, next) => {
rows, rows,
totals: { entry_date: null, weekday: 'Summe', duration: durationDe(total), minutes: total, ...(showAmounts ? { amount } : {}) }, 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', title: 'Zusammenfassung nach Projekt und Task',
columns: [ columns: [
@@ -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<number[]>([]);
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 (
<Modal
title={`Kundennachweis – ${customer.name}`}
subtitle="Alle Buchungen des Monats über alle Berater"
onClose={onClose}
footer={
<>
<Button variant="secondary" onClick={onClose}>
Schließen
</Button>
<Button icon="download" busy={busy} onClick={run}>
Exportieren
</Button>
</>
}
>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-2">
{FORMATS.map((f) => (
<button
key={f.value}
onClick={() => setFormat(f.value)}
className={`flex flex-col items-center gap-1 rounded-2xl border p-3 text-center transition ${
format === f.value ? 'border-brand-400 bg-brand-50 text-brand-700 shadow-card' : 'border-ink-200 text-ink-600 hover:border-brand-200'
}`}
>
<Icon name={f.icon} className="h-6 w-6" />
<span className="text-sm font-extrabold">{f.label}</span>
<span className="hidden text-[10px] leading-tight text-ink-400 sm:block">{f.hint}</span>
</button>
))}
</div>
<Field label="Monat">
<select className={inputClass} value={month} onChange={(e) => setMonth(e.target.value)}>
{months.map((m) => (
<option key={m} value={m}>
{MONTHS[Number(m.slice(5)) - 1]} {m.slice(0, 4)}
</option>
))}
</select>
</Field>
<Field
label="Projekte"
hint={gewaehlt.length ? `${gewaehlt.length} von ${projekte.length} ausgewählt` : 'Ohne Auswahl sind alle Projekte des Kunden enthalten'}
>
<div className="scrollbar-slim max-h-56 space-y-1 overflow-y-auto rounded-xl border border-ink-200 p-2">
{projekte.length === 0 && <p className="px-2 py-3 text-sm text-ink-400">Für diesen Kunden gibt es noch kein Projekt.</p>}
{projekte.map((p) => (
<label
key={p.id}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 text-sm transition hover:bg-ink-50"
>
<input
type="checkbox"
className="h-4 w-4 accent-[var(--color-brand-500)]"
checked={gewaehlt.includes(p.id)}
onChange={() => umschalten(p.id)}
/>
<span className="min-w-0 flex-1 truncate font-semibold text-ink-800">{p.display_name}</span>
{!p.is_active && <span className="shrink-0 text-[11px] text-ink-400">archiviert</span>}
<span className="tabular shrink-0 text-xs text-ink-400">{formatHours(p.used_minutes)} h</span>
</label>
))}
</div>
</Field>
{gewaehlt.length > 0 && (
<button onClick={() => setGewaehlt([])} className="text-xs font-bold text-brand-700 hover:underline">
Auswahl aufheben – alle Projekte nehmen
</button>
)}
{atLeast(user, 'lead') && (
<Toggle
checked={amounts}
onChange={setAmounts}
label="Stundensätze und Beträge ausweisen"
hint="Für die Rechnungsstellung. Im Nachweis an den Kunden meist ausgeschaltet."
/>
)}
</div>
</Modal>
);
};
+11 -2
View File
@@ -9,6 +9,7 @@ import { useGrow, useLoader, usePersistentState } from '../../lib/hooks';
import { MONTHS, colorFor, compactEuro, formatEuro, formatHours } from '../../lib/format'; import { MONTHS, colorFor, compactEuro, formatEuro, formatHours } from '../../lib/format';
import { useRouter } from '../../lib/router'; import { useRouter } from '../../lib/router';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import { CustomerExportDialog } from './CustomerExportDialog';
import { TYPE_TONE } from '../projects/Projects'; import { TYPE_TONE } from '../projects/Projects';
import { PROJECT_TYPE_LABELS, type Customer } from '../../types'; 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 [form, setForm] = useState<{ customer: Customer | null } | null>(null);
const [remove, setRemove] = useState<CustomerOverview | null>(null); const [remove, setRemove] = useState<CustomerOverview | null>(null);
const [expanded, setExpanded] = useState<number | null>(null); const [expanded, setExpanded] = useState<number | null>(null);
const [exportFor, setExportFor] = useState<CustomerOverview | null>(null);
const [layout, setLayout] = usePersistentState<'cards' | 'list'>('pulse_customers_layout', 'cards'); const [layout, setLayout] = usePersistentState<'cards' | 'list'>('pulse_customers_layout', 'cards');
const customers = useMemo(() => { const customers = useMemo(() => {
@@ -107,6 +109,7 @@ export const Customers: React.FC = () => {
onEdit={(c) => setForm({ customer: c })} onEdit={(c) => setForm({ customer: c })}
onDelete={setRemove} onDelete={setRemove}
onReport={(id) => navigate(`reports/kunde/${id}`)} onReport={(id) => navigate(`reports/kunde/${id}`)}
onExport={setExportFor}
/> />
</Card> </Card>
) : ( ) : (
@@ -122,12 +125,14 @@ export const Customers: React.FC = () => {
onEdit={() => setForm({ customer: c })} onEdit={() => setForm({ customer: c })}
onDelete={() => setRemove(c)} onDelete={() => setRemove(c)}
onReport={() => navigate(`reports/kunde/${c.id}`)} onReport={() => navigate(`reports/kunde/${c.id}`)}
onExport={() => setExportFor(c)}
onProject={(id) => navigate(`projekte/${id}`)} onProject={(id) => navigate(`projekte/${id}`)}
/> />
))} ))}
</div> </div>
)} )}
{exportFor && <CustomerExportDialog customer={exportFor} onClose={() => setExportFor(null)} />}
{form && <CustomerForm customer={form.customer} onClose={() => setForm(null)} onSaved={reload} />} {form && <CustomerForm customer={form.customer} onClose={() => setForm(null)} onSaved={reload} />}
{remove && ( {remove && (
<ConfirmDialog <ConfirmDialog
@@ -223,10 +228,11 @@ const CustomerCard: React.FC<{
expanded: boolean; expanded: boolean;
onToggle: () => void; onToggle: () => void;
onEdit: () => void; onEdit: () => void;
onExport: () => void;
onDelete: () => void; onDelete: () => void;
onReport: () => void; onReport: () => void;
onProject: (id: number) => 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 color = colorFor(c.id, c.color);
const total = c.earned + c.open; const total = c.earned + c.open;
const percent = total ? (c.earned / total) * 100 : 0; const percent = total ? (c.earned / total) * 100 : 0;
@@ -248,6 +254,7 @@ const CustomerCard: React.FC<{
</p> </p>
</div> </div>
<div className="flex shrink-0"> <div className="flex shrink-0">
<IconButton icon="download" label="Kundennachweis exportieren" onClick={onExport} />
<IconButton icon="chart" label="Kundenreport" onClick={onReport} /> <IconButton icon="chart" label="Kundenreport" onClick={onReport} />
<IconButton icon="edit" label="Bearbeiten" onClick={onEdit} /> <IconButton icon="edit" label="Bearbeiten" onClick={onEdit} />
{c.projects.length === 0 && <IconButton icon="trash" tone="danger" label="Löschen" onClick={onDelete} />} {c.projects.length === 0 && <IconButton icon="trash" tone="danger" label="Löschen" onClick={onDelete} />}
@@ -355,9 +362,10 @@ type CustomerSortKey = 'name' | 'city' | 'projects' | 'used_minutes' | 'earned'
const CustomerTable: React.FC<{ const CustomerTable: React.FC<{
customers: CustomerOverview[]; customers: CustomerOverview[];
onEdit: (customer: CustomerOverview) => void; onEdit: (customer: CustomerOverview) => void;
onExport: (customer: CustomerOverview) => void;
onDelete: (customer: CustomerOverview) => void; onDelete: (customer: CustomerOverview) => void;
onReport: (id: number) => 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 [sort, setSort] = useState<{ key: CustomerSortKey; dir: 1 | -1 }>({ key: 'earned', dir: -1 });
const sorted = useMemo(() => { const sorted = useMemo(() => {
@@ -448,6 +456,7 @@ const CustomerTable: React.FC<{
</td> </td>
<td className="py-2.5 pl-3 pr-4"> <td className="py-2.5 pl-3 pr-4">
<div className="flex justify-end"> <div className="flex justify-end">
<IconButton icon="download" label="Kundennachweis exportieren" onClick={() => onExport(c)} />
<IconButton icon="chart" label="Kundenreport" onClick={() => onReport(c.id)} /> <IconButton icon="chart" label="Kundenreport" onClick={() => onReport(c.id)} />
<IconButton icon="edit" label="Bearbeiten" onClick={() => onEdit(c)} /> <IconButton icon="edit" label="Bearbeiten" onClick={() => onEdit(c)} />
{c.projects.length === 0 && <IconButton icon="trash" tone="danger" label="Löschen" onClick={() => onDelete(c)} />} {c.projects.length === 0 && <IconButton icon="trash" tone="danger" label="Löschen" onClick={() => onDelete(c)} />}