From be37d89d536dadb7a736b9e17f94d40831fbe486 Mon Sep 17 00:00:00 2001 From: schakal00783 Date: Fri, 18 Sep 2026 11:28:42 +0200 Subject: [PATCH] feat(projekte): Listenansicht der Projektuebersicht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Umschalter Karten/Liste neben dem Statusfilter, die Wahl merkt sich der Browser. Die Liste zeigt alle gefilterten Projekte flach ueber die Kunden hinweg und sortierbar: Projekt mit Code, Toepfen und Tasks, Kunde, Typ, gebuchte Stunden und Tage, Budget mit freiem Rest, Verbrauchsbalken, offener Betrag mit erbrachtem Umsatz und das Team. Beträge nur ab Vorgesetztem. Ein Klick auf eine Zeile oeffnet dieselbe Detailansicht wie eine Kachel, in der Liste unterhalb der Tabelle. Dafuer ist der Detailblock einmal aufgebaut und wird in beiden Ansichten verwendet. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 + src/components/projects/ProjectTable.tsx | 192 +++++++++++++++++++++++ src/components/projects/Projects.tsx | 61 +++++-- 3 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 src/components/projects/ProjectTable.tsx diff --git a/README.md b/README.md index 823927d..0a3d1df 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,11 @@ Dieselbe Komponente wird an drei Stellen benutzt: ein gesetzter Kundenfilter wird übernommen (`GET /api/entries?...&customer_id=`). - **Kunden**: Umschalter *Karten / Liste* mit Ort, Projektanzahl, Stunden, Umsatz erbracht/offen und Team. +`src/components/projects/ProjectTable.tsx` ist die Listenansicht der Projekte (Umschalter *Karten / Liste*): flach über alle +Kunden statt nach Kunde gruppiert, sortierbar nach Projekt, Kunde, Typ, gebuchten Stunden, Budget, Verbrauch und offenem Betrag. +Ein Klick auf eine Zeile öffnet dieselbe Detailansicht wie eine Kachel, hier unterhalb der Tabelle. Beträge sieht nur, wer +mindestens Vorgesetzter ist. + ## SSO / Active Directory @@ -136,6 +141,7 @@ Aus Auftrag und Call-Mitschrift. | 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 | +| Projekte: zusätzliche Listenansicht | ✅ | Projekte → 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/src/components/projects/ProjectTable.tsx b/src/components/projects/ProjectTable.tsx new file mode 100644 index 0000000..28a8cf6 --- /dev/null +++ b/src/components/projects/ProjectTable.tsx @@ -0,0 +1,192 @@ +import React, { useMemo, useState } from 'react'; +import { AvatarStack, Badge, BudgetBar } from '../ui/controls'; +import { Icon } from '../ui/Icon'; +import { TYPE_TONE } from './Projects'; +import { colorFor, formatEuro, formatHours, formatNumber } from '../../lib/format'; +import { PROJECT_TYPE_LABELS, type Project } from '../../types'; + +type SortKey = 'name' | 'customer' | 'type' | 'used' | 'budget' | 'percent' | 'open'; + +const percentOf = (p: Project) => (p.budget_hours ? (p.budget_used_minutes / 60 / p.budget_hours) * 100 : null); + +/** + * Listenansicht der Projekte: dieselben Kennzahlen wie auf den Kacheln, nur + * flach ueber alle Kunden und sortierbar. + */ +export const ProjectTable: React.FC<{ + projects: Project[]; + selectedId: number | null; + showAmounts: boolean; + hoursPerDay: number; + warnAt: number; + onOpen: (id: number) => void; +}> = ({ projects, selectedId, showAmounts, hoursPerDay, warnAt, onOpen }) => { + const [sort, setSort] = useState<{ key: SortKey; dir: 1 | -1 }>({ key: 'used', dir: -1 }); + + const sorted = useMemo(() => { + const value = (p: Project): string | number => { + switch (sort.key) { + case 'name': + return p.display_name; + case 'customer': + return p.customer.name; + case 'type': + return PROJECT_TYPE_LABELS[p.type]; + case 'budget': + return p.budget_hours ?? -1; + case 'percent': + return percentOf(p) ?? -1; + case 'open': + return p.open ?? -1; + default: + return p.used_minutes; + } + }; + return [...projects].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.display_name.localeCompare(b.display_name, 'de')) * sort.dir; + }); + }, [projects, sort]); + + const sum = projects.reduce( + (s, p) => ({ minutes: s.minutes + p.used_minutes, budget: s.budget + (p.budget_hours ?? 0), open: s.open + (p.open ?? 0) }), + { minutes: 0, budget: 0, open: 0 } + ); + + const head = (key: SortKey, text: string, align: 'left' | 'right' = 'right', className = '') => ( + + + + ); + + return ( +
+ + + + {head('name', 'Projekt', 'left', 'pl-4')} + {head('customer', 'Kunde', 'left')} + {head('type', 'Typ', 'left')} + {head('used', 'Gebucht')} + {head('budget', 'Budget')} + {head('percent', 'Verbrauch', 'left', 'w-[15%]')} + {showAmounts && head('open', 'Offen')} + + + + + {sorted.map((p, i) => { + const percent = percentOf(p); + const warning = p.pots.find((pot) => pot.warning && pot.status !== 'expired'); + return ( + onOpen(p.id)} + className={`animate-rise cursor-pointer border-b border-ink-100 last:border-0 hover:bg-brand-50/50 ${ + p.id === selectedId ? 'bg-brand-50/70' : '' + } ${p.is_active ? '' : 'opacity-60'}`} + style={{ '--i': Math.min(i, 15) } as React.CSSProperties} + > + + + + + + + {showAmounts && ( + + )} + + + ); + })} + + + + + + + } + + +
Team
+ + + + + {p.display_name} + {!p.is_active && Archiv} + {warning && ( + + + + )} + + + {p.code} + + + {p.pots.length} + + + + {p.tasks.filter((t) => t.is_active).length} + + + + + + {p.customer.name} + + {PROJECT_TYPE_LABELS[p.type]} + + {formatHours(p.used_minutes, 1)} h + {formatNumber(p.used_minutes / 60 / hoursPerDay, 1)} Tage + + {p.budget_hours != null ? ( + <> + {formatNumber(p.budget_hours, 0)} h + {formatNumber(p.remaining_hours ?? 0, 1)} h frei + + ) : p.type === 'abruf' ? ( + <> + nach Aufwand + + {p.rate != null ? `${formatEuro(p.rate, 2)} / ${p.billing_unit === 'day' ? 'Tag' : 'Std.'}` : 'kein Satz'} + + + ) : ( + ohne Limit + )} + + + {percent == null ? '–' : `${Math.round(percent)} %`} + + {p.type === 'abruf' || p.budget_value ? ( + <> + {formatEuro(p.open ?? 0)} + {formatEuro(p.earned ?? 0)} erbracht + + ) : ( + – + )} + + + + +
+ Summe · {projects.length} {projects.length === 1 ? 'Projekt' : 'Projekte'} + {formatHours(sum.minutes, 1)} h{sum.budget ? `${formatNumber(sum.budget, 0)} h` : '–'} + {showAmounts && {formatEuro(sum.open)} +
+
+ ); +}; diff --git a/src/components/projects/Projects.tsx b/src/components/projects/Projects.tsx index 794a156..75a730b 100644 --- a/src/components/projects/Projects.tsx +++ b/src/components/projects/Projects.tsx @@ -3,6 +3,7 @@ import { AvatarStack, Badge, BudgetBar, Button, Card, EmptyState, ErrorBox, Segm import { Icon } from '../ui/Icon'; import { ProjectForm } from './ProjectForm'; import { ProjectDetail } from './ProjectDetail'; +import { ProjectTable } from './ProjectTable'; import { useAuth } from '../../lib/auth'; import { customersApi, projectsApi, usersApi } from '../../lib/api'; import { useLoader, usePersistentState } from '../../lib/hooks'; @@ -22,6 +23,7 @@ export const Projects: React.FC = () => { const [search, setSearch] = useState(''); const [customerFilter, setCustomerFilter] = useState(''); const [status, setStatus] = usePersistentState<'active' | 'archived' | 'all'>('pulse_projects_status', 'active'); + const [layout, setLayout] = usePersistentState<'cards' | 'list'>('pulse_projects_layout', 'cards'); const [form, setForm] = useState<{ project: Project | null } | null>(null); const detailRef = useRef(null); @@ -70,6 +72,24 @@ export const Projects: React.FC = () => { if (projects.error && !projects.data) return ; if (!projects.data) return ; + // Die Detailansicht steht in der Kachelansicht unter ihrer Kundengruppe, + // in der Liste unter der Tabelle - der Inhalt ist derselbe. + const detail = selected && ( + setForm({ project: selected })} + onDeleted={() => { + projects.setData((cur) => (cur ?? []).filter((p) => p.id !== selected.id)); + navigate('projekte'); + }} + onClose={() => navigate('projekte')} + /> + ); + return (
@@ -99,6 +119,15 @@ export const Projects: React.FC = () => { ]} /> )} + {canEdit && ( : undefined} /> + ) : layout === 'list' ? ( +
+ + navigate(id === selectedId ? 'projekte' : `projekte/${id}`)} + /> + + {selected && ( +
+ {detail} +
+ )} +
) : (
{groups.map((group, gi) => ( @@ -140,19 +187,7 @@ export const Projects: React.FC = () => {
{selected && selected.customer_id === group.customer.id && (
- setForm({ project: selected })} - onDeleted={() => { - projects.setData((cur) => (cur ?? []).filter((p) => p.id !== selected.id)); - navigate('projekte'); - }} - onClose={() => navigate('projekte')} - /> + {detail}
)}