diff --git a/README.md b/README.md index 4da1d5d..823927d 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/server/src/routes/entries.js b/server/src/routes/entries.js index 0882dcc..f575cb9 100644 --- a/server/src/routes/entries.js +++ b/server/src/routes/entries.js @@ -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); diff --git a/src/components/booking/Booking.tsx b/src/components/booking/Booking.tsx index 4809254..ab43a8f 100644 --- a/src/components/booking/Booking.tsx +++ b/src/components/booking/Booking.tsx @@ -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('pulse_booking_mode', 'week'); + const [layout, setLayout] = usePersistentState('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 = () => {

{label}

-
+
{ { value: 'year', label: 'Jahr' }, ]} /> + {isLead && team.length > 1 && ( setSearch(e.target.value)} />
+ @@ -81,6 +91,15 @@ export const Customers: React.FC = () => { {customers.length === 0 ? ( setForm({ customer: null })}>Neuer Kunde} /> + ) : layout === 'list' ? ( + + setForm({ customer: c })} + onDelete={setRemove} + onReport={(id) => navigate(`reports/kunde/${id}`)} + /> + ) : (
{customers.map((c, i) => ( @@ -317,3 +336,131 @@ const MiniRing: React.FC<{ percent: number; color: string }> = ({ percent, color
); }; + +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 = '') => ( + + + + ); + + return ( +
+ + + + {head('name', 'Kunde', 'left', 'pl-4')} + {head('city', 'Ort', 'left')} + {head('projects', 'Projekte')} + {head('used_minutes', 'Stunden')} + {head('earned', 'Erbracht')} + {head('open', 'Offen')} + + + + + {sorted.map((c, i) => { + const color = colorFor(c.id, c.color); + const active = c.projects.filter((p) => p.is_active).length; + return ( + + + + + + + + + + + ); + })} + + + + + + + + + + +
Team +
+ + {c.city ?? '–'} + {c.projects.length} + {c.projects.length > 0 && · {active} aktiv} + {formatHours(c.used_minutes, 1)}{formatEuro(c.earned)}{formatEuro(c.open)} + + +
+ onReport(c.id)} /> + onEdit(c)} /> + {c.projects.length === 0 && onDelete(c)} />} +
+
Summe · {customers.length} Kunden + {sum.projects}{formatHours(sum.minutes, 1)}{formatEuro(sum.earned)}{formatEuro(sum.open)} + +
+
+ ); +}; diff --git a/src/components/reports/Reports.tsx b/src/components/reports/Reports.tsx index f4a3bb9..3dac6e6 100644 --- a/src/components/reports/Reports.tsx +++ b/src/components/reports/Reports.tsx @@ -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([]); const [leads, setLeads] = useState([]); + // Klick auf einen Mitarbeiter oeffnet seine Buchungen des Zeitraums als Liste. + const [detail, setDetail] = useState(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}`) - : undefined + : tab === 'users' + ? (row) => setDetail(row) + : undefined } /> )} + + {detail && ( + setDetail(null)} /> + )}
); }; +/** 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 ( + + {range.label} + · + + {formatHours(row.minutes)} h gesamt, davon {formatHours(row.billable_minutes)} h billable + + {row.utilization != null && ( + <> + · + {row.utilization} % Auslastung + + )} + + } + > +
+ +

+ {entries.data ? `${entries.data.length} Buchungen` : 'Lade Buchungen…'} +

+
+ {entries.error && } + {!entries.data ? ( + + ) : ( +
+ +
+ )} +
+ ); +}; + export { periodRange }; diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index eb4cd44..9db3acf 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -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[] = []; diff --git a/src/components/ui/controls.tsx b/src/components/ui/controls.tsx index a5c6eaa..85ff726 100644 --- a/src/components/ui/controls.tsx +++ b/src/components/ui/controls.tsx @@ -80,7 +80,7 @@ export function Segmented({ 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({ 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 && } {option.label} ))} diff --git a/src/lib/api.ts b/src/lib/api.ts index 26c169f..d5b733d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -239,7 +239,8 @@ export const projectsApi = { }; export const entriesApi = { - list: (from: string, to: string, userId?: number) => get(`/entries${qs({ from, to, user_id: userId })}`), + list: (from: string, to: string, userId?: number, customerId?: number | null) => + get(`/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 }>) =>