diff --git a/apps/api/src/routers/day-entry.ts b/apps/api/src/routers/day-entry.ts index 8491308..4256a2d 100644 --- a/apps/api/src/routers/day-entry.ts +++ b/apps/api/src/routers/day-entry.ts @@ -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(); + 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). */ diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index 2eb85ca..5b269e5 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -11,6 +11,7 @@ const SHORTCUT_MAP: Record = { e: '/bereich/entwicklung', v: '/bereich/vertrieb', m: '/bereich/marketing', + i: '/historie', a: '/archiv', }; diff --git a/apps/web/src/components/layout/TopNav.tsx b/apps/web/src/components/layout/TopNav.tsx index 2e6159d..7d03407 100644 --- a/apps/web/src/components/layout/TopNav.tsx +++ b/apps/web/src/components/layout/TopNav.tsx @@ -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; diff --git a/apps/web/src/lib/router.tsx b/apps/web/src/lib/router.tsx index 0969d1e..2233d8e 100644 --- a/apps/web/src/lib/router.tsx +++ b/apps/web/src/lib/router.tsx @@ -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() }, { path: 'leads', element: withSuspense() }, { path: 'lead/:id', element: withSuspense() }, + { path: 'historie', element: withSuspense() }, { path: 'archiv', element: withSuspense() }, { path: '*', element: withSuspense() }, ], diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/dashboard.tsx index ff0af7e..b6fe805 100644 --- a/apps/web/src/routes/dashboard.tsx +++ b/apps/web/src/routes/dashboard.tsx @@ -204,9 +204,17 @@ export function DashboardRoute() {
-

- Gestern im Team -

+
+

+ Gestern im Team +

+ + Ganze Historie → + +
{q.data.feed.length === 0 ? ( diff --git a/apps/web/src/routes/historie.tsx b/apps/web/src/routes/historie.tsx new file mode 100644 index 0000000..22f3a11 --- /dev/null +++ b/apps/web/src/routes/historie.tsx @@ -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['dayEntry']['historyForDate']['entries'][number]; + +const AMPEL_META: Record = { + 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 = { + 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(undefined); + const q = trpc.dayEntry.historyForDate.useQuery({ date }); + + if (q.isPending) return ; + if (!q.data) return null; + const { date: current, prevDate, nextDate, entries, ohneEintrag } = q.data; + + return ( +
+
+

Historie

+

+ Was hat das Team an einem Tag gemacht — Reflexion & ToDos. +

+
+ +
+ + {formatDateLong(current)} + + 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" + /> +
+ + {entries.length === 0 ? ( + + +

+ Keine Einträge an diesem Tag. +

+
+
+ ) : ( +
+ {entries.map((p) => ( + + ))} +
+ )} + + {ohneEintrag.length > 0 && ( +

+ Ohne Eintrag an diesem Tag: {ohneEintrag.join(', ')} +

+ )} +
+ ); +} + +function PersonCard({ person: p }: { person: HistoryPerson }) { + const ampel = p.ampel ? AMPEL_META[p.ampel] : null; + return ( + + +
+

{p.name}

+ {ampel && ( + + {ampel.label} + + )} + {p.trend && ( + + {TREND_LABEL[p.trend] ?? p.trend} + + )} + {p.status === 'begun' && ( + + nicht reflektiert + + )} +
+ + {p.todos.length > 0 && ( +
    + {p.todos.map((t) => ( +
  • + + + {t.title} + {t.status === 'dropped' && t.dropReason && ( + — {t.dropReason} + )} + +
  • + ))} +
+ )} + + {p.reflectionText && ( +

{p.reflectionText}

+ )} + {p.todos.length === 0 && !p.reflectionText && ( +

Keine Details.

+ )} +
+
+ ); +} diff --git a/packages/shared/src/schemas/day-entry.ts b/packages/shared/src/schemas/day-entry.ts index cf12d29..f09eec7 100644 --- a/packages/shared/src/schemas/day-entry.ts +++ b/packages/shared/src/schemas/day-entry.ts @@ -65,6 +65,14 @@ export const addTodoInput = z.object({ }); export type AddTodoInput = z.infer; +export const historyForDateInput = z.object({ + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), // Default: vorheriger Werktag +}); +export type HistoryForDateInput = z.infer; + 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}$/),