feat(ansichten): Listenansicht fuer Buchungen, Report und Kunden

Neue Komponente EntryList zeigt einzelne Buchungen als sortierbare Tabelle
mit Datum, Kunde, Projekt, Task und der Zeit getrennt nach billable und
non-billable, inklusive Summen.

- Buchung: Umschalter Kalender/Liste neben Woche/Monat/Jahr, Auswahl wird
  im Browser gemerkt. Klick auf eine Zeile oeffnet die Buchung.
- Reports: Klick auf einen Mitarbeiter oeffnet dessen Buchungen des
  Reportzeitraums als Liste; ein gesetzter Kundenfilter wird uebernommen.
- Kunden: Umschalter Karten/Liste mit Ort, Projekten, Stunden und Umsatz.
- GET /api/entries akzeptiert dafuer customer_id, Segmented nimmt Icons,
  Modal kennt die Groesse 2xl.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-18 10:14:48 +02:00
co-authored by Claude Opus 5
parent 8c93df11d9
commit ef7171496f
9 changed files with 424 additions and 17 deletions
+16
View File
@@ -53,6 +53,19 @@ Serverseitig erzeugt, alle mit Kopf/Fuß aus *Einstellungen → Export* (Logo, A
- **Stundennachweis je Monat** (PDF, Excel mit Summenformeln, CSV mit UTF-8-BOM/Semikolon): eigener Nachweis, eines Teammitglieds oder aller; filterbar nach Kunde/Projekt; optional mit Sätzen und Beträgen für die Rechnung. - **Stundennachweis je Monat** (PDF, Excel mit Summenformeln, CSV mit UTF-8-BOM/Semikolon): eigener Nachweis, eines Teammitglieds oder aller; filterbar nach Kunde/Projekt; optional mit Sätzen und Beträgen für die Rechnung.
- **Zeitreport** nach Kunden/Projekten/Tasks/Mitarbeitern oder als Detailliste. - **Zeitreport** nach Kunden/Projekten/Tasks/Mitarbeitern oder als Detailliste.
## Listenansichten
`src/components/booking/EntryList.tsx` zeigt einzelne Buchungen als sortierbare Tabelle: Datum, optional Mitarbeiter,
Kunde, Projekt (mit Code), Task (mit Notiz) sowie die Zeit getrennt nach **Billable** und **Non-billable**, unten die Summen.
Dieselbe Komponente wird an drei Stellen benutzt:
- **Buchung**: Umschalter *Kalender / Liste* neben *Woche / Monat / Jahr*; die Liste zeigt den jeweils gewählten Zeitraum.
Ein Klick auf eine Zeile öffnet die Buchung zum Bearbeiten (nicht in der fremden Teamansicht). Die Wahl merkt sich der Browser.
- **Reports → Zeitreport → Mitarbeiter**: Klick auf eine Zeile öffnet die Buchungen dieses Mitarbeiters im Reportzeitraum;
ein gesetzter Kundenfilter wird übernommen (`GET /api/entries?...&customer_id=`).
- **Kunden**: Umschalter *Karten / Liste* mit Ort, Projektanzahl, Stunden, Umsatz erbracht/offen und Team.
## SSO / Active Directory ## SSO / Active Directory
**Einrichtung** (nur Administrator, *Einstellungen → SSO / Entra ID*): Microsoft Entra ID per OpenID Connect (Authorization Code, Client Secret AES-verschlüsselt in der DB). App-Registrierung mit Redirect-URI `https://pulse.perinco.de/api/auth/sso/callback`, delegiert `openid profile email`, Anwendung `User.Read.All` mit Administratorzustimmung. **Einrichtung** (nur Administrator, *Einstellungen → SSO / Entra ID*): Microsoft Entra ID per OpenID Connect (Authorization Code, Client Secret AES-verschlüsselt in der DB). App-Registrierung mit Redirect-URI `https://pulse.perinco.de/api/auth/sso/callback`, delegiert `openid profile email`, Anwendung `User.Read.All` mit Administratorzustimmung.
@@ -120,6 +133,9 @@ Aus Auftrag und Call-Mitschrift.
| Mitarbeiter-Übersicht auf-/absteigend, letzte Tage/Woche/Monat/Quartal/Jahr, Filter billable/non-billable | ✅ animierte Umsortierung | Dashboard | | Mitarbeiter-Übersicht auf-/absteigend, letzte Tage/Woche/Monat/Quartal/Jahr, Filter billable/non-billable | ✅ animierte Umsortierung | Dashboard |
| Vorgesetzte sehen nur ihr Team | ✅ rekursiv, auch im Backend | Dashboard, Reports, Buchung (Teamansicht, nur lesen) | | Vorgesetzte sehen nur ihr Team | ✅ rekursiv, auch im Backend | Dashboard, Reports, Buchung (Teamansicht, nur lesen) |
| Buchung: Wochenansicht Standard, umschaltbar Monat/Jahr | ✅ | Buchung | | Buchung: Wochenansicht Standard, umschaltbar Monat/Jahr | ✅ | Buchung |
| Buchung: zusätzliche Listenansicht (Kunde, Projekt, Task, Billable/Non-billable Zeit) für Woche/Monat/Jahr | ✅ | Buchung → Kalender/Liste |
| Klick auf einen Mitarbeiter im Zeitreport zeigt dessen Buchungen des Zeitraums als Liste | ✅ | Reports → Zeitreport → Mitarbeiter |
| Kunden: zusätzliche Listenansicht | ✅ | Kunden → Karten/Liste |
| Grünes Plus → Projekt, Task, optionaler Freitext, Zeit rechts daneben, `0:00`, `1:23` und `1,5` | ✅ | Buchungsdialog | | Grünes Plus → Projekt, Task, optionaler Freitext, Zeit rechts daneben, `0:00`, `1:23` und `1,5` | ✅ | Buchungsdialog |
| Nur zugewiesene Projekte, Task optional | ✅ | Buchungsdialog | | Nur zugewiesene Projekte, Task optional | ✅ | Buchungsdialog |
| Zeiten bearbeiten und löschen | ✅ | Klick auf Buchung | | Zeiten bearbeiten und löschen | ✅ | Klick auf Buchung |
+4 -1
View File
@@ -48,7 +48,10 @@ router.get('/', async (req, res, next) => {
userIds = [wanted]; userIds = [wanted];
} }
const settings = await getSettings(); const settings = await getSettings();
const entries = await loadEntries(settings, { from, to, userIds }); // Optionaler Kundenfilter, damit die Listenansicht im Report den dort
// gesetzten Filter uebernehmen kann.
const customerId = req.query.customer_id ? Number(req.query.customer_id) : undefined;
const entries = await loadEntries(settings, { from, to, userIds, customerId });
res.json(entries.map((e) => publicEntry(e, hasRole(req.user, 'lead')))); res.json(entries.map((e) => publicEntry(e, hasRole(req.user, 'lead'))));
} catch (err) { } catch (err) {
next(err); next(err);
+27 -5
View File
@@ -7,6 +7,7 @@ import { ExportDialog } from './ExportDialog';
import { WeekView } from './WeekView'; import { WeekView } from './WeekView';
import { MonthView } from './MonthView'; import { MonthView } from './MonthView';
import { YearView } from './YearView'; import { YearView } from './YearView';
import { EntryList } from './EntryList';
import { useAuth } from '../../lib/auth'; import { useAuth } from '../../lib/auth';
import { entriesApi, projectsApi, usersApi } from '../../lib/api'; import { entriesApi, projectsApi, usersApi } from '../../lib/api';
import { useIsMobile, useLoader, usePersistentState, useCountUp } from '../../lib/hooks'; import { useIsMobile, useLoader, usePersistentState, useCountUp } from '../../lib/hooks';
@@ -27,6 +28,8 @@ import { visibleTeam } from '../team/hierarchy';
import { atLeast, type DirectoryUser, type Project, type TimeEntry } from '../../types'; import { atLeast, type DirectoryUser, type Project, type TimeEntry } from '../../types';
export type BookingMode = 'week' | 'month' | 'year'; export type BookingMode = 'week' | 'month' | 'year';
/** Kalender = Wochen-/Monats-/Jahresraster, Liste = die einzelnen Buchungen als Tabelle. */
export type BookingLayout = 'calendar' | 'list';
function rangeFor(mode: BookingMode, anchor: string) { function rangeFor(mode: BookingMode, anchor: string) {
if (mode === 'week') { if (mode === 'week') {
@@ -48,6 +51,7 @@ export const Booking: React.FC = () => {
const { user, settings } = useAuth(); const { user, settings } = useAuth();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const [mode, setMode] = usePersistentState<BookingMode>('pulse_booking_mode', 'week'); const [mode, setMode] = usePersistentState<BookingMode>('pulse_booking_mode', 'week');
const [layout, setLayout] = usePersistentState<BookingLayout>('pulse_booking_layout', 'calendar');
const [anchor, setAnchor] = useState(todayIso()); const [anchor, setAnchor] = useState(todayIso());
const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null } | null>(null); const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null } | null>(null);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
@@ -141,7 +145,7 @@ export const Booking: React.FC = () => {
</div> </div>
<h2 className="tabular min-w-0 flex-1 truncate text-base text-ink-900 sm:flex-none sm:text-lg">{label}</h2> <h2 className="tabular min-w-0 flex-1 truncate text-base text-ink-900 sm:flex-none sm:text-lg">{label}</h2>
<div className="order-last flex w-full items-center gap-2 sm:order-none sm:ml-auto sm:w-auto"> <div className="order-last flex w-full flex-wrap items-center gap-2 sm:order-none sm:ml-auto sm:w-auto sm:flex-nowrap">
<Segmented <Segmented
value={mode} value={mode}
onChange={setMode} onChange={setMode}
@@ -152,6 +156,15 @@ export const Booking: React.FC = () => {
{ value: 'year', label: 'Jahr' }, { value: 'year', label: 'Jahr' },
]} ]}
/> />
<Segmented
value={layout}
onChange={setLayout}
className="flex-1 sm:flex-none"
options={[
{ value: 'calendar', label: 'Kalender', icon: 'calendar', title: 'Kalenderansicht' },
{ value: 'list', label: 'Liste', icon: 'list', title: 'Alle Buchungen als Liste' },
]}
/>
{isLead && team.length > 1 && ( {isLead && team.length > 1 && (
<select className={`${inputClass} w-auto max-w-[180px] py-2 text-xs font-bold`} value={viewUserId} onChange={(e) => setViewUserId(Number(e.target.value))} title="Zeiten ansehen von"> <select className={`${inputClass} w-auto max-w-[180px] py-2 text-xs font-bold`} value={viewUserId} onChange={(e) => setViewUserId(Number(e.target.value))} title="Zeiten ansehen von">
{team.map((t) => ( {team.map((t) => (
@@ -207,11 +220,20 @@ export const Booking: React.FC = () => {
{entries.loading && !entries.data ? ( {entries.loading && !entries.data ? (
<Spinner /> <Spinner />
) : ( ) : (
<div key={`${mode}-${range.from}`} className="animate-fade"> <div key={`${layout}-${mode}-${range.from}`} className="animate-fade">
{mode === 'week' && ( {layout === 'list' && (
<Card className="overflow-hidden">
<EntryList
entries={list}
onEdit={readOnly ? undefined : (entry) => setModal({ date: entry.entry_date, entry })}
emptyText={`Keine Buchungen ${mode === 'week' ? 'in dieser Woche' : mode === 'month' ? 'in diesem Monat' : 'in diesem Jahr'}.`}
/>
</Card>
)}
{layout === 'calendar' && mode === 'week' && (
<WeekView from={range.from} entries={list} readOnly={readOnly} dailyTarget={(weekly / 5) * 60} onAdd={openNew} onEdit={(entry) => !readOnly && setModal({ date: entry.entry_date, entry })} /> <WeekView from={range.from} entries={list} readOnly={readOnly} dailyTarget={(weekly / 5) * 60} onAdd={openNew} onEdit={(entry) => !readOnly && setModal({ date: entry.entry_date, entry })} />
)} )}
{mode === 'month' && ( {layout === 'calendar' && mode === 'month' && (
<MonthView <MonthView
month={range.from} month={range.from}
entries={list} entries={list}
@@ -224,7 +246,7 @@ export const Booking: React.FC = () => {
readOnly={readOnly} readOnly={readOnly}
/> />
)} )}
{mode === 'year' && ( {layout === 'calendar' && mode === 'year' && (
<YearView <YearView
year={anchor.slice(0, 4)} year={anchor.slice(0, 4)}
entries={list} entries={list}
+156
View File
@@ -0,0 +1,156 @@
import React, { useMemo, useState } from 'react';
import { Avatar } from '../ui/controls';
import { Icon } from '../ui/Icon';
import { colorFor, formatDate, formatHours, weekdayIndex, WEEKDAYS_SHORT } from '../../lib/format';
import type { TimeEntry } from '../../types';
type SortKey = 'entry_date' | 'user_name' | 'customer_name' | 'project_display' | 'task_name' | 'billable' | 'non_billable';
const minutesOf = (entry: TimeEntry, billable: boolean) => (entry.billable === billable ? entry.minutes : 0);
/**
* Listenansicht der einzelnen Buchungen: Kunde, Projekt, Task und die Zeit in
* zwei Spalten - billable und non-billable. Wird in der Buchung und im
* Zeitreport (Klick auf einen Mitarbeiter) verwendet.
*/
export const EntryList: React.FC<{
entries: TimeEntry[];
showUser?: boolean;
onEdit?: (entry: TimeEntry) => void;
emptyText?: string;
}> = ({ entries, showUser = false, onEdit, emptyText = 'Keine Buchungen im Zeitraum.' }) => {
const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: 'entry_date', dir: 1 });
const sorted = useMemo(() => {
const value = (e: TimeEntry): string | number => {
switch (sort.key) {
case 'entry_date':
return e.entry_date;
case 'user_name':
return e.user_name ?? '';
case 'customer_name':
return e.customer_name ?? '';
case 'project_display':
return e.project_display ?? e.project_name ?? '';
case 'task_name':
return e.task_name ?? '';
case 'billable':
return minutesOf(e, true);
default:
return minutesOf(e, false);
}
};
return [...entries].sort((a, b) => {
const av = value(a);
const bv = value(b);
const cmp = typeof av === 'string' ? av.localeCompare(bv as string, 'de') : av - (bv as number);
// Gleichstand immer nach Datum, damit die Reihenfolge stabil bleibt.
return (cmp || a.entry_date.localeCompare(b.entry_date) || a.id - b.id) * sort.dir;
});
}, [entries, sort]);
const sum = entries.reduce(
(s, e) => ({ billable: s.billable + minutesOf(e, true), non: s.non + minutesOf(e, false) }),
{ billable: 0, non: 0 }
);
const head = (key: SortKey, text: string, align: 'left' | 'right' = 'left', className = '') => (
<th className={`px-3 py-3 ${align === 'right' ? 'text-right' : 'text-left'} ${className}`}>
<button
onClick={() => setSort({ key, dir: sort.key === key ? ((sort.dir * -1) as 1 | -1) : key === 'billable' || key === 'non_billable' ? -1 : 1 })}
className={`inline-flex items-center gap-1 font-extrabold uppercase tracking-wider hover:text-ink-700 ${sort.key === key ? 'text-ink-700' : ''}`}
>
{text}
{sort.key === key && <Icon name={sort.dir === 1 ? 'chevronUp' : 'chevronDown'} className="h-3 w-3" strokeWidth={2.5} />}
</button>
</th>
);
if (entries.length === 0) return <p className="px-4 py-12 text-center text-sm text-ink-400">{emptyText}</p>;
return (
<div className="scrollbar-slim overflow-x-auto">
<table className="w-full min-w-[820px] text-sm">
<thead>
<tr className="border-b border-ink-200 text-[11px] text-ink-400">
{head('entry_date', 'Datum', 'left', 'pl-4')}
{showUser && head('user_name', 'Mitarbeiter')}
{head('customer_name', 'Kunde')}
{head('project_display', 'Projekt')}
{head('task_name', 'Task')}
{head('billable', 'Billable Zeit', 'right')}
{head('non_billable', 'Non-billable Zeit', 'right', 'pr-4')}
</tr>
</thead>
<tbody>
{sorted.map((e, i) => {
const bill = minutesOf(e, true);
const non = minutesOf(e, false);
// Datum nur an der ersten Zeile des Tages, solange nach Datum sortiert wird.
const grouped = sort.key === 'entry_date';
const first = !grouped || i === 0 || sorted[i - 1].entry_date !== e.entry_date;
return (
<tr
key={e.id}
onClick={() => onEdit?.(e)}
className={`animate-rise border-b border-ink-100 last:border-0 ${onEdit ? 'cursor-pointer hover:bg-brand-50/50' : 'hover:bg-ink-50'} ${
first && i > 0 ? 'border-t border-t-ink-200' : ''
}`}
style={{ '--i': Math.min(i, 15) } as React.CSSProperties}
>
<td className="tabular py-2.5 pl-4 pr-3 align-top">
{first ? (
<span className="whitespace-nowrap font-bold text-ink-800">
<span className="text-ink-400">{WEEKDAYS_SHORT[weekdayIndex(e.entry_date)]} </span>
{formatDate(e.entry_date)}
</span>
) : (
<span className="text-ink-300">·</span>
)}
</td>
{showUser && (
<td className="px-3 py-2.5 align-top">
<span className="flex items-center gap-2">
<Avatar id={e.user_id} name={e.user_name} size={22} />
<span className="truncate font-semibold text-ink-700">{e.user_name}</span>
</span>
</td>
)}
<td className="px-3 py-2.5 align-top">
<span className="flex min-w-0 items-center gap-2">
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: colorFor(e.customer_id, e.customer_color) }} />
<span className="truncate font-semibold text-ink-700">{e.customer_name}</span>
</span>
</td>
<td className="px-3 py-2.5 align-top">
<span className="block truncate font-bold text-ink-900">{e.project_display}</span>
<span className="tabular block truncate text-[11px] text-ink-400">{e.project_code}</span>
</td>
<td className="px-3 py-2.5 align-top">
<span className="block truncate text-ink-700">{e.task_name ?? '–'}</span>
{e.note && <span className="block truncate text-[11px] text-ink-400">{e.note}</span>}
</td>
<td className={`tabular px-3 py-2.5 text-right align-top font-extrabold ${bill ? 'text-ink-900' : 'text-ink-300'}`}>
{bill ? formatHours(bill) : '–'}
</td>
<td className={`tabular py-2.5 pl-3 pr-4 text-right align-top font-extrabold ${non ? 'text-ink-700' : 'text-ink-300'}`}>
{non ? formatHours(non) : '–'}
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-ink-200 bg-ink-50/60 font-extrabold text-ink-900">
<td className="py-3 pl-4 pr-3" colSpan={showUser ? 4 : 3}>
Summe · {entries.length} {entries.length === 1 ? 'Buchung' : 'Buchungen'}
</td>
<td className="tabular px-3 py-3 text-right">{formatHours(sum.billable + sum.non)} h gesamt</td>
<td className="tabular px-3 py-3 text-right">{formatHours(sum.billable)}</td>
<td className="tabular py-3 pl-3 pr-4 text-right">{formatHours(sum.non)}</td>
</tr>
</tfoot>
</table>
</div>
);
};
+148 -1
View File
@@ -5,7 +5,7 @@ import { Icon } from '../ui/Icon';
import { CountUp, SegmentRing, Sparkline } from '../ui/charts'; import { CountUp, SegmentRing, Sparkline } from '../ui/charts';
import { CustomerForm } from './CustomerForm'; import { CustomerForm } from './CustomerForm';
import { customersApi, type CustomerOverview } from '../../lib/api'; import { customersApi, type CustomerOverview } from '../../lib/api';
import { useGrow, useLoader } from '../../lib/hooks'; 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';
@@ -21,6 +21,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 [layout, setLayout] = usePersistentState<'cards' | 'list'>('pulse_customers_layout', 'cards');
const customers = useMemo(() => { const customers = useMemo(() => {
const q = search.trim().toLowerCase(); const q = search.trim().toLowerCase();
@@ -74,6 +75,15 @@ export const Customers: React.FC = () => {
<input className={`${inputClass} pl-9`} placeholder="Kunde suchen…" value={search} onChange={(e) => setSearch(e.target.value)} /> <input className={`${inputClass} pl-9`} placeholder="Kunde suchen…" value={search} onChange={(e) => setSearch(e.target.value)} />
</div> </div>
<Segmented size="sm" value={status} onChange={setStatus} options={[{ value: 'active', label: 'Aktiv' }, { value: 'all', label: 'Alle' }]} /> <Segmented size="sm" value={status} onChange={setStatus} options={[{ value: 'active', label: 'Aktiv' }, { value: 'all', label: 'Alle' }]} />
<Segmented
size="sm"
value={layout}
onChange={setLayout}
options={[
{ value: 'cards', label: 'Karten', icon: 'grid', title: 'Kachelansicht' },
{ value: 'list', label: 'Liste', icon: 'list', title: 'Listenansicht' },
]}
/>
<Button icon="plus" className="ml-auto" onClick={() => setForm({ customer: null })}> <Button icon="plus" className="ml-auto" onClick={() => setForm({ customer: null })}>
Neuer Kunde Neuer Kunde
</Button> </Button>
@@ -81,6 +91,15 @@ export const Customers: React.FC = () => {
{customers.length === 0 ? ( {customers.length === 0 ? (
<EmptyState icon="building" title="Keine Kunden gefunden" action={<Button icon="plus" onClick={() => setForm({ customer: null })}>Neuer Kunde</Button>} /> <EmptyState icon="building" title="Keine Kunden gefunden" action={<Button icon="plus" onClick={() => setForm({ customer: null })}>Neuer Kunde</Button>} />
) : layout === 'list' ? (
<Card className="animate-fade overflow-hidden">
<CustomerTable
customers={customers}
onEdit={(c) => setForm({ customer: c })}
onDelete={setRemove}
onReport={(id) => navigate(`reports/kunde/${id}`)}
/>
</Card>
) : ( ) : (
<div className="grid gap-3 md:grid-cols-2 2xl:grid-cols-3"> <div className="grid gap-3 md:grid-cols-2 2xl:grid-cols-3">
{customers.map((c, i) => ( {customers.map((c, i) => (
@@ -317,3 +336,131 @@ const MiniRing: React.FC<{ percent: number; color: string }> = ({ percent, color
</div> </div>
); );
}; };
type CustomerSortKey = 'name' | 'city' | 'projects' | 'used_minutes' | 'earned' | 'open';
/**
* Listenansicht der Kunden - dieselben Zahlen wie auf den Karten, nur als
* sortierbare Tabelle fuer den schnellen Ueberblick.
*/
const CustomerTable: React.FC<{
customers: CustomerOverview[];
onEdit: (customer: CustomerOverview) => void;
onDelete: (customer: CustomerOverview) => void;
onReport: (id: number) => void;
}> = ({ customers, onEdit, onDelete, onReport }) => {
const [sort, setSort] = useState<{ key: CustomerSortKey; dir: 1 | -1 }>({ key: 'earned', dir: -1 });
const sorted = useMemo(() => {
const value = (c: CustomerOverview): string | number => {
if (sort.key === 'name') return c.name;
if (sort.key === 'city') return c.city ?? '';
if (sort.key === 'projects') return c.projects.length;
if (sort.key === 'used_minutes') return c.used_minutes;
if (sort.key === 'open') return c.open;
return c.earned;
};
return [...customers].sort((a, b) => {
const av = value(a);
const bv = value(b);
const cmp = typeof av === 'string' ? av.localeCompare(bv as string, 'de') : av - (bv as number);
return (cmp || a.name.localeCompare(b.name, 'de')) * sort.dir;
});
}, [customers, sort]);
const sum = customers.reduce(
(s, c) => ({
minutes: s.minutes + c.used_minutes,
earned: s.earned + c.earned,
open: s.open + c.open,
projects: s.projects + c.projects.length,
}),
{ minutes: 0, earned: 0, open: 0, projects: 0 }
);
const head = (key: CustomerSortKey, text: string, align: 'left' | 'right' = 'right', className = '') => (
<th className={`px-3 py-3 ${align === 'right' ? 'text-right' : 'text-left'} ${className}`}>
<button
onClick={() => setSort({ key, dir: sort.key === key ? ((sort.dir * -1) as 1 | -1) : key === 'name' || key === 'city' ? 1 : -1 })}
className={`inline-flex items-center gap-1 font-extrabold uppercase tracking-wider hover:text-ink-700 ${sort.key === key ? 'text-ink-700' : ''}`}
>
{text}
{sort.key === key && <Icon name={sort.dir === 1 ? 'chevronUp' : 'chevronDown'} className="h-3 w-3" strokeWidth={2.5} />}
</button>
</th>
);
return (
<div className="scrollbar-slim overflow-x-auto">
<table className="w-full min-w-[920px] text-sm">
<thead>
<tr className="border-b border-ink-200 text-[11px] text-ink-400">
{head('name', 'Kunde', 'left', 'pl-4')}
{head('city', 'Ort', 'left')}
{head('projects', 'Projekte')}
{head('used_minutes', 'Stunden')}
{head('earned', 'Erbracht')}
{head('open', 'Offen')}
<th className="px-3 py-3 text-left font-extrabold uppercase tracking-wider">Team</th>
<th className="py-3 pl-3 pr-4" />
</tr>
</thead>
<tbody>
{sorted.map((c, i) => {
const color = colorFor(c.id, c.color);
const active = c.projects.filter((p) => p.is_active).length;
return (
<tr
key={c.id}
className={`animate-rise border-b border-ink-100 last:border-0 hover:bg-ink-50 ${c.is_active ? '' : 'opacity-60'}`}
style={{ '--i': Math.min(i, 15) } as React.CSSProperties}
>
<td className="py-2.5 pl-4 pr-3">
<button onClick={() => onReport(c.id)} className="flex min-w-0 items-center gap-2.5 text-left">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg text-[11px] font-extrabold text-white" style={{ background: color }}>
{c.short_name.slice(0, 2)}
</span>
<span className="min-w-0">
<span className="block truncate font-bold text-ink-900 underline decoration-ink-200 underline-offset-4 hover:decoration-brand-400">{c.name}</span>
<span className="block truncate text-[11px] text-ink-400">{c.short_name}</span>
</span>
</button>
</td>
<td className="px-3 py-2.5 text-ink-600">{c.city ?? '–'}</td>
<td className="tabular px-3 py-2.5 text-right text-ink-700">
{c.projects.length}
{c.projects.length > 0 && <span className="text-ink-400"> · {active} aktiv</span>}
</td>
<td className="tabular px-3 py-2.5 text-right font-extrabold text-ink-900">{formatHours(c.used_minutes, 1)}</td>
<td className="tabular px-3 py-2.5 text-right font-extrabold text-ink-900">{formatEuro(c.earned)}</td>
<td className="tabular px-3 py-2.5 text-right text-ink-500">{formatEuro(c.open)}</td>
<td className="px-3 py-2.5">
<AvatarStack people={c.members} max={5} size={22} />
</td>
<td className="py-2.5 pl-3 pr-4">
<div className="flex justify-end">
<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)} />}
</div>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t-2 border-ink-200 bg-ink-50/60 font-extrabold text-ink-900">
<td className="py-3 pl-4 pr-3">Summe · {customers.length} Kunden</td>
<td />
<td className="tabular px-3 py-3 text-right">{sum.projects}</td>
<td className="tabular px-3 py-3 text-right">{formatHours(sum.minutes, 1)}</td>
<td className="tabular px-3 py-3 text-right">{formatEuro(sum.earned)}</td>
<td className="tabular px-3 py-3 text-right">{formatEuro(sum.open)}</td>
<td />
<td />
</tr>
</tfoot>
</table>
</div>
);
};
+63 -3
View File
@@ -1,13 +1,15 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Card, ErrorBox, IconButton, Spinner, inputClass } from '../ui/controls'; import { Avatar, Card, ErrorBox, IconButton, Spinner, inputClass } from '../ui/controls';
import { Modal } from '../ui/Modal';
import { Icon } from '../ui/Icon'; import { Icon } from '../ui/Icon';
import { CountUp, Donut, BILLABLE, NON_BILLABLE } from '../ui/charts'; import { CountUp, Donut, BILLABLE, NON_BILLABLE } from '../ui/charts';
import { ReportTable } from './ReportTable'; import { ReportTable } from './ReportTable';
import { EntryList } from '../booking/EntryList';
import { ExportMenu } from './ExportMenu'; import { ExportMenu } from './ExportMenu';
import { CustomerReport } from './CustomerReport'; import { CustomerReport } from './CustomerReport';
import { ProjectReport } from './ProjectReport'; import { ProjectReport } from './ProjectReport';
import { useAuth } from '../../lib/auth'; import { useAuth } from '../../lib/auth';
import { customersApi, qs, reportsApi, usersApi } from '../../lib/api'; import { customersApi, entriesApi, qs, reportsApi, usersApi, type GroupRow } from '../../lib/api';
import { useLoader, usePersistentState } from '../../lib/hooks'; import { useLoader, usePersistentState } from '../../lib/hooks';
import { MONTHS, addDays, addMonths, formatDate, formatEuro, formatHours, isoWeek, mondayOf, monthEnd, monthStart, todayIso } from '../../lib/format'; import { MONTHS, addDays, addMonths, formatDate, formatEuro, formatHours, isoWeek, mondayOf, monthEnd, monthStart, todayIso } from '../../lib/format';
import { useRouter } from '../../lib/router'; import { useRouter } from '../../lib/router';
@@ -75,6 +77,8 @@ const TimeReport: React.FC = () => {
const [includeFixed, setIncludeFixed] = useState(false); const [includeFixed, setIncludeFixed] = useState(false);
const [customers, setCustomers] = useState<Customer[]>([]); const [customers, setCustomers] = useState<Customer[]>([]);
const [leads, setLeads] = useState<DirectoryUser[]>([]); const [leads, setLeads] = useState<DirectoryUser[]>([]);
// Klick auf einen Mitarbeiter oeffnet seine Buchungen des Zeitraums als Liste.
const [detail, setDetail] = useState<GroupRow | null>(null);
useEffect(() => { useEffect(() => {
customersApi.list().then(setCustomers).catch(() => undefined); customersApi.list().then(setCustomers).catch(() => undefined);
@@ -247,14 +251,70 @@ const TimeReport: React.FC = () => {
? (row) => navigate(`reports/kunde/${row.customer_id}`) ? (row) => navigate(`reports/kunde/${row.customer_id}`)
: tab === 'projects' : tab === 'projects'
? (row) => navigate(`reports/projekt/${row.project_id}`) ? (row) => navigate(`reports/projekt/${row.project_id}`)
: undefined : tab === 'users'
? (row) => setDetail(row)
: undefined
} }
/> />
</Card> </Card>
</> </>
)} )}
{detail && (
<UserEntriesDialog row={detail} range={range} customerId={customerId || null} onClose={() => setDetail(null)} />
)}
</div> </div>
); );
}; };
/** Die einzelnen Buchungen eines Mitarbeiters im gewaehlten Reportzeitraum. */
const UserEntriesDialog: React.FC<{
row: GroupRow;
range: { from: string; to: string; label: string };
customerId: number | null;
onClose: () => void;
}> = ({ row, range, customerId, onClose }) => {
const entries = useLoader(
() => entriesApi.list(range.from, range.to, row.user_id, customerId),
[range.from, range.to, row.user_id, customerId]
);
return (
<Modal
size="2xl"
onClose={onClose}
title={row.name}
subtitle={
<span className="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>{range.label}</span>
<span className="text-ink-300">·</span>
<span>
{formatHours(row.minutes)} h gesamt, davon {formatHours(row.billable_minutes)} h billable
</span>
{row.utilization != null && (
<>
<span className="text-ink-300">·</span>
<span>{row.utilization} % Auslastung</span>
</>
)}
</span>
}
>
<div className="flex items-center gap-3 pb-4">
<Avatar id={row.user_id} name={row.name} color={row.color} size={40} external={row.is_external} />
<p className="text-sm font-semibold text-ink-500">
{entries.data ? `${entries.data.length} Buchungen` : 'Lade Buchungen…'}
</p>
</div>
{entries.error && <ErrorBox message={entries.error} onRetry={entries.reload} />}
{!entries.data ? (
<Spinner />
) : (
<div className="-mx-5 overflow-hidden border-t border-ink-100 sm:-mx-6">
<EntryList entries={entries.data} emptyText="Keine Buchungen in diesem Zeitraum." />
</div>
)}
</Modal>
);
};
export { periodRange }; export { periodRange };
+2 -2
View File
@@ -7,10 +7,10 @@ interface ModalProps {
onClose: () => void; onClose: () => void;
children: React.ReactNode; children: React.ReactNode;
footer?: React.ReactNode; footer?: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl'; size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
} }
const SIZES = { sm: 'sm:max-w-sm', md: 'sm:max-w-lg', lg: 'sm:max-w-2xl', xl: 'sm:max-w-4xl' }; const SIZES = { sm: 'sm:max-w-sm', md: 'sm:max-w-lg', lg: 'sm:max-w-2xl', xl: 'sm:max-w-4xl', '2xl': 'sm:max-w-6xl' };
/** Offene Dialoge in Oeffnungsreihenfolge - Escape schliesst nur den obersten. */ /** Offene Dialoge in Oeffnungsreihenfolge - Escape schliesst nur den obersten. */
const openModals: symbol[] = []; const openModals: symbol[] = [];
+6 -4
View File
@@ -80,7 +80,7 @@ export function Segmented<T extends string>({
className = '', className = '',
}: { }: {
value: T; value: T;
options: { value: T; label: string }[]; options: { value: T; label: string; icon?: IconName; title?: string }[];
onChange: (value: T) => void; onChange: (value: T) => void;
size?: 'sm' | 'md'; size?: 'sm' | 'md';
className?: string; className?: string;
@@ -99,10 +99,12 @@ export function Segmented<T extends string>({
type="button" type="button"
onClick={() => onChange(option.value)} onClick={() => onChange(option.value)}
aria-pressed={option.value === value} aria-pressed={option.value === value}
className={`relative z-10 whitespace-nowrap rounded-lg font-bold transition ${size === 'sm' ? 'px-2.5 py-1 text-[11px]' : 'px-3.5 py-1.5 text-xs'} ${ title={option.title}
option.value === value ? 'text-brand-700' : 'text-ink-500 hover:text-ink-800' className={`relative z-10 inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-lg font-bold transition ${
}`} size === 'sm' ? 'px-2.5 py-1 text-[11px]' : 'px-3.5 py-1.5 text-xs'
} ${option.value === value ? 'text-brand-700' : 'text-ink-500 hover:text-ink-800'}`}
> >
{option.icon && <Icon name={option.icon} className={size === 'sm' ? 'h-3.5 w-3.5' : 'h-4 w-4'} strokeWidth={2.2} />}
{option.label} {option.label}
</button> </button>
))} ))}
+2 -1
View File
@@ -239,7 +239,8 @@ export const projectsApi = {
}; };
export const entriesApi = { export const entriesApi = {
list: (from: string, to: string, userId?: number) => get<TimeEntry[]>(`/entries${qs({ from, to, user_id: userId })}`), list: (from: string, to: string, userId?: number, customerId?: number | null) =>
get<TimeEntry[]>(`/entries${qs({ from, to, user_id: userId, customer_id: customerId || undefined })}`),
create: (data: { project_id: number; task_id: number | null; entry_date: string; minutes: number; note: string }) => create: (data: { project_id: number; task_id: number | null; entry_date: string; minutes: number; note: string }) =>
send<{ entry: TimeEntry; warning: string | null }>('POST', '/entries', data), send<{ entry: TimeEntry; warning: string | null }>('POST', '/entries', data),
update: (id: number, data: Partial<{ project_id: number; task_id: number | null; entry_date: string; minutes: number; note: string }>) => update: (id: number, data: Partial<{ project_id: number; task_id: number | null; entry_date: string; minutes: number; note: string }>) =>