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:
co-authored by
Claude Opus 5
parent
8c93df11d9
commit
ef7171496f
@@ -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.
|
||||
- **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
|
||||
|
||||
**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 |
|
||||
| Vorgesetzte sehen nur ihr Team | ✅ rekursiv, auch im Backend | Dashboard, Reports, Buchung (Teamansicht, nur lesen) |
|
||||
| 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 |
|
||||
| Nur zugewiesene Projekte, Task optional | ✅ | Buchungsdialog |
|
||||
| Zeiten bearbeiten und löschen | ✅ | Klick auf Buchung |
|
||||
|
||||
@@ -48,7 +48,10 @@ router.get('/', async (req, res, next) => {
|
||||
userIds = [wanted];
|
||||
}
|
||||
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'))));
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ExportDialog } from './ExportDialog';
|
||||
import { WeekView } from './WeekView';
|
||||
import { MonthView } from './MonthView';
|
||||
import { YearView } from './YearView';
|
||||
import { EntryList } from './EntryList';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { entriesApi, projectsApi, usersApi } from '../../lib/api';
|
||||
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';
|
||||
|
||||
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) {
|
||||
if (mode === 'week') {
|
||||
@@ -48,6 +51,7 @@ export const Booking: React.FC = () => {
|
||||
const { user, settings } = useAuth();
|
||||
const isMobile = useIsMobile();
|
||||
const [mode, setMode] = usePersistentState<BookingMode>('pulse_booking_mode', 'week');
|
||||
const [layout, setLayout] = usePersistentState<BookingLayout>('pulse_booking_layout', 'calendar');
|
||||
const [anchor, setAnchor] = useState(todayIso());
|
||||
const [modal, setModal] = useState<{ date: string; entry?: TimeEntry | null } | null>(null);
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
@@ -141,7 +145,7 @@ export const Booking: React.FC = () => {
|
||||
</div>
|
||||
<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
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
@@ -152,6 +156,15 @@ export const Booking: React.FC = () => {
|
||||
{ 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 && (
|
||||
<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) => (
|
||||
@@ -207,11 +220,20 @@ export const Booking: React.FC = () => {
|
||||
{entries.loading && !entries.data ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<div key={`${mode}-${range.from}`} className="animate-fade">
|
||||
{mode === 'week' && (
|
||||
<div key={`${layout}-${mode}-${range.from}`} className="animate-fade">
|
||||
{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 })} />
|
||||
)}
|
||||
{mode === 'month' && (
|
||||
{layout === 'calendar' && mode === 'month' && (
|
||||
<MonthView
|
||||
month={range.from}
|
||||
entries={list}
|
||||
@@ -224,7 +246,7 @@ export const Booking: React.FC = () => {
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
)}
|
||||
{mode === 'year' && (
|
||||
{layout === 'calendar' && mode === 'year' && (
|
||||
<YearView
|
||||
year={anchor.slice(0, 4)}
|
||||
entries={list}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { Icon } from '../ui/Icon';
|
||||
import { CountUp, SegmentRing, Sparkline } from '../ui/charts';
|
||||
import { CustomerForm } from './CustomerForm';
|
||||
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 { useRouter } from '../../lib/router';
|
||||
import { useToast } from '../Toast';
|
||||
@@ -21,6 +21,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 [layout, setLayout] = usePersistentState<'cards' | 'list'>('pulse_customers_layout', 'cards');
|
||||
|
||||
const customers = useMemo(() => {
|
||||
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)} />
|
||||
</div>
|
||||
<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 })}>
|
||||
Neuer Kunde
|
||||
</Button>
|
||||
@@ -81,6 +91,15 @@ export const Customers: React.FC = () => {
|
||||
|
||||
{customers.length === 0 ? (
|
||||
<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">
|
||||
{customers.map((c, i) => (
|
||||
@@ -317,3 +336,131 @@ const MiniRing: React.FC<{ percent: number; color: string }> = ({ percent, color
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
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 { CountUp, Donut, BILLABLE, NON_BILLABLE } from '../ui/charts';
|
||||
import { ReportTable } from './ReportTable';
|
||||
import { EntryList } from '../booking/EntryList';
|
||||
import { ExportMenu } from './ExportMenu';
|
||||
import { CustomerReport } from './CustomerReport';
|
||||
import { ProjectReport } from './ProjectReport';
|
||||
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 { MONTHS, addDays, addMonths, formatDate, formatEuro, formatHours, isoWeek, mondayOf, monthEnd, monthStart, todayIso } from '../../lib/format';
|
||||
import { useRouter } from '../../lib/router';
|
||||
@@ -75,6 +77,8 @@ const TimeReport: React.FC = () => {
|
||||
const [includeFixed, setIncludeFixed] = useState(false);
|
||||
const [customers, setCustomers] = useState<Customer[]>([]);
|
||||
const [leads, setLeads] = useState<DirectoryUser[]>([]);
|
||||
// Klick auf einen Mitarbeiter oeffnet seine Buchungen des Zeitraums als Liste.
|
||||
const [detail, setDetail] = useState<GroupRow | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
customersApi.list().then(setCustomers).catch(() => undefined);
|
||||
@@ -247,14 +251,70 @@ const TimeReport: React.FC = () => {
|
||||
? (row) => navigate(`reports/kunde/${row.customer_id}`)
|
||||
: tab === 'projects'
|
||||
? (row) => navigate(`reports/projekt/${row.project_id}`)
|
||||
: tab === 'users'
|
||||
? (row) => setDetail(row)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{detail && (
|
||||
<UserEntriesDialog row={detail} range={range} customerId={customerId || null} onClose={() => setDetail(null)} />
|
||||
)}
|
||||
</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 };
|
||||
|
||||
@@ -7,10 +7,10 @@ interface ModalProps {
|
||||
onClose: () => void;
|
||||
children: 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. */
|
||||
const openModals: symbol[] = [];
|
||||
|
||||
@@ -80,7 +80,7 @@ export function Segmented<T extends string>({
|
||||
className = '',
|
||||
}: {
|
||||
value: T;
|
||||
options: { value: T; label: string }[];
|
||||
options: { value: T; label: string; icon?: IconName; title?: string }[];
|
||||
onChange: (value: T) => void;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
@@ -99,10 +99,12 @@ export function Segmented<T extends string>({
|
||||
type="button"
|
||||
onClick={() => onChange(option.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'} ${
|
||||
option.value === value ? 'text-brand-700' : 'text-ink-500 hover:text-ink-800'
|
||||
}`}
|
||||
title={option.title}
|
||||
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}
|
||||
</button>
|
||||
))}
|
||||
|
||||
+2
-1
@@ -239,7 +239,8 @@ export const projectsApi = {
|
||||
};
|
||||
|
||||
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 }) =>
|
||||
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 }>) =>
|
||||
|
||||
Reference in New Issue
Block a user