Files
everythingbutwork/apps/api/src/routers/dashboard.ts
T
dejanandClaude Opus 4.8 864a1cef6a
ci / quality (push) Failing after 1m0s
ci / build (push) Has been skipped
feat(projekt): Projektmanagement-Tiefe nach Buch & Leitfaden v3.0
Operationalisiert den ToolCTRL-Projektmanagement-Leitfaden auf der
Projekt-Detailseite (hybrid: Cockpit-Primitive nativ, Schwergewichtiges
per Outline-Link):

- Projekt-Modus (Relax/Standard/Eskalation/Taskforce) + Dashboard-Surfacing
- Risiko- & Chancen-Register (Wahrscheinlichkeit×Auswirkung, Showstopper,
  Owner, Strategie) — offene Showstopper landen auf dem Dashboard
- Stakeholder-Analyse mit Macht/Interesse-Matrix
- Ziel & Definition: SMART-Ziel, Nicht-Ziele, Liefergegenstaende, Maxime,
  Zieldreieck (welche Ecke gibt nach)
- Meilensteine editierbar inkl. Ergebnis/Kundennutzen, Freitags-/
  Feiertags-Warnung, Mindest-2-Hinweis
- Phasen-Lebenszyklus (Definition→Planung→Umsetzung→Abschluss) mit Gate-Reviews
- Projekt-Statusbericht (Ampel/Trend/Text, extern-Flag, Verlauf) — speist
  statusCache & Pflicht-Minimum
- Änderungs-Log (Scope-Creep-Schutz), strukturierter Abschluss mit
  Checkliste + Abschlussvermerk

Migrationen 0009_pm_insights + 0010_leitfaden_phases. Enthält auch die
gemeinsame schema.ts (inkl. Lead-Tabellen aus dem Vor-Commit) und Seed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 17:31:30 +02:00

119 lines
4.2 KiB
TypeScript

import { and, eq, ne, isNotNull, isNull, count } from 'drizzle-orm';
import { area, project, blocker, dayEntry, user, risk, 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, showstopperRows] =
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),
ctx.db
.select({
id: risk.id,
title: risk.title,
projectId: risk.projectId,
projectName: project.name,
})
.from(risk)
.innerJoin(project, eq(project.id, risk.projectId))
.where(
and(
eq(risk.showstopper, true),
eq(risk.kind, 'risiko'),
ne(risk.status, 'geschlossen'),
eq(project.archiviert, false),
),
),
]);
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' },
showstoppers: showstopperRows,
kritischeModi: allProjects
.filter((p) => p.modus === 'eskalation' || p.modus === 'taskforce')
.map((p) => ({ id: p.id, name: p.name, areaId: p.areaId, modus: p.modus })),
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);
}