feat(projekt): Projektmanagement-Tiefe nach Buch & Leitfaden v3.0
ci / quality (push) Failing after 1m0s
ci / build (push) Has been skipped

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>
This commit is contained in:
dejanandClaude Opus 4.8 committed 2026-06-05 17:31:30 +02:00
1 parent d72aa9fbf3
commit 864a1cef6a
22 files changed
+9237 -60

No files matched your search

+8
View File
@@ -6,6 +6,10 @@ import { userRouter } from './user.js';
import { dashboardRouter } from './dashboard.js';
import { projectRouter } from './project.js';
import { dealRouter, gtmRouter } from './deal.js';
import { leadRouter } from './lead.js';
import { riskRouter } from './risk.js';
import { stakeholderRouter } from './stakeholder.js';
import { changeRouter } from './change.js';
import { campaignRouter } from './campaign.js';
import { areaRouter } from './area.js';
import { blockerRouter } from './blocker.js';
@@ -21,6 +25,10 @@ export const appRouter = router({
project: projectRouter,
deal: dealRouter,
gtm: gtmRouter,
lead: leadRouter,
risk: riskRouter,
stakeholder: stakeholderRouter,
change: changeRouter,
campaign: campaignRouter,
blocker: blockerRouter,
dayEntry: dayEntryRouter,
+62
View File
@@ -0,0 +1,62 @@
import { TRPCError } from '@trpc/server';
import { desc, eq } from 'drizzle-orm';
import { changeEntry, user } from '@cockpit/db';
import { ChangeSchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
export const changeRouter = router({
forProject: protectedProcedure
.input(ChangeSchemas.changeForProjectInput)
.query(async ({ ctx, input }) => {
const rows = await ctx.db
.select({ change: changeEntry, authorName: user.name })
.from(changeEntry)
.leftJoin(user, eq(user.id, changeEntry.createdBy))
.where(eq(changeEntry.projectId, input.projectId))
.orderBy(desc(changeEntry.createdAt));
return rows.map((r) => ({ ...r.change, authorName: r.authorName }));
}),
create: protectedProcedure
.input(ChangeSchemas.createChangeInput)
.mutation(async ({ ctx, input }) => {
const [created] = await ctx.db
.insert(changeEntry)
.values({
projectId: input.projectId,
titel: input.titel,
auswirkung: input.auswirkung ?? null,
entscheidung: input.entscheidung,
createdBy: ctx.user.id,
})
.returning();
return created;
}),
update: protectedProcedure
.input(ChangeSchemas.updateChangeInput)
.mutation(async ({ ctx, input }) => {
const { id, ...patch } = input;
if (Object.keys(patch).length === 0) {
const existing = (
await ctx.db.select().from(changeEntry).where(eq(changeEntry.id, id)).limit(1)
)[0];
if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' });
return existing;
}
const [updated] = await ctx.db
.update(changeEntry)
.set(patch)
.where(eq(changeEntry.id, id))
.returning();
if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' });
return updated;
}),
remove: protectedProcedure
.input(ChangeSchemas.removeChangeInput)
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(changeEntry).where(eq(changeEntry.id, input.id));
return { ok: true as const };
}),
});
+24 -3
View File
@@ -1,5 +1,5 @@
import { and, eq, isNotNull, isNull, count } from 'drizzle-orm';
import { area, project, blocker, dayEntry, user, type Area, type Project } from '@cockpit/db';
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';
@@ -7,7 +7,7 @@ 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] =
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)),
@@ -33,6 +33,23 @@ export const dashboardRouter = router({
.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[]>();
@@ -78,6 +95,10 @@ export const dashboardRouter = router({
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 })),
+213 -1
View File
@@ -1,10 +1,16 @@
import { TRPCError } from '@trpc/server';
import { z } from 'zod';
import { and, eq, desc } from 'drizzle-orm';
import { project, milestone, area, user, type Project } from '@cockpit/db';
import { project, milestone, area, user, projectStatus, phaseGate, type Project } from '@cockpit/db';
import { ProjectSchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
const PHASE_ORDER = ['definition', 'planung', 'umsetzung', 'abschluss'] as const;
function nextPhase(p: (typeof PHASE_ORDER)[number]): (typeof PHASE_ORDER)[number] {
const i = PHASE_ORDER.indexOf(p);
return PHASE_ORDER[Math.min(i + 1, PHASE_ORDER.length - 1)]!;
}
const byAreaInput = z.object({
slug: z.string().min(1),
includeArchived: z.boolean().optional(),
@@ -120,7 +126,9 @@ export const projectRouter = router({
.set({
archiviert: true,
archiviertAm: new Date(),
phase: 'abschluss',
lessonsLearnedUrl: input.lessonsLearnedUrl,
abschlussvermerk: input.abschlussvermerk ?? p.abschlussvermerk,
pflichtMinimumState: { ...p.pflichtMinimumState, retrospektive: true },
})
.where(eq(project.id, input.projectId))
@@ -128,6 +136,95 @@ export const projectRouter = router({
return updated as Project;
}),
// Phase direkt setzen (Leitfaden Kap. 2).
setPhase: protectedProcedure
.input(ProjectSchemas.setPhaseInput)
.mutation(async ({ ctx, input }) => {
const [updated] = await ctx.db
.update(project)
.set({ phase: input.phase, updatedAt: new Date() })
.where(eq(project.id, input.projectId))
.returning();
if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' });
return updated;
}),
// Gate-Review am Phasenende (Leitfaden Kap. 5), optional Folgephase freigeben.
addPhaseGate: protectedProcedure
.input(ProjectSchemas.addPhaseGateInput)
.mutation(async ({ ctx, input }) => {
const [gate] = await ctx.db
.insert(phaseGate)
.values({
projectId: input.projectId,
phase: input.phase,
ergebnisErreicht: input.ergebnisErreicht,
notiz: input.notiz ?? null,
reviewedBy: ctx.user.id,
})
.returning();
if (input.advance) {
await ctx.db
.update(project)
.set({ phase: nextPhase(input.phase), updatedAt: new Date() })
.where(eq(project.id, input.projectId));
}
return gate;
}),
phaseGates: protectedProcedure
.input(ProjectSchemas.phaseGatesInput)
.query(async ({ ctx, input }) => {
return ctx.db
.select()
.from(phaseGate)
.where(eq(phaseGate.projectId, input.projectId))
.orderBy(desc(phaseGate.reviewedAt));
}),
// Statusbericht erfassen (Leitfaden Kap. 5) — speist statusCache + Pflicht-Minimum.
addStatus: protectedProcedure
.input(ProjectSchemas.addProjectStatusInput)
.mutation(async ({ ctx, input }) => {
const p = (
await ctx.db.select().from(project).where(eq(project.id, input.projectId)).limit(1)
)[0];
if (!p) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' });
const [created] = await ctx.db
.insert(projectStatus)
.values({
projectId: input.projectId,
ampel: input.ampel,
trend: input.trend ?? null,
text: input.text ?? null,
externSichtbar: input.externSichtbar,
createdBy: ctx.user.id,
})
.returning();
await ctx.db
.update(project)
.set({
statusCache: input.ampel,
trendCache: input.trend ?? null,
pflichtMinimumState: { ...p.pflichtMinimumState, status_aktuell: true },
updatedAt: new Date(),
})
.where(eq(project.id, input.projectId));
return created;
}),
statusHistory: protectedProcedure
.input(ProjectSchemas.projectStatusHistoryInput)
.query(async ({ ctx, input }) => {
const rows = await ctx.db
.select({ status: projectStatus, authorName: user.name })
.from(projectStatus)
.leftJoin(user, eq(user.id, projectStatus.createdBy))
.where(eq(projectStatus.projectId, input.projectId))
.orderBy(desc(projectStatus.createdAt));
return rows.map((r) => ({ ...r.status, authorName: r.authorName }));
}),
setMilestoneStatus: protectedProcedure
.input(ProjectSchemas.setMilestoneStatusInput)
.mutation(async ({ ctx, input }) => {
@@ -139,6 +236,121 @@ export const projectRouter = router({
return updated;
}),
// Projekt-Modus setzen (Buch Kap. 4): muss jederzeit ans Team kommuniziert sein.
setModus: protectedProcedure
.input(ProjectSchemas.setModusInput)
.mutation(async ({ ctx, input }) => {
const [updated] = await ctx.db
.update(project)
.set({ modus: input.modus, updatedAt: new Date() })
.where(eq(project.id, input.projectId))
.returning();
if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' });
return updated;
}),
// Maxime / Beschreibung / DoD bearbeiten (Ziel-Disziplin).
update: protectedProcedure
.input(ProjectSchemas.updateProjectInput)
.mutation(async ({ ctx, input }) => {
const { projectId, ...patch } = input;
if (Object.keys(patch).length === 0) {
const existing = (
await ctx.db.select().from(project).where(eq(project.id, projectId)).limit(1)
)[0];
if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' });
return existing;
}
const [updated] = await ctx.db
.update(project)
.set({ ...patch, updatedAt: new Date() })
.where(eq(project.id, projectId))
.returning();
if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Projekt nicht gefunden.' });
return updated;
}),
addMilestone: protectedProcedure
.input(ProjectSchemas.addMilestoneInput)
.mutation(async ({ ctx, input }) => {
const existing = await ctx.db
.select({ id: milestone.id })
.from(milestone)
.where(eq(milestone.projectId, input.projectId));
const [created] = await ctx.db
.insert(milestone)
.values({
projectId: input.projectId,
title: input.title,
due: input.due ?? null,
order: existing.length,
})
.returning();
// Pflicht-Minimum-Punkt „Meilensteine" nachziehen (Buch: mind. 2).
const p = (
await ctx.db.select().from(project).where(eq(project.id, input.projectId)).limit(1)
)[0];
if (p) {
await ctx.db
.update(project)
.set({
pflichtMinimumState: {
...p.pflichtMinimumState,
meilensteine: existing.length + 1 >= 2,
},
})
.where(eq(project.id, input.projectId));
}
return created;
}),
updateMilestone: protectedProcedure
.input(ProjectSchemas.updateMilestoneInput)
.mutation(async ({ ctx, input }) => {
const { milestoneId, ...patch } = input;
if (Object.keys(patch).length === 0) {
const existing = (
await ctx.db.select().from(milestone).where(eq(milestone.id, milestoneId)).limit(1)
)[0];
if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Meilenstein nicht gefunden.' });
return existing;
}
const [updated] = await ctx.db
.update(milestone)
.set(patch)
.where(eq(milestone.id, milestoneId))
.returning();
if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Meilenstein nicht gefunden.' });
return updated;
}),
removeMilestone: protectedProcedure
.input(ProjectSchemas.removeMilestoneInput)
.mutation(async ({ ctx, input }) => {
const [removed] = await ctx.db
.delete(milestone)
.where(eq(milestone.id, input.milestoneId))
.returning({ projectId: milestone.projectId });
if (removed) {
const rest = await ctx.db
.select({ id: milestone.id })
.from(milestone)
.where(eq(milestone.projectId, removed.projectId));
const p = (
await ctx.db.select().from(project).where(eq(project.id, removed.projectId)).limit(1)
)[0];
if (p) {
await ctx.db
.update(project)
.set({
pflichtMinimumState: { ...p.pflichtMinimumState, meilensteine: rest.length >= 2 },
})
.where(eq(project.id, removed.projectId));
}
}
return { ok: true as const };
}),
listArchived: protectedProcedure.query(async ({ ctx }) => {
return await ctx.db
.select()
+65
View File
@@ -0,0 +1,65 @@
import { TRPCError } from '@trpc/server';
import { desc, eq } from 'drizzle-orm';
import { risk } from '@cockpit/db';
import { RiskSchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
export const riskRouter = router({
forProject: protectedProcedure
.input(RiskSchemas.riskForProjectInput)
.query(async ({ ctx, input }) => {
return ctx.db
.select()
.from(risk)
.where(eq(risk.projectId, input.projectId))
.orderBy(desc(risk.showstopper), desc(risk.createdAt));
}),
create: protectedProcedure
.input(RiskSchemas.createRiskInput)
.mutation(async ({ ctx, input }) => {
const [created] = await ctx.db
.insert(risk)
.values({
projectId: input.projectId,
kind: input.kind,
title: input.title,
wahrscheinlichkeit: input.wahrscheinlichkeit,
auswirkung: input.auswirkung,
herkunft: input.herkunft ?? null,
showstopper: input.showstopper,
massnahme: input.massnahme ?? null,
strategie: input.strategie ?? null,
ownerUserId: input.ownerUserId ?? null,
status: input.status,
createdBy: ctx.user.id,
})
.returning();
return created;
}),
update: protectedProcedure
.input(RiskSchemas.updateRiskInput)
.mutation(async ({ ctx, input }) => {
const { id, ...patch } = input;
if (Object.keys(patch).length === 0) {
const existing = (await ctx.db.select().from(risk).where(eq(risk.id, id)).limit(1))[0];
if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' });
return existing;
}
const [updated] = await ctx.db
.update(risk)
.set({ ...patch, updatedAt: new Date() })
.where(eq(risk.id, id))
.returning();
if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Eintrag nicht gefunden.' });
return updated;
}),
remove: protectedProcedure
.input(RiskSchemas.removeRiskInput)
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(risk).where(eq(risk.id, input.id));
return { ok: true as const };
}),
});
+71
View File
@@ -0,0 +1,71 @@
import { TRPCError } from '@trpc/server';
import { asc, eq, sql } from 'drizzle-orm';
import { stakeholder } from '@cockpit/db';
import { StakeholderSchemas } from '@cockpit/shared';
import { router, protectedProcedure } from '../trpc.js';
export const stakeholderRouter = router({
forProject: protectedProcedure
.input(StakeholderSchemas.stakeholderForProjectInput)
.query(async ({ ctx, input }) => {
return ctx.db
.select()
.from(stakeholder)
.where(eq(stakeholder.projectId, input.projectId))
.orderBy(asc(stakeholder.order), asc(stakeholder.createdAt));
}),
create: protectedProcedure
.input(StakeholderSchemas.createStakeholderInput)
.mutation(async ({ ctx, input }) => {
const count =
(
await ctx.db
.select({ c: sql<number>`count(*)::int` })
.from(stakeholder)
.where(eq(stakeholder.projectId, input.projectId))
)[0]?.c ?? 0;
const [created] = await ctx.db
.insert(stakeholder)
.values({
projectId: input.projectId,
name: input.name,
rolle: input.rolle ?? null,
interesse: input.interesse,
einfluss: input.einfluss,
haltung: input.haltung,
beduerfnis: input.beduerfnis ?? null,
notiz: input.notiz ?? null,
order: count,
})
.returning();
return created;
}),
update: protectedProcedure
.input(StakeholderSchemas.updateStakeholderInput)
.mutation(async ({ ctx, input }) => {
const { id, ...patch } = input;
if (Object.keys(patch).length === 0) {
const existing = (
await ctx.db.select().from(stakeholder).where(eq(stakeholder.id, id)).limit(1)
)[0];
if (!existing) throw new TRPCError({ code: 'NOT_FOUND', message: 'Stakeholder nicht gefunden.' });
return existing;
}
const [updated] = await ctx.db
.update(stakeholder)
.set(patch)
.where(eq(stakeholder.id, id))
.returning();
if (!updated) throw new TRPCError({ code: 'NOT_FOUND', message: 'Stakeholder nicht gefunden.' });
return updated;
}),
remove: protectedProcedure
.input(StakeholderSchemas.removeStakeholderInput)
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(stakeholder).where(eq(stakeholder.id, input.id));
return { ok: true as const };
}),
});