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:
co-authored by
Claude Opus 5
parent
dd42fba1cb
commit
8a8e433397
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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<CustomerOverview | 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 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}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -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}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{exportFor && <CustomerExportDialog customer={exportFor} onClose={() => setExportFor(null)} />}
|
||||
{form && <CustomerForm customer={form.customer} onClose={() => setForm(null)} onSaved={reload} />}
|
||||
{remove && (
|
||||
<ConfirmDialog
|
||||
@@ -223,10 +228,11 @@ const CustomerCard: React.FC<{
|
||||
expanded: boolean;
|
||||
onToggle: () => 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<{
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0">
|
||||
<IconButton icon="download" label="Kundennachweis exportieren" onClick={onExport} />
|
||||
<IconButton icon="chart" label="Kundenreport" onClick={onReport} />
|
||||
<IconButton icon="edit" label="Bearbeiten" onClick={onEdit} />
|
||||
{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<{
|
||||
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<{
|
||||
</td>
|
||||
<td className="py-2.5 pl-3 pr-4">
|
||||
<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="edit" label="Bearbeiten" onClick={() => onEdit(c)} />
|
||||
{c.projects.length === 0 && <IconButton icon="trash" tone="danger" label="Löschen" onClick={() => onDelete(c)} />}
|
||||
|
||||
Reference in New Issue
Block a user