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