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)} />}