feat(projekte): Listenansicht der Projektuebersicht
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
133fca9ef6
commit
be37d89d53
@@ -65,6 +65,11 @@ Dieselbe Komponente wird an drei Stellen benutzt:
|
|||||||
ein gesetzter Kundenfilter wird übernommen (`GET /api/entries?...&customer_id=`).
|
ein gesetzter Kundenfilter wird übernommen (`GET /api/entries?...&customer_id=`).
|
||||||
- **Kunden**: Umschalter *Karten / Liste* mit Ort, Projektanzahl, Stunden, Umsatz erbracht/offen und Team.
|
- **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
|
## 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 |
|
| 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 |
|
| Klick auf einen Mitarbeiter im Zeitreport zeigt dessen Buchungen des Zeitraums als Liste | ✅ | Reports → Zeitreport → Mitarbeiter |
|
||||||
| Kunden: zusätzliche Listenansicht | ✅ | Kunden → Karten/Liste |
|
| 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 |
|
| 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 |
|
||||||
|
|||||||
@@ -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 = '') => (
|
||||||
|
<th className={`px-2.5 py-3 ${align === 'right' ? 'text-right' : 'text-left'} ${className}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => setSort({ key, dir: sort.key === key ? ((sort.dir * -1) as 1 | -1) : key === 'used' || key === 'budget' || key === 'percent' || key === 'open' ? -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-[1000px] text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-ink-200 text-[11px] text-ink-400">
|
||||||
|
{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')}
|
||||||
|
<th className="w-[1%] whitespace-nowrap py-3 pl-3 pr-4 text-left font-extrabold uppercase tracking-wider">Team</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((p, i) => {
|
||||||
|
const percent = percentOf(p);
|
||||||
|
const warning = p.pots.find((pot) => pot.warning && pot.status !== 'expired');
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={p.id}
|
||||||
|
onClick={() => 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}
|
||||||
|
>
|
||||||
|
<td className="py-2.5 pl-4 pr-3">
|
||||||
|
<span className="flex min-w-0 items-start gap-2.5">
|
||||||
|
<span aria-hidden className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: colorFor(p.customer_id, p.customer.color) }} />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="max-w-[300px] truncate font-bold text-ink-900">{p.display_name}</span>
|
||||||
|
{!p.is_active && <Badge>Archiv</Badge>}
|
||||||
|
{warning && (
|
||||||
|
<span title={warning.warning ?? undefined}>
|
||||||
|
<Icon name="warning" className="h-3.5 w-3.5 shrink-0 text-amber-600" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="tabular flex items-center gap-2 text-[11px] text-ink-400">
|
||||||
|
<span className="truncate">{p.code}</span>
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1" title={`${p.pots.length} Budgettöpfe`}>
|
||||||
|
<Icon name="pot" className="h-3 w-3" />
|
||||||
|
{p.pots.length}
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1" title="Aktive Tasks">
|
||||||
|
<Icon name="list" className="h-3 w-3" />
|
||||||
|
{p.tasks.filter((t) => t.is_active).length}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2.5 py-2.5 text-ink-600">
|
||||||
|
<span className="block max-w-[220px] truncate">{p.customer.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2.5 py-2.5">
|
||||||
|
<Badge tone={TYPE_TONE[p.type]}>{PROJECT_TYPE_LABELS[p.type]}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="tabular whitespace-nowrap px-2.5 py-2.5 text-right font-extrabold text-ink-900">
|
||||||
|
{formatHours(p.used_minutes, 1)} h
|
||||||
|
<span className="block text-[11px] font-semibold text-ink-400">{formatNumber(p.used_minutes / 60 / hoursPerDay, 1)} Tage</span>
|
||||||
|
</td>
|
||||||
|
<td className="tabular whitespace-nowrap px-2.5 py-2.5 text-right text-ink-700">
|
||||||
|
{p.budget_hours != null ? (
|
||||||
|
<>
|
||||||
|
{formatNumber(p.budget_hours, 0)} h
|
||||||
|
<span className="block text-[11px] font-semibold text-ink-400">{formatNumber(p.remaining_hours ?? 0, 1)} h frei</span>
|
||||||
|
</>
|
||||||
|
) : p.type === 'abruf' ? (
|
||||||
|
<>
|
||||||
|
nach Aufwand
|
||||||
|
<span className="block text-[11px] font-semibold text-ink-400">
|
||||||
|
{p.rate != null ? `${formatEuro(p.rate, 2)} / ${p.billing_unit === 'day' ? 'Tag' : 'Std.'}` : 'kein Satz'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="whitespace-nowrap text-ink-400">ohne Limit</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2.5 py-2.5">
|
||||||
|
<BudgetBar percent={percent} warnAt={warnAt} />
|
||||||
|
<span className="tabular mt-1 block text-[11px] font-semibold text-ink-400">{percent == null ? '–' : `${Math.round(percent)} %`}</span>
|
||||||
|
</td>
|
||||||
|
{showAmounts && (
|
||||||
|
<td className="tabular whitespace-nowrap px-2.5 py-2.5 text-right text-ink-700">
|
||||||
|
{p.type === 'abruf' || p.budget_value ? (
|
||||||
|
<>
|
||||||
|
{formatEuro(p.open ?? 0)}
|
||||||
|
<span className="block text-[11px] font-semibold text-ink-400">{formatEuro(p.earned ?? 0)} erbracht</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-ink-300">–</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="py-2.5 pl-3 pr-4">
|
||||||
|
<span className="block w-max">
|
||||||
|
<AvatarStack people={p.members} max={5} size={22} />
|
||||||
|
</span>
|
||||||
|
</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={3}>
|
||||||
|
Summe · {projects.length} {projects.length === 1 ? 'Projekt' : 'Projekte'}
|
||||||
|
</td>
|
||||||
|
<td className="tabular whitespace-nowrap px-2.5 py-3 text-right">{formatHours(sum.minutes, 1)} h</td>
|
||||||
|
<td className="tabular whitespace-nowrap px-2.5 py-3 text-right">{sum.budget ? `${formatNumber(sum.budget, 0)} h` : '–'}</td>
|
||||||
|
<td />
|
||||||
|
{showAmounts && <td className="tabular whitespace-nowrap px-2.5 py-3 text-right">{formatEuro(sum.open)}</td>}
|
||||||
|
<td />
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -3,6 +3,7 @@ import { AvatarStack, Badge, BudgetBar, Button, Card, EmptyState, ErrorBox, Segm
|
|||||||
import { Icon } from '../ui/Icon';
|
import { Icon } from '../ui/Icon';
|
||||||
import { ProjectForm } from './ProjectForm';
|
import { ProjectForm } from './ProjectForm';
|
||||||
import { ProjectDetail } from './ProjectDetail';
|
import { ProjectDetail } from './ProjectDetail';
|
||||||
|
import { ProjectTable } from './ProjectTable';
|
||||||
import { useAuth } from '../../lib/auth';
|
import { useAuth } from '../../lib/auth';
|
||||||
import { customersApi, projectsApi, usersApi } from '../../lib/api';
|
import { customersApi, projectsApi, usersApi } from '../../lib/api';
|
||||||
import { useLoader, usePersistentState } from '../../lib/hooks';
|
import { useLoader, usePersistentState } from '../../lib/hooks';
|
||||||
@@ -22,6 +23,7 @@ export const Projects: React.FC = () => {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [customerFilter, setCustomerFilter] = useState<number | ''>('');
|
const [customerFilter, setCustomerFilter] = useState<number | ''>('');
|
||||||
const [status, setStatus] = usePersistentState<'active' | 'archived' | 'all'>('pulse_projects_status', 'active');
|
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 [form, setForm] = useState<{ project: Project | null } | null>(null);
|
||||||
const detailRef = useRef<HTMLDivElement>(null);
|
const detailRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@@ -70,6 +72,24 @@ export const Projects: React.FC = () => {
|
|||||||
if (projects.error && !projects.data) return <ErrorBox message={projects.error} onRetry={projects.reload} />;
|
if (projects.error && !projects.data) return <ErrorBox message={projects.error} onRetry={projects.reload} />;
|
||||||
if (!projects.data) return <Spinner label="Lade Projekte…" />;
|
if (!projects.data) return <Spinner label="Lade Projekte…" />;
|
||||||
|
|
||||||
|
// Die Detailansicht steht in der Kachelansicht unter ihrer Kundengruppe,
|
||||||
|
// in der Liste unter der Tabelle - der Inhalt ist derselbe.
|
||||||
|
const detail = selected && (
|
||||||
|
<ProjectDetail
|
||||||
|
key={selected.id}
|
||||||
|
project={selected}
|
||||||
|
canEdit={canEdit}
|
||||||
|
users={users}
|
||||||
|
onChanged={replaceProject}
|
||||||
|
onEdit={() => setForm({ project: selected })}
|
||||||
|
onDeleted={() => {
|
||||||
|
projects.setData((cur) => (cur ?? []).filter((p) => p.id !== selected.id));
|
||||||
|
navigate('projekte');
|
||||||
|
}}
|
||||||
|
onClose={() => navigate('projekte')}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-[1500px]">
|
<div className="mx-auto max-w-[1500px]">
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||||
@@ -99,6 +119,15 @@ export const Projects: React.FC = () => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<Segmented
|
||||||
|
size="sm"
|
||||||
|
value={layout}
|
||||||
|
onChange={setLayout}
|
||||||
|
options={[
|
||||||
|
{ value: 'cards', label: 'Karten', icon: 'grid', title: 'Kachelansicht' },
|
||||||
|
{ value: 'list', label: 'Liste', icon: 'list', title: 'Listenansicht' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Button icon="plus" className="ml-auto" onClick={() => setForm({ project: null })}>
|
<Button icon="plus" className="ml-auto" onClick={() => setForm({ project: null })}>
|
||||||
Neues Projekt
|
Neues Projekt
|
||||||
@@ -113,6 +142,24 @@ export const Projects: React.FC = () => {
|
|||||||
text={canEdit ? 'Lege ein Projekt mit Kunde und Budgettopf an und weise Mitarbeiter zu.' : 'Dein Vorgesetzter weist dir Projekte zu – danach kannst du darauf buchen.'}
|
text={canEdit ? 'Lege ein Projekt mit Kunde und Budgettopf an und weise Mitarbeiter zu.' : 'Dein Vorgesetzter weist dir Projekte zu – danach kannst du darauf buchen.'}
|
||||||
action={canEdit && !list.length ? <Button icon="plus" onClick={() => setForm({ project: null })}>Neues Projekt</Button> : undefined}
|
action={canEdit && !list.length ? <Button icon="plus" onClick={() => setForm({ project: null })}>Neues Projekt</Button> : undefined}
|
||||||
/>
|
/>
|
||||||
|
) : layout === 'list' ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Card className="animate-fade overflow-hidden">
|
||||||
|
<ProjectTable
|
||||||
|
projects={filtered}
|
||||||
|
selectedId={selectedId}
|
||||||
|
showAmounts={canEdit}
|
||||||
|
hoursPerDay={settings.hours_per_day}
|
||||||
|
warnAt={settings.budget_warn_percent}
|
||||||
|
onOpen={(id) => navigate(id === selectedId ? 'projekte' : `projekte/${id}`)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
{selected && (
|
||||||
|
<div ref={detailRef} className="scroll-mt-4">
|
||||||
|
{detail}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{groups.map((group, gi) => (
|
{groups.map((group, gi) => (
|
||||||
@@ -140,19 +187,7 @@ export const Projects: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
{selected && selected.customer_id === group.customer.id && (
|
{selected && selected.customer_id === group.customer.id && (
|
||||||
<div ref={detailRef} className="mt-3 scroll-mt-4">
|
<div ref={detailRef} className="mt-3 scroll-mt-4">
|
||||||
<ProjectDetail
|
{detail}
|
||||||
key={selected.id}
|
|
||||||
project={selected}
|
|
||||||
canEdit={canEdit}
|
|
||||||
users={users}
|
|
||||||
onChanged={replaceProject}
|
|
||||||
onEdit={() => setForm({ project: selected })}
|
|
||||||
onDeleted={() => {
|
|
||||||
projects.setData((cur) => (cur ?? []).filter((p) => p.id !== selected.id));
|
|
||||||
navigate('projekte');
|
|
||||||
}}
|
|
||||||
onClose={() => navigate('projekte')}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user