feat(historie): team-weite Tages-Historie (ENTWICKLUN-10)
Neue Seite /historie: pro vergangenem Tag sieht man, was jede/r gemacht hat —
Reflexion (Ampel/Trend/Text) + ToDos (erledigt/offen/verworfen), plus Liste
„ohne Eintrag". Datums-Navigation (vorheriger/nächster Werktag, Datum-Picker);
Default = vorheriger Werktag. Heutige Reflexionen bleiben privat (nur ToDos).
- Endpoint dayEntry.historyForDate({date?}) liefert Einträge + prev/next-Werktag.
- TopNav-Tab „Historie" (g i), Link „Ganze Historie →" im Dashboard-Feed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c3d3c4500e
commit
9bf20fbabd
7 files changed
+259
-5
No files matched your search
@@ -11,6 +11,7 @@ const SHORTCUT_MAP: Record<string, string> = {
|
||||
e: '/bereich/entwicklung',
|
||||
v: '/bereich/vertrieb',
|
||||
m: '/bereich/marketing',
|
||||
i: '/historie',
|
||||
a: '/archiv',
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const TABS = [
|
||||
{ to: '/dashboard', label: 'Dashboard', shortcut: 'g d', matchPrefix: '/dashboard' },
|
||||
{ to: '/bereich', label: 'Bereich', shortcut: 'g b', matchPrefix: '/bereich' },
|
||||
{ to: '/heute', label: 'Heute', shortcut: 'g h', matchPrefix: '/heute' },
|
||||
{ to: '/historie', label: 'Historie', shortcut: 'g i', matchPrefix: '/historie' },
|
||||
{ to: '/archiv', label: 'Archiv', shortcut: 'g a', matchPrefix: '/archiv' },
|
||||
] as const;
|
||||
|
||||
|
||||
@@ -55,6 +55,9 @@ const Projekt = lazyWithReload(() =>
|
||||
);
|
||||
const Leads = lazyWithReload(() => import('../routes/leads').then((m) => ({ default: m.LeadsRoute })));
|
||||
const Lead = lazyWithReload(() => import('../routes/lead').then((m) => ({ default: m.LeadRoute })));
|
||||
const Historie = lazyWithReload(() =>
|
||||
import('../routes/historie').then((m) => ({ default: m.HistorieRoute })),
|
||||
);
|
||||
const Archiv = lazyWithReload(() =>
|
||||
import('../routes/archiv').then((m) => ({ default: m.ArchivRoute })),
|
||||
);
|
||||
@@ -103,6 +106,7 @@ const router = createBrowserRouter([
|
||||
{ path: 'projekt/:id', element: withSuspense(<Projekt />) },
|
||||
{ path: 'leads', element: withSuspense(<Leads />) },
|
||||
{ path: 'lead/:id', element: withSuspense(<Lead />) },
|
||||
{ path: 'historie', element: withSuspense(<Historie />) },
|
||||
{ path: 'archiv', element: withSuspense(<Archiv />) },
|
||||
{ path: '*', element: withSuspense(<NotFound />) },
|
||||
],
|
||||
|
||||
@@ -204,9 +204,17 @@ export function DashboardRoute() {
|
||||
<TeamTimeSection />
|
||||
|
||||
<section aria-labelledby="feed-heading">
|
||||
<h2 id="feed-heading" className="mb-3 text-lg font-semibold">
|
||||
Gestern im Team
|
||||
</h2>
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<h2 id="feed-heading" className="text-lg font-semibold">
|
||||
Gestern im Team
|
||||
</h2>
|
||||
<Link
|
||||
to="/historie"
|
||||
className="text-xs text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:underline"
|
||||
>
|
||||
Ganze Historie →
|
||||
</Link>
|
||||
</div>
|
||||
{q.data.feed.length === 0 ? (
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import type { inferRouterOutputs } from '@trpc/server';
|
||||
import type { AppRouter } from '@cockpit/api/router';
|
||||
import { trpc } from '../lib/trpc';
|
||||
import { Card } from '../components/ui/Card';
|
||||
import { SkeletonCard } from '../components/ui/SkeletonCard';
|
||||
|
||||
type HistoryPerson = inferRouterOutputs<AppRouter>['dayEntry']['historyForDate']['entries'][number];
|
||||
|
||||
const AMPEL_META: Record<string, { label: string; badge: string }> = {
|
||||
green: { label: 'Im Plan', badge: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]' },
|
||||
amber: { label: 'Gefährdet', badge: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]' },
|
||||
red: { label: 'Blockiert', badge: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]' },
|
||||
};
|
||||
const TREND_LABEL: Record<string, string> = {
|
||||
besser: '↗ Besser',
|
||||
gleich: '→ Gleich',
|
||||
schlechter: '↘ Schlechter',
|
||||
};
|
||||
|
||||
function formatDateLong(iso: string): string {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: 'Europe/Berlin',
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}).format(new Date(`${iso}T12:00:00Z`));
|
||||
}
|
||||
|
||||
const todayStr = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin' }).format(new Date());
|
||||
|
||||
export function HistorieRoute() {
|
||||
const [date, setDate] = useState<string | undefined>(undefined);
|
||||
const q = trpc.dayEntry.historyForDate.useQuery({ date });
|
||||
|
||||
if (q.isPending) return <SkeletonCard lines={6} />;
|
||||
if (!q.data) return null;
|
||||
const { date: current, prevDate, nextDate, entries, ohneEintrag } = q.data;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="text-3xl font-bold">Historie</h1>
|
||||
<p className="text-[var(--color-ink-muted)]">
|
||||
Was hat das Team an einem Tag gemacht — Reflexion & ToDos.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDate(prevDate)}
|
||||
aria-label="Vorheriger Werktag"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)]"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<span className="min-w-[200px] text-center font-medium">{formatDateLong(current)}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => nextDate && setDate(nextDate)}
|
||||
disabled={!nextDate}
|
||||
aria-label="Nächster Werktag"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-ink-muted)] hover:border-[var(--color-ink)] hover:text-[var(--color-ink)] disabled:opacity-40 disabled:hover:border-[var(--color-border)]"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
<input
|
||||
type="date"
|
||||
value={current}
|
||||
max={todayStr}
|
||||
onChange={(e) => setDate(e.target.value || undefined)}
|
||||
className="ml-2 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-2 py-1.5 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{entries.length === 0 ? (
|
||||
<Card.Root>
|
||||
<Card.Body>
|
||||
<p className="text-sm text-[var(--color-ink-muted)]">
|
||||
Keine Einträge an diesem Tag.
|
||||
</p>
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{entries.map((p) => (
|
||||
<PersonCard key={p.userId} person={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ohneEintrag.length > 0 && (
|
||||
<p className="text-xs text-[var(--color-ink-faint)]">
|
||||
Ohne Eintrag an diesem Tag: {ohneEintrag.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonCard({ person: p }: { person: HistoryPerson }) {
|
||||
const ampel = p.ampel ? AMPEL_META[p.ampel] : null;
|
||||
return (
|
||||
<Card.Root>
|
||||
<Card.Body className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className="font-semibold">{p.name}</p>
|
||||
{ampel && (
|
||||
<span className={clsx('rounded-full px-2 py-0.5 text-xs font-medium', ampel.badge)}>
|
||||
{ampel.label}
|
||||
</span>
|
||||
)}
|
||||
{p.trend && (
|
||||
<span className="text-xs text-[var(--color-ink-muted)]">
|
||||
{TREND_LABEL[p.trend] ?? p.trend}
|
||||
</span>
|
||||
)}
|
||||
{p.status === 'begun' && (
|
||||
<span className="rounded-full bg-black/5 px-2 py-0.5 text-xs text-[var(--color-ink-muted)]">
|
||||
nicht reflektiert
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{p.todos.length > 0 && (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{p.todos.map((t) => (
|
||||
<li key={t.id} className="flex items-start gap-2">
|
||||
<span
|
||||
className={clsx(
|
||||
'mt-1.5 inline-block h-2 w-2 shrink-0 rounded-full',
|
||||
t.status === 'done'
|
||||
? 'bg-[var(--color-ampel-green-fg)]'
|
||||
: t.status === 'dropped'
|
||||
? 'bg-[var(--color-ink-faint)]'
|
||||
: 'bg-[var(--color-border-strong)]',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={clsx(
|
||||
t.status === 'dropped' && 'text-[var(--color-ink-muted)] line-through',
|
||||
)}
|
||||
>
|
||||
{t.title}
|
||||
{t.status === 'dropped' && t.dropReason && (
|
||||
<span className="text-xs text-[var(--color-ink-faint)]"> — {t.dropReason}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{p.reflectionText && (
|
||||
<p className="whitespace-pre-wrap text-sm text-[var(--color-ink)]">{p.reflectionText}</p>
|
||||
)}
|
||||
{p.todos.length === 0 && !p.reflectionText && (
|
||||
<p className="text-xs text-[var(--color-ink-faint)]">Keine Details.</p>
|
||||
)}
|
||||
</Card.Body>
|
||||
</Card.Root>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user