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>
119 lines
4.2 KiB
TypeScript
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);
|
|
}
|