From 864a1cef6a6e0030c02b566bf3393e991d759205 Mon Sep 17 00:00:00 2001 From: DeeCee03 Date: Fri, 5 Jun 2026 17:31:30 +0200 Subject: [PATCH] feat(projekt): Projektmanagement-Tiefe nach Buch & Leitfaden v3.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- apps/api/src/routers/_app.ts | 8 + apps/api/src/routers/change.ts | 62 + apps/api/src/routers/dashboard.ts | 27 +- apps/api/src/routers/project.ts | 214 +- apps/api/src/routers/risk.ts | 65 + apps/api/src/routers/stakeholder.ts | 71 + apps/web/src/lib/project-meta.ts | 114 + apps/web/src/routes/bereich.tsx | 82 +- apps/web/src/routes/dashboard.tsx | 57 + apps/web/src/routes/projekt.tsx | 1632 ++++++++- packages/db/migrations/0009_pm_insights.sql | 40 + .../db/migrations/0010_leitfaden_phases.sql | 49 + .../db/migrations/meta/0009_snapshot.json | 2869 +++++++++++++++ .../db/migrations/meta/0010_snapshot.json | 3239 +++++++++++++++++ packages/db/migrations/meta/_journal.json | 21 + packages/db/src/schema.ts | 333 ++ packages/db/src/seed.ts | 219 +- packages/shared/src/index.ts | 6 +- packages/shared/src/schemas/change.ts | 24 + packages/shared/src/schemas/project.ts | 90 + packages/shared/src/schemas/risk.ts | 43 + packages/shared/src/schemas/stakeholder.ts | 32 + 22 files changed, 9237 insertions(+), 60 deletions(-) create mode 100644 apps/api/src/routers/change.ts create mode 100644 apps/api/src/routers/risk.ts create mode 100644 apps/api/src/routers/stakeholder.ts create mode 100644 apps/web/src/lib/project-meta.ts create mode 100644 packages/db/migrations/0009_pm_insights.sql create mode 100644 packages/db/migrations/0010_leitfaden_phases.sql create mode 100644 packages/db/migrations/meta/0009_snapshot.json create mode 100644 packages/db/migrations/meta/0010_snapshot.json create mode 100644 packages/shared/src/schemas/change.ts create mode 100644 packages/shared/src/schemas/risk.ts create mode 100644 packages/shared/src/schemas/stakeholder.ts diff --git a/apps/api/src/routers/_app.ts b/apps/api/src/routers/_app.ts index f664335..cfe567b 100644 --- a/apps/api/src/routers/_app.ts +++ b/apps/api/src/routers/_app.ts @@ -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, diff --git a/apps/api/src/routers/change.ts b/apps/api/src/routers/change.ts new file mode 100644 index 0000000..3ac83a2 --- /dev/null +++ b/apps/api/src/routers/change.ts @@ -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 }; + }), +}); diff --git a/apps/api/src/routers/dashboard.ts b/apps/api/src/routers/dashboard.ts index 9f779d9..1f3a47c 100644 --- a/apps/api/src/routers/dashboard.ts +++ b/apps/api/src/routers/dashboard.ts @@ -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(); @@ -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 })), diff --git a/apps/api/src/routers/project.ts b/apps/api/src/routers/project.ts index 7bb084a..dc7fb16 100644 --- a/apps/api/src/routers/project.ts +++ b/apps/api/src/routers/project.ts @@ -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() diff --git a/apps/api/src/routers/risk.ts b/apps/api/src/routers/risk.ts new file mode 100644 index 0000000..c64095a --- /dev/null +++ b/apps/api/src/routers/risk.ts @@ -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 }; + }), +}); diff --git a/apps/api/src/routers/stakeholder.ts b/apps/api/src/routers/stakeholder.ts new file mode 100644 index 0000000..fae159b --- /dev/null +++ b/apps/api/src/routers/stakeholder.ts @@ -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`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 }; + }), +}); diff --git a/apps/web/src/lib/project-meta.ts b/apps/web/src/lib/project-meta.ts new file mode 100644 index 0000000..ed65c6e --- /dev/null +++ b/apps/web/src/lib/project-meta.ts @@ -0,0 +1,114 @@ +// Anzeige-Metadaten für die PM-Insights-Features (Modus, Risiken, Stakeholder). + +export type ProjectModus = 'relax' | 'standard' | 'eskalation' | 'taskforce'; + +export const MODUS_META: Record = { + relax: { + label: 'Relax', + badge: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]', + hint: 'Ruhige Phase, kein Druck.', + }, + standard: { + label: 'Standard', + badge: 'bg-black/5 text-[var(--color-ink-muted)]', + hint: 'Normaler Projektbetrieb.', + }, + eskalation: { + label: 'Eskalation', + badge: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]', + hint: 'Erhöhte Aufmerksamkeit nötig.', + }, + taskforce: { + label: 'Taskforce', + badge: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]', + hint: 'Alle Kräfte gebündelt, höchste Priorität.', + }, +}; +export const MODI: ProjectModus[] = ['relax', 'standard', 'eskalation', 'taskforce']; + +export type Level = 'niedrig' | 'mittel' | 'hoch'; + +export const LEVEL_META: Record = { + niedrig: { label: 'Niedrig', badge: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]' }, + mittel: { label: 'Mittel', badge: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]' }, + hoch: { label: 'Hoch', badge: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]' }, +}; +export const LEVELS: Level[] = ['niedrig', 'mittel', 'hoch']; + +export type RiskStatus = 'offen' | 'beobachtet' | 'eingetreten' | 'geschlossen'; +export const RISK_STATUS_LABEL: Record = { + offen: 'Offen', + beobachtet: 'Beobachtet', + eingetreten: 'Eingetreten', + geschlossen: 'Geschlossen', +}; +export const RISK_STATUSES: RiskStatus[] = ['offen', 'beobachtet', 'eingetreten', 'geschlossen']; + +export type Haltung = 'pro' | 'neutral' | 'contra'; +export const HALTUNG_META: Record = { + pro: { label: 'Pro', dot: 'bg-[var(--color-ampel-green-fg)]' }, + neutral: { label: 'Neutral', dot: 'bg-[var(--color-ink-faint)]' }, + contra: { label: 'Contra', dot: 'bg-[var(--color-ampel-red-fg)]' }, +}; +export const HALTUNGEN: Haltung[] = ['pro', 'neutral', 'contra']; + +// Empfehlung je Quadrant der Macht/Interesse-Matrix (Einfluss × Interesse). +export function stakeholderStrategie(einfluss: Level, interesse: Level): string { + const hochEinfluss = einfluss === 'hoch'; + const hochInteresse = interesse === 'hoch'; + if (hochEinfluss && hochInteresse) return 'Eng managen'; + if (hochEinfluss && !hochInteresse) return 'Zufrieden halten'; + if (!hochEinfluss && hochInteresse) return 'Informiert halten'; + return 'Beobachten'; +} + +export type ProjectPhase = 'definition' | 'planung' | 'umsetzung' | 'abschluss'; +export const PHASES: ProjectPhase[] = ['definition', 'planung', 'umsetzung', 'abschluss']; +export const PHASE_META: Record = { + definition: { label: 'Definition', gateZiel: 'Steckbrief abgestimmt und freigegeben' }, + planung: { label: 'Planung', gateZiel: 'Plan steht — Backlog zerlegt, Meilensteine & Risiken erfasst' }, + umsetzung: { label: 'Umsetzung', gateZiel: 'Liefergegenstände abgenommen' }, + abschluss: { label: 'Abschluss', gateZiel: 'Projekt beendet, Erkenntnisse gesichert' }, +}; + +export type ZieldreieckFlex = 'leistung' | 'zeit' | 'aufwand'; +export const ZIELDREIECK_LABEL: Record = { + leistung: 'Leistung / Umfang', + zeit: 'Zeit', + aufwand: 'Aufwand', +}; +export const ZIELDREIECK_FLEX: ZieldreieckFlex[] = ['leistung', 'zeit', 'aufwand']; + +export type RiskStrategie = 'vermeiden' | 'verlagern' | 'mindern' | 'akzeptieren'; +export const RISK_STRATEGIE_LABEL: Record = { + vermeiden: 'Vermeiden', + verlagern: 'Verlagern', + mindern: 'Mindern', + akzeptieren: 'Akzeptieren', +}; +export const RISK_STRATEGIEN: RiskStrategie[] = ['vermeiden', 'verlagern', 'mindern', 'akzeptieren']; + +export type ChangeEntscheidung = 'offen' | 'genehmigt' | 'abgelehnt'; +export const CHANGE_ENTSCHEIDUNG_META: Record = { + offen: { label: 'Offen', badge: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]' }, + genehmigt: { label: 'Genehmigt', badge: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]' }, + abgelehnt: { label: 'Abgelehnt', badge: 'bg-black/5 text-[var(--color-ink-faint)]' }, +}; +export const CHANGE_ENTSCHEIDUNGEN: ChangeEntscheidung[] = ['offen', 'genehmigt', 'abgelehnt']; + +// Datums-Warnungen nach Buch-Regeln (keine Meilensteine Freitag / vor Feiertagen). +export function dateWarnings(iso: string | null | undefined): string[] { + if (!iso) return []; + const d = new Date(`${iso}T00:00:00`); + if (Number.isNaN(d.getTime())) return []; + const out: string[] = []; + const wd = d.getDay(); // 0=So .. 6=Sa + if (wd === 5) out.push('Fällt auf einen Freitag — Buch-Regel: keine Meilensteine/Abgaben freitags.'); + if (wd === 0 || wd === 6) out.push('Fällt aufs Wochenende.'); + const month = d.getMonth(); + const day = d.getDate(); + if ((month === 11 && day >= 20) || (month === 0 && day <= 6)) { + out.push('Liegt in der Weihnachts-/Neujahrszeit — besser einige Wochen davor oder danach.'); + } + return out; +} diff --git a/apps/web/src/routes/bereich.tsx b/apps/web/src/routes/bereich.tsx index 46fe89f..a4780a8 100644 --- a/apps/web/src/routes/bereich.tsx +++ b/apps/web/src/routes/bereich.tsx @@ -7,6 +7,8 @@ import { Button } from '../components/ui/Button'; import { SkeletonCard } from '../components/ui/SkeletonCard'; import { toast } from 'sonner'; import { clsx } from 'clsx'; +import { LEAD_STATUS_META, type LeadStatus } from '../lib/lead-meta'; +import { MODUS_META, type ProjectModus } from '../lib/project-meta'; const BEREICHE: Array<{ slug: 'entwicklung' | 'vertrieb' | 'marketing'; label: string }> = [ { slug: 'entwicklung', label: 'Entwicklung' }, @@ -85,6 +87,7 @@ function Section({ name: string; statusCache: 'green' | 'amber' | 'red' | null; deadline: string | null; + modus: ProjectModus; pflichtMinimumState: Record; }>; }) { @@ -104,7 +107,19 @@ function Section({
-

{p.name}

+

+ {p.name} + {p.modus !== 'standard' && ( + + {MODUS_META[p.modus].label} + + )} +

{p.deadline ? `bis ${p.deadline}` : 'kein Deadline'}

@@ -297,7 +312,70 @@ function Field({ label, children }: { label: string; children: React.ReactNode } } function VertriebDetail() { - return ; + return ( +
+ + +
+ ); +} + +function LeadsTeaser() { + const q = trpc.lead.list.useQuery(); + const leads = q.data ?? []; + const offen = leads.filter((l) => l.status !== 'won' && l.status !== 'lost'); + const top = [...offen] + .sort((a, b) => b.score - a.score) + .slice(0, 3); + return ( +
+
+

Leads

+ + Alle Leads ({leads.length}) → + +
+ + + {q.isPending ? ( +

Lädt…

+ ) : offen.length === 0 ? ( +

+ Keine offenen Leads.{' '} + + Lege einen an. + +

+ ) : ( +
    + {top.map((l) => { + const meta = LEAD_STATUS_META[l.status as LeadStatus]; + return ( +
  • + + + + {l.firma} + {l.subSegment && ( + {l.subSegment} + )} + + + {l.score}/10 + + +
  • + ); + })} +
+ )} +
+
+
+ ); } function MarketingDetail() { diff --git a/apps/web/src/routes/dashboard.tsx b/apps/web/src/routes/dashboard.tsx index 3461c89..ff0af7e 100644 --- a/apps/web/src/routes/dashboard.tsx +++ b/apps/web/src/routes/dashboard.tsx @@ -5,6 +5,8 @@ import { Card } from '../components/ui/Card'; import { Button } from '../components/ui/Button'; import { SkeletonCard } from '../components/ui/SkeletonCard'; import { TeamTimeSection } from '../components/time/TeamTimeSection'; +import { MODUS_META, type ProjectModus } from '../lib/project-meta'; +import { AlertTriangle } from 'lucide-react'; import { clsx } from 'clsx'; type Ampel = 'green' | 'amber' | 'red' | 'grey'; @@ -72,6 +74,61 @@ export function DashboardRoute() {
+ {q.data.showstoppers.length > 0 && ( +
+ + +

+ Showstopper +

+
+ +
    + {q.data.showstoppers.map((s) => ( +
  • + {s.title} + + {s.projectName} → + +
  • + ))} +
+
+
+
+ )} + + {q.data.kritischeModi.length > 0 && ( +
+

Projekte in Eskalation / Taskforce

+
+ {q.data.kritischeModi.map((p) => ( + + + {MODUS_META[p.modus as ProjectModus].label} + + {p.name} + + ))} +
+
+ )} +
diff --git a/apps/web/src/routes/projekt.tsx b/apps/web/src/routes/projekt.tsx index 14ec229..7160a9f 100644 --- a/apps/web/src/routes/projekt.tsx +++ b/apps/web/src/routes/projekt.tsx @@ -1,6 +1,8 @@ import { useState } from 'react'; import { useParams, Link, useNavigate } from 'react-router'; -import { ArrowLeft } from 'lucide-react'; +import { ArrowLeft, Pencil, Plus, Trash2, AlertTriangle, Check } from 'lucide-react'; +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 { Button } from '../components/ui/Button'; @@ -9,6 +11,50 @@ import { useCurrentUser } from '../hooks/useCurrentUser'; import { voice } from '../lib/voice'; import { toast } from 'sonner'; import { clsx } from 'clsx'; +import { + MODUS_META, + MODI, + LEVEL_META, + LEVELS, + RISK_STATUS_LABEL, + RISK_STATUSES, + RISK_STRATEGIE_LABEL, + RISK_STRATEGIEN, + HALTUNG_META, + HALTUNGEN, + PHASE_META, + PHASES, + ZIELDREIECK_LABEL, + ZIELDREIECK_FLEX, + CHANGE_ENTSCHEIDUNG_META, + CHANGE_ENTSCHEIDUNGEN, + stakeholderStrategie, + dateWarnings, + type ProjectModus, + type Level, + type RiskStatus, + type RiskStrategie, + type Haltung, + type ProjectPhase, + type ZieldreieckFlex, + type ChangeEntscheidung, +} from '../lib/project-meta'; + +type Risk = inferRouterOutputs['risk']['forProject'][number]; +type Stakeholder = inferRouterOutputs['stakeholder']['forProject'][number]; +type Milestone = inferRouterOutputs['project']['byId']['milestones'][number]; +type ChangeItem = inferRouterOutputs['change']['forProject'][number]; +type ProjectDetail = inferRouterOutputs['project']['byId']['project']; + +const inputCls = + 'w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-white px-3 py-2 text-sm'; + +function countSentences(text: string): number { + return text + .split(/[.!?]+/) + .map((s) => s.trim()) + .filter(Boolean).length; +} export function ProjektRoute() { const { id = '' } = useParams<{ id: string }>(); @@ -24,13 +70,12 @@ export function ProjektRoute() { { key: 'steckbrief_freigegeben', label: 'Steckbrief in Outline freigegeben' }, { key: 'verantwortlicher', label: 'Projektverantwortliche/r gesetzt' }, { key: 'plane_board', label: 'Plane-Board verlinkt' }, - { key: 'meilensteine', label: 'Meilensteine vorhanden' }, + { key: 'meilensteine', label: 'Meilensteine vorhanden (≥ 2)' }, { key: 'status_aktuell', label: 'Status-Ampel aktuell (≤ 7 Werktage)' }, { key: 'retrospektive', label: 'Abschluss-Retrospektive (bei beendeten Projekten)' }, ]; function goBack() { - // Wenn der History-Stack einen Vorgänger hat, dorthin; sonst auf den Bereich. if (window.history.length > 1) navigate(-1); else navigate(`/bereich/${a.slug}`); } @@ -72,17 +117,25 @@ export function ProjektRoute() { {p.kind === 'kundenprojekt' ? 'Kundenprojekt' : 'Internes Projekt'} ·{' '} {p.arbeitsmodus ?? '—'} · {p.deadline ? `Deadline ${p.deadline}` : 'kein Deadline'}

- {p.description && ( -

- {p.description} -

- )} +
+ +
{abschlussOpen && ( - setAbschlussOpen(false)} /> + setAbschlussOpen(false)} + /> )} + + + + + +
@@ -121,37 +174,13 @@ export function ProjektRoute() {
-
-

Meilensteine

- {milestones.length === 0 ? ( - - -

Noch keine Meilensteine.

-
-
- ) : ( - - -
    - {milestones.map((m) => ( -
  • - - {m.title} - - - {m.due ?? '—'} · {m.status} - -
  • - ))} -
-
-
- )} -
+ + + + + + + {p.dodText && ( @@ -163,12 +192,1479 @@ export function ProjektRoute() { )} + + {p.abschlussvermerk && ( + + +

Abschlussvermerk

+
+ +

{p.abschlussvermerk}

+
+
+ )} ); } -function AbschlussDialog({ projectId, onClose }: { projectId: string; onClose: () => void }) { +// --- Modus (Buch Kap. 4: jederzeit ans Team kommuniziert) ------------------- +function ModusControl({ projectId, modus }: { projectId: string; modus: ProjectModus }) { + const utils = trpc.useUtils(); + const set = trpc.project.setModus.useMutation({ + onSuccess: async () => { + toast(voice.toast.saved); + await utils.project.byId.invalidate({ id: projectId }); + await utils.dashboard.overview.invalidate(); + }, + }); + const meta = MODUS_META[modus]; + return ( + + ); +} + +// --- Ziel & Maxime + SMART-Ziel, Nicht-Ziele, Liefergegenstände, Zieldreieck - +function ZielSection({ projectId, project: p }: { projectId: string; project: ProjectDetail }) { + const utils = trpc.useUtils(); + const [editing, setEditing] = useState(false); + const [maxime, setMaxime] = useState(p.maxime ?? ''); + const [ziel, setZiel] = useState(p.ziel ?? ''); + const [desc, setDesc] = useState(p.description ?? ''); + const [nichtZiele, setNichtZiele] = useState(p.nichtZiele ?? ''); + const [liefer, setLiefer] = useState(p.liefergegenstaende ?? ''); + const [flex, setFlex] = useState((p.zieldreieckFlex as ZieldreieckFlex) ?? ''); + const update = trpc.project.update.useMutation({ + onSuccess: async () => { + toast(voice.toast.saved); + await utils.project.byId.invalidate({ id: projectId }); + setEditing(false); + }, + }); + + const sentences = countSentences(ziel || desc); + const zielHint = + (ziel || desc).trim() === '' + ? 'Noch kein Ziel. Leitfaden: SMART formulieren — spezifisch, messbar, akzeptiert, realistisch, terminiert.' + : sentences > 4 + ? `${sentences} Sätze — kürzer ist schärfer (3-4 Sätze reichen).` + : null; + + function startEdit() { + setMaxime(p.maxime ?? ''); + setZiel(p.ziel ?? ''); + setDesc(p.description ?? ''); + setNichtZiele(p.nichtZiele ?? ''); + setLiefer(p.liefergegenstaende ?? ''); + setFlex((p.zieldreieckFlex as ZieldreieckFlex) ?? ''); + setEditing(true); + } + function save() { + update.mutate({ + projectId, + maxime: maxime.trim() || null, + ziel: ziel.trim() || null, + description: desc.trim() || null, + nichtZiele: nichtZiele.trim() || null, + liefergegenstaende: liefer.trim() || null, + zieldreieckFlex: flex === '' ? null : flex, + }); + } + + return ( + + +

Ziel & Definition

+ {!editing && ( + + )} +
+ + {editing ? ( +
+ +