UI/Routing - Tab + Route: /puls → /dashboard, Heading „Unternehmens-Puls" → „Dashboard" - Shortcut: g p → g d (Cheatsheet entsprechend) - Breadcrumbs, 404-Hinweistext, alle internen Links angepasst Code-Layout - apps/web/src/routes/puls.tsx → dashboard.tsx (Komponente DashboardRoute) - apps/api/src/routers/puls.ts → dashboard.ts (dashboardRouter) - tRPC-Aufrufe: trpc.puls.* / utils.puls.* → trpc.dashboard.* / utils.dashboard.* - Auth-Redirects (/api/auth/login, /callback, dev-login) zeigen jetzt auf /dashboard Projekt-Beschreibung - Migration 0007_project_description: project.description text (nullable) - Shared Zod: ProjectSchemas.createProjectInput.description (max 2000, optional) - Backend: project.create mappt input.description - Frontend Dialog „Neues Projekt": Textarea (3 Zeilen, optional) - Projekt-Detail: zeigt Beschreibung als whitespace-pre-wrap unter Subtitle README - Routen-Tabelle, Shortcut-Liste, Verzeichnis-Baum, DB-Tabellen, Migration-Range, Frontend-Routenliste aktualisiert Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
98 lines
3.4 KiB
TypeScript
98 lines
3.4 KiB
TypeScript
import { and, eq, isNotNull, isNull, count } from 'drizzle-orm';
|
|
import { area, project, blocker, dayEntry, user, type Area, type Project } from '@cockpit/db';
|
|
import { router, protectedProcedure } from '../trpc.js';
|
|
import { todayIso, previousWerktag } from '../lib/werktag.js';
|
|
import { aggregateAreaAmpel } from '../lib/ampel.js';
|
|
|
|
export const dashboardRouter = router({
|
|
overview: protectedProcedure.query(async ({ ctx }) => {
|
|
const todayDate = todayIso();
|
|
const [areas, allProjects, openBlockers, todayPresence, totalActiveUsers, feedRows] =
|
|
await Promise.all([
|
|
ctx.db.select().from(area).orderBy(area.order),
|
|
ctx.db.select().from(project).where(eq(project.archiviert, false)),
|
|
ctx.db.select().from(blocker).where(isNull(blocker.resolvedAt)),
|
|
ctx.db
|
|
.select({ userId: dayEntry.userId, status: dayEntry.status })
|
|
.from(dayEntry)
|
|
.where(eq(dayEntry.date, todayDate)),
|
|
ctx.db.select({ c: count() }).from(user).where(eq(user.active, true)),
|
|
ctx.db
|
|
.select({
|
|
id: dayEntry.id,
|
|
userId: dayEntry.userId,
|
|
userName: user.name,
|
|
date: dayEntry.date,
|
|
ampel: dayEntry.ampel,
|
|
trend: dayEntry.trend,
|
|
reflectionText: dayEntry.reflectionText,
|
|
publishedAt: dayEntry.publishedAt,
|
|
})
|
|
.from(dayEntry)
|
|
.innerJoin(user, eq(user.id, dayEntry.userId))
|
|
.where(and(isNotNull(dayEntry.publishedAt), eq(dayEntry.status, 'full')))
|
|
.orderBy(dayEntry.publishedAt)
|
|
.limit(50),
|
|
]);
|
|
|
|
const projectsByArea = new Map<string, Project[]>();
|
|
for (const p of allProjects) {
|
|
const list = projectsByArea.get(p.areaId) ?? [];
|
|
list.push(p);
|
|
projectsByArea.set(p.areaId, list);
|
|
}
|
|
|
|
const areaSummaries = areas.map((a: Area) => {
|
|
const projs = projectsByArea.get(a.id) ?? [];
|
|
const agg = aggregateAreaAmpel(projs);
|
|
return {
|
|
id: a.id,
|
|
slug: a.slug,
|
|
name: a.name,
|
|
kind: a.kind,
|
|
ampel: agg.ampel,
|
|
counts: agg.counts,
|
|
};
|
|
});
|
|
|
|
const presentToday = new Set(todayPresence.map((p) => p.userId));
|
|
const usersAll = await ctx.db
|
|
.select({ id: user.id, name: user.name })
|
|
.from(user)
|
|
.where(eq(user.active, true));
|
|
const fehlende = usersAll.filter((u) => !presentToday.has(u.id));
|
|
|
|
return {
|
|
date: todayDate,
|
|
areas: areaSummaries,
|
|
blockers: openBlockers.map((b) => ({
|
|
id: b.id,
|
|
title: b.title,
|
|
openedAt: b.openedAt,
|
|
areaId: b.areaId,
|
|
projectId: b.projectId,
|
|
})),
|
|
heuteQuote: {
|
|
gepingt: presentToday.size,
|
|
gesamt: totalActiveUsers[0]?.c ?? 0,
|
|
fehlende,
|
|
},
|
|
jourfixe: { nextDate: nextJourfixe(todayDate), label: 'Di 11:00' },
|
|
pflichtMinimumOffen: allProjects
|
|
.filter((p) => Object.values(p.pflichtMinimumState).some((v) => v === false))
|
|
.map((p) => ({ id: p.id, name: p.name, areaId: p.areaId })),
|
|
feed: feedRows,
|
|
gestern: previousWerktag(todayDate),
|
|
};
|
|
}),
|
|
});
|
|
|
|
function nextJourfixe(todayIsoDate: string): string {
|
|
// Nächster Dienstag (Default Jourfixe). Wenn heute Dienstag, dann heute.
|
|
const d = new Date(`${todayIsoDate}T00:00:00Z`);
|
|
const wd = d.getUTCDay(); // 0=So..6=Sa
|
|
const offset = (2 - wd + 7) % 7 || 0; // 2=Di
|
|
d.setUTCDate(d.getUTCDate() + offset);
|
|
return d.toISOString().slice(0, 10);
|
|
}
|