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
@@ -1,9 +1,9 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { and, eq, isNull, lt, asc, desc, inArray } from 'drizzle-orm';
|
||||
import { dayEntry, todo, blocker, type Todo } from '@cockpit/db';
|
||||
import { dayEntry, todo, blocker, user, type Todo } from '@cockpit/db';
|
||||
import { DayEntrySchemas } from '@cockpit/shared';
|
||||
import { router, protectedProcedure } from '../trpc.js';
|
||||
import { todayIso } from '../lib/werktag.js';
|
||||
import { todayIso, previousWerktag, nextWerktag } from '../lib/werktag.js';
|
||||
|
||||
export const dayEntryRouter = router({
|
||||
/**
|
||||
@@ -383,6 +383,71 @@ export const dayEntryRouter = router({
|
||||
return entries;
|
||||
}),
|
||||
|
||||
/**
|
||||
* Team-weite Historie eines (vergangenen) Tages: was hat jede/r gemacht —
|
||||
* Reflexion (Ampel/Trend/Text) + ToDos. Default-Datum = vorheriger Werktag.
|
||||
* Datums-Navigation (prev/next Werktag) wird mitgeliefert.
|
||||
*/
|
||||
historyForDate: protectedProcedure
|
||||
.input(DayEntrySchemas.historyForDateInput)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const today = todayIso();
|
||||
const date = input.date ?? previousWerktag(today);
|
||||
const isToday = date === today;
|
||||
|
||||
const entries = await ctx.db.select().from(dayEntry).where(eq(dayEntry.date, date));
|
||||
const userRows = await ctx.db
|
||||
.select({ id: user.id, name: user.name })
|
||||
.from(user)
|
||||
.where(eq(user.active, true));
|
||||
const nameById = new Map(userRows.map((u) => [u.id, u.name]));
|
||||
|
||||
const ids = entries.map((e) => e.id);
|
||||
const todoRows = ids.length
|
||||
? await ctx.db.select().from(todo).where(inArray(todo.dayEntryId, ids)).orderBy(asc(todo.order))
|
||||
: [];
|
||||
const todosByEntry = new Map<string, Todo[]>();
|
||||
for (const t of todoRows) {
|
||||
const arr = todosByEntry.get(t.dayEntryId) ?? [];
|
||||
arr.push(t);
|
||||
todosByEntry.set(t.dayEntryId, arr);
|
||||
}
|
||||
|
||||
const list = entries
|
||||
.map((e) => ({
|
||||
userId: e.userId,
|
||||
name: nameById.get(e.userId) ?? '—',
|
||||
status: e.status,
|
||||
ampel: e.ampel,
|
||||
trend: e.trend,
|
||||
// Heutige Reflexion bleibt privat bis morgen; ToDos sind teamweit sichtbar.
|
||||
reflectionText: isToday ? null : e.reflectionText,
|
||||
url: e.url,
|
||||
todos: (todosByEntry.get(e.id) ?? []).map((t) => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
status: t.status,
|
||||
dropReason: t.dropReason,
|
||||
})),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const withEntry = new Set(entries.map((e) => e.userId));
|
||||
const ohneEintrag = userRows
|
||||
.filter((u) => !withEntry.has(u.id))
|
||||
.map((u) => u.name)
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
|
||||
const nw = nextWerktag(date);
|
||||
return {
|
||||
date,
|
||||
prevDate: previousWerktag(date),
|
||||
nextDate: nw < today ? nw : null,
|
||||
entries: list,
|
||||
ohneEintrag,
|
||||
};
|
||||
}),
|
||||
|
||||
/**
|
||||
* „Heute fehlt"-Liste (Anwesenheits-Übersicht).
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,14 @@ export const addTodoInput = z.object({
|
||||
});
|
||||
export type AddTodoInput = z.infer<typeof addTodoInput>;
|
||||
|
||||
export const historyForDateInput = z.object({
|
||||
date: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(), // Default: vorheriger Werktag
|
||||
});
|
||||
export type HistoryForDateInput = z.infer<typeof historyForDateInput>;
|
||||
|
||||
export const markAbsentInput = z.object({
|
||||
from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
|
||||
Reference in new issue
Block a user