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 ? (
+
+
+
+ {zielHint && (
+
+
+ {zielHint}
+
+ )}
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
+
+function ZielRow({ label, value, italic }: { label: string; value: string | null; italic?: boolean }) {
+ return (
+
+
{label}
+ {value ? (
+
+ {italic ? `„${value}“` : value}
+
+ ) : (
+
—
+ )}
+
+ );
+}
+
+function Zieldreieck({ flex }: { flex: ZieldreieckFlex | null }) {
+ const corners: ZieldreieckFlex[] = ['leistung', 'zeit', 'aufwand'];
+ return (
+
+
Zieldreieck
+
+ {corners.map((c) => {
+ const gibtNach = flex === c;
+ return (
+
+ {ZIELDREIECK_LABEL[c]}
+ {gibtNach && ' · gibt nach'}
+
+ );
+ })}
+ {!flex && Priorität nicht festgelegt}
+
+
+ );
+}
+
+// --- Phasen-Stepper + Gate-Review -------------------------------------------
+function PhaseSection({ projectId, phase }: { projectId: string; phase: ProjectPhase }) {
+ const utils = trpc.useUtils();
+ const gatesQ = trpc.project.phaseGates.useQuery({ projectId });
+ const [gateOpen, setGateOpen] = useState(false);
+ const setPhase = trpc.project.setPhase.useMutation({
+ onSuccess: async () => {
+ await utils.project.byId.invalidate({ id: projectId });
+ await utils.dashboard.overview.invalidate();
+ },
+ });
+ const currentIdx = PHASES.indexOf(phase);
+ const gates = gatesQ.data ?? [];
+
+ return (
+
+
+ Phase
+ {phase !== 'abschluss' && (
+
+ )}
+
+
+
+ {PHASES.map((ph, i) => {
+ const done = i < currentIdx;
+ const current = i === currentIdx;
+ return (
+ -
+
+
+ );
+ })}
+
+ Gate-Ziel: {PHASE_META[phase].gateZiel}
+ {gates.length > 0 && (
+
+
+ {gates.length} Gate-Review{gates.length === 1 ? '' : 's'}
+
+
+ {gates.map((g) => (
+ -
+
+ {g.ergebnisErreicht ? '✓' : '✗'}
+
+
+ {PHASE_META[g.phase as ProjectPhase].label}{' '}
+
+ {new Date(g.reviewedAt).toISOString().slice(0, 10)}
+
+ {g.notiz && {g.notiz}}
+
+
+ ))}
+
+
+ )}
+
+ {gateOpen && setGateOpen(false)} />}
+
+ );
+}
+
+function GateDialog({
+ projectId,
+ phase,
+ onClose,
+}: {
+ projectId: string;
+ phase: ProjectPhase;
+ onClose: () => void;
+}) {
+ const utils = trpc.useUtils();
+ const [erreicht, setErreicht] = useState(true);
+ const [notiz, setNotiz] = useState('');
+ const [advance, setAdvance] = useState(true);
+ const add = trpc.project.addPhaseGate.useMutation({
+ onSuccess: async () => {
+ toast(voice.toast.saved);
+ await utils.project.byId.invalidate({ id: projectId });
+ await utils.project.phaseGates.invalidate({ projectId });
+ await utils.dashboard.overview.invalidate();
+ onClose();
+ },
+ });
+ return (
+
+
e.stopPropagation()}>
+
Gate-Review: {PHASE_META[phase].label}
+
Ziel: {PHASE_META[phase].gateZiel}
+
+
+
+ );
+}
+
+// --- Projekt-Statusbericht (Ampel + Trend + 2-3 Sätze) ----------------------
+function StatusSection({
+ projectId,
+ ampel,
+ trend,
+}: {
+ projectId: string;
+ ampel: 'green' | 'amber' | 'red' | null;
+ trend: 'besser' | 'gleich' | 'schlechter' | null;
+}) {
+ const utils = trpc.useUtils();
+ const histQ = trpc.project.statusHistory.useQuery({ projectId });
+ const [open, setOpen] = useState(false);
+ const [neuAmpel, setNeuAmpel] = useState<'green' | 'amber' | 'red'>(ampel ?? 'green');
+ const [neuTrend, setNeuTrend] = useState<'besser' | 'gleich' | 'schlechter' | ''>(trend ?? '');
+ const [text, setText] = useState('');
+ const [extern, setExtern] = useState(false);
+ const add = trpc.project.addStatus.useMutation({
+ onSuccess: async () => {
+ toast(voice.toast.saved);
+ await utils.project.byId.invalidate({ id: projectId });
+ await utils.project.statusHistory.invalidate({ projectId });
+ await utils.dashboard.overview.invalidate();
+ setText('');
+ setOpen(false);
+ },
+ });
+ const history = histQ.data ?? [];
+ const latest = history[0];
+ const ampelLabel = { green: 'Im Plan', amber: 'Gefährdet', red: 'Blockiert' };
+ const ampelCls = {
+ green: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]',
+ amber: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]',
+ red: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]',
+ };
+
+ return (
+
+
+ Status
+ {!open && (
+
+ )}
+
+
+ {open && (
+
+ )}
+
+ {!latest ? (
+ Noch kein Status gesetzt.
+ ) : (
+
+
+
+ {ampelLabel[latest.ampel]}
+
+ {latest.trend && Trend: {latest.trend}}
+ {latest.externSichtbar && (
+ extern
+ )}
+
+ {new Date(latest.createdAt).toISOString().slice(0, 10)}
+ {latest.authorName ? ` · ${latest.authorName}` : ''}
+
+
+ {latest.text &&
{latest.text}
}
+
+ )}
+
+ {history.length > 1 && (
+
+
+ Verlauf ({history.length - 1} ältere)
+
+
+ {history.slice(1).map((s) => (
+ -
+
+
+
+ {new Date(s.createdAt).toISOString().slice(0, 10)}
+
+ {s.text && {s.text}}
+
+
+ ))}
+
+
+ )}
+
+
+ );
+}
+
+// --- Änderungs-Log (Scope-Creep) --------------------------------------------
+function ChangeLogSection({ projectId }: { projectId: string }) {
+ const utils = trpc.useUtils();
+ const q = trpc.change.forProject.useQuery({ projectId });
+ const [adding, setAdding] = useState(false);
+ const [titel, setTitel] = useState('');
+ const [auswirkung, setAuswirkung] = useState('');
+ const add = trpc.change.create.useMutation({
+ onSuccess: async () => {
+ await utils.change.forProject.invalidate({ projectId });
+ setTitel('');
+ setAuswirkung('');
+ setAdding(false);
+ },
+ });
+ const list = q.data ?? [];
+ return (
+
+
+ Änderungs-Log
+ {!adding && (
+
+ )}
+
+
+ {adding && (
+
+ )}
+ {list.length === 0 && !adding ? (
+
+ Keine Änderungen. Leitfaden: keine stille Erweiterung des Umfangs — jede Änderung bewusst entscheiden.
+
+ ) : (
+
+ {list.map((c) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+function ChangeRow({ projectId, change: c }: { projectId: string; change: ChangeItem }) {
+ const utils = trpc.useUtils();
+ const invalidate = () => utils.change.forProject.invalidate({ projectId });
+ const update = trpc.change.update.useMutation({ onSuccess: invalidate });
+ const remove = trpc.change.remove.useMutation({ onSuccess: invalidate });
+ const meta = CHANGE_ENTSCHEIDUNG_META[c.entscheidung as ChangeEntscheidung];
+ return (
+
+
+
+
{c.titel}
+ {c.auswirkung &&
Auswirkung: {c.auswirkung}
}
+
+ {new Date(c.createdAt).toISOString().slice(0, 10)}
+ {c.authorName ? ` · ${c.authorName}` : ''}
+
+ {c.entscheidung === 'genehmigt' && (
+
+
+ Genehmigt — Baseline/Steckbrief in Outline nachziehen.
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+// --- Stakeholder-Analyse + Macht/Interesse-Matrix ---------------------------
+function StakeholdersSection({ projectId }: { projectId: string }) {
+ const q = trpc.stakeholder.forProject.useQuery({ projectId });
+ const [adding, setAdding] = useState(false);
+ const list = q.data ?? [];
+ return (
+
+
+ Stakeholder
+ {!adding && (
+
+ )}
+
+
+ {list.length > 0 && }
+ {list.length === 0 && !adding && (
+
+ Noch keine Stakeholder erfasst. Buch: alle, die auch nur entfernt mit dem Projekt zu
+ tun haben, haben Interessen, Macht und Einfluss.
+
+ )}
+
+ {list.map((s) => (
+
+ ))}
+
+ {adding && setAdding(false)} />}
+
+
+ );
+}
+
+function StakeholderMatrix({ list }: { list: Stakeholder[] }) {
+ // Zeilen = Einfluss (hoch oben), Spalten = Interesse (hoch rechts).
+ const rows: Level[] = ['hoch', 'mittel', 'niedrig'];
+ const cols: Level[] = ['niedrig', 'mittel', 'hoch'];
+ return (
+
+
+
+
+ {cols.map((c) => (
+
+ Interesse {LEVEL_META[c].label.toLowerCase()}
+
+ ))}
+ {rows.map((r) => (
+
+ ))}
+
+
+ Zeilen: Einfluss (oben = hoch) · Spalten: Interesse (rechts = hoch)
+
+
+
+ );
+}
+
+function FragmentRow({ r, cols, list }: { r: Level; cols: Level[]; list: Stakeholder[] }) {
+ return (
+ <>
+
+ Einfluss {LEVEL_META[r].label.toLowerCase()}
+
+ {cols.map((c) => {
+ const cell = list.filter((s) => s.einfluss === r && s.interesse === c);
+ return (
+
+
+ {stakeholderStrategie(r, c)}
+
+
+ {cell.map((s) => (
+
+
+ {s.name}
+
+ ))}
+
+
+ );
+ })}
+ >
+ );
+}
+
+function StakeholderRow({
+ projectId,
+ stakeholder: s,
+}: {
+ projectId: string;
+ stakeholder: Stakeholder;
+}) {
+ const utils = trpc.useUtils();
+ const [editing, setEditing] = useState(false);
+ const invalidate = () => utils.stakeholder.forProject.invalidate({ projectId });
+ const update = trpc.stakeholder.update.useMutation({
+ onSuccess: async () => {
+ await invalidate();
+ setEditing(false);
+ },
+ });
+ const remove = trpc.stakeholder.remove.useMutation({ onSuccess: invalidate });
+
+ const [name, setName] = useState(s.name);
+ const [rolle, setRolle] = useState(s.rolle ?? '');
+ const [interesse, setInteresse] = useState(s.interesse as Level);
+ const [einfluss, setEinfluss] = useState(s.einfluss as Level);
+ const [haltung, setHaltung] = useState(s.haltung as Haltung);
+ const [beduerfnis, setBeduerfnis] = useState(s.beduerfnis ?? '');
+ const [notiz, setNotiz] = useState(s.notiz ?? '');
+
+ if (editing) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ {s.name}
+ {s.rolle && · {s.rolle}}
+
+
+
+ Interesse {LEVEL_META[s.interesse as Level].label}
+
+
+ Einfluss {LEVEL_META[s.einfluss as Level].label}
+
+
+ {s.beduerfnis &&
Braucht: {s.beduerfnis}
}
+ {s.notiz &&
{s.notiz}
}
+
+
+
+
+
+
+ );
+}
+
+function AddStakeholderForm({ projectId, onDone }: { projectId: string; onDone: () => void }) {
+ const utils = trpc.useUtils();
+ const [name, setName] = useState('');
+ const [rolle, setRolle] = useState('');
+ const [interesse, setInteresse] = useState('mittel');
+ const [einfluss, setEinfluss] = useState('mittel');
+ const [haltung, setHaltung] = useState('neutral');
+ const [beduerfnis, setBeduerfnis] = useState('');
+ const add = trpc.stakeholder.create.useMutation({
+ onSuccess: async () => {
+ await utils.stakeholder.forProject.invalidate({ projectId });
+ onDone();
+ },
+ });
+ return (
+
+ );
+}
+
+function LevelSelect({
+ label,
+ value,
+ onChange,
+}: {
+ label: string;
+ value: Level;
+ onChange: (l: Level) => void;
+}) {
+ return (
+
+ );
+}
+
+// --- Risiken & Chancen ------------------------------------------------------
+function RisksSection({ projectId }: { projectId: string }) {
+ const q = trpc.risk.forProject.useQuery({ projectId });
+ const usersQ = trpc.user.list.useQuery();
+ const users = usersQ.data ?? [];
+ const userMap = new Map(users.map((u) => [u.id, u.name]));
+ const [adding, setAdding] = useState<'risiko' | 'chance' | null>(null);
+ const list = q.data ?? [];
+ const risiken = list.filter((r) => r.kind === 'risiko');
+ const chancen = list.filter((r) => r.kind === 'chance');
+ return (
+
+
+ Risiken & Chancen
+
+
+
+
+
+
+ {adding && (
+ setAdding(null)}
+ />
+ )}
+
+
Risiken
+ {risiken.length === 0 ? (
+
Keine Risiken erfasst.
+ ) : (
+
+ {risiken.map((r) => (
+
+ ))}
+
+ )}
+
+
+
Chancen
+ {chancen.length === 0 ? (
+
Keine Chancen erfasst.
+ ) : (
+
+ {chancen.map((r) => (
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+function RiskRow({
+ projectId,
+ risk: r,
+ users,
+ userMap,
+}: {
+ projectId: string;
+ risk: Risk;
+ users: Array<{ id: string; name: string }>;
+ userMap: Map;
+}) {
+ const utils = trpc.useUtils();
+ const [editing, setEditing] = useState(false);
+ const invalidate = async () => {
+ await utils.risk.forProject.invalidate({ projectId });
+ await utils.dashboard.overview.invalidate();
+ };
+ const update = trpc.risk.update.useMutation({
+ onSuccess: async () => {
+ await invalidate();
+ setEditing(false);
+ },
+ });
+ const remove = trpc.risk.remove.useMutation({ onSuccess: invalidate });
+
+ const [title, setTitle] = useState(r.title);
+ const [wahr, setWahr] = useState(r.wahrscheinlichkeit as Level);
+ const [ausw, setAusw] = useState(r.auswirkung as Level);
+ const [herkunft, setHerkunft] = useState(r.herkunft ?? '');
+ const [showstopper, setShowstopper] = useState(r.showstopper);
+ const [massnahme, setMassnahme] = useState(r.massnahme ?? '');
+ const [strategie, setStrategie] = useState(r.strategie ?? '');
+ const [owner, setOwner] = useState(r.ownerUserId ?? '');
+ const [status, setStatus] = useState(r.status as RiskStatus);
+ const isChance = r.kind === 'chance';
+
+ if (editing) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ {r.showstopper && (
+
+ Showstopper
+
+ )}
+ {r.title}
+
+
+
+
+
+
+
+
+ W: {LEVEL_META[r.wahrscheinlichkeit as Level].label}
+
+
+ A: {LEVEL_META[r.auswirkung as Level].label}
+
+ {r.herkunft && (
+
+ {r.herkunft === 'intern' ? 'Intern' : 'Extern'}
+
+ )}
+
+ {RISK_STATUS_LABEL[r.status as RiskStatus]}
+
+ {r.strategie && (
+
+ {RISK_STRATEGIE_LABEL[r.strategie as RiskStrategie]}
+
+ )}
+ {r.ownerUserId && userMap.get(r.ownerUserId) && (
+
+ {userMap.get(r.ownerUserId)}
+
+ )}
+
+ {r.massnahme && (
+
+ {isChance ? 'Nutzung: ' : 'Gegenmaßnahme: '}
+ {r.massnahme}
+
+ )}
+
+ );
+}
+
+function AddRiskForm({
+ projectId,
+ kind,
+ users,
+ onDone,
+}: {
+ projectId: string;
+ kind: 'risiko' | 'chance';
+ users: Array<{ id: string; name: string }>;
+ onDone: () => void;
+}) {
+ const utils = trpc.useUtils();
+ const [title, setTitle] = useState('');
+ const [wahr, setWahr] = useState('mittel');
+ const [ausw, setAusw] = useState('mittel');
+ const [showstopper, setShowstopper] = useState(false);
+ const [massnahme, setMassnahme] = useState('');
+ const [strategie, setStrategie] = useState('');
+ const [owner, setOwner] = useState('');
+ const add = trpc.risk.create.useMutation({
+ onSuccess: async () => {
+ await utils.risk.forProject.invalidate({ projectId });
+ await utils.dashboard.overview.invalidate();
+ onDone();
+ },
+ });
+ return (
+
+ );
+}
+
+// --- Meilensteine (editierbar, Ergebnis/Kundennutzen, Datums-Warnung) -------
+function MilestonesSection({
+ projectId,
+ milestones,
+ deadline,
+}: {
+ projectId: string;
+ milestones: Milestone[];
+ deadline: string | null;
+}) {
+ const [adding, setAdding] = useState(false);
+ const deadlineWarn = dateWarnings(deadline);
+ return (
+
+
+
Meilensteine
+ {!adding && (
+
+ )}
+
+ {deadline && deadlineWarn.length > 0 && (
+
+
+ Deadline {deadline}: {deadlineWarn.join(' ')}
+
+ )}
+ {milestones.length < 2 && (
+
+
+ Buch-Regel: jedes Projekt hat mindestens 2 Meilensteine (Anfang & Ende).
+
+ )}
+
+
+ {milestones.length === 0 && !adding && (
+ Noch keine Meilensteine.
+ )}
+ {milestones.map((m) => (
+
+ ))}
+ {adding && setAdding(false)} />}
+
+
+
+ );
+}
+
+function MilestoneRow({ projectId, milestone: m }: { projectId: string; milestone: Milestone }) {
+ const utils = trpc.useUtils();
+ const [editing, setEditing] = useState(false);
+ const invalidate = () => utils.project.byId.invalidate({ id: projectId });
+ const update = trpc.project.updateMilestone.useMutation({
+ onSuccess: async () => {
+ await invalidate();
+ setEditing(false);
+ },
+ });
+ const remove = trpc.project.removeMilestone.useMutation({ onSuccess: invalidate });
+
+ const [title, setTitle] = useState(m.title);
+ const [due, setDue] = useState(m.due ?? '');
+ const [status, setStatus] = useState(m.status);
+ const [ergebnis, setErgebnis] = useState(m.ergebnis ?? '');
+ const [kundennutzen, setKundennutzen] = useState(m.kundennutzen ?? '');
+ const warn = dateWarnings(m.due);
+
+ if (editing) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+ {m.title}
+
+
+ {m.due ?? 'kein Datum'} · {m.status === 'done' ? 'erledigt' : m.status === 'overdue' ? 'überfällig' : 'offen'}
+
+ {warn.length > 0 && (
+
+
+ {warn.join(' ')}
+
+ )}
+ {m.ergebnis &&
Ergebnis: {m.ergebnis}
}
+ {m.kundennutzen && (
+
Kundennutzen: {m.kundennutzen}
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+function AddMilestoneForm({ projectId, onDone }: { projectId: string; onDone: () => void }) {
+ const utils = trpc.useUtils();
+ const [title, setTitle] = useState('');
+ const [due, setDue] = useState('');
+ const add = trpc.project.addMilestone.useMutation({
+ onSuccess: async () => {
+ await utils.project.byId.invalidate({ id: projectId });
+ onDone();
+ },
+ });
+ const warn = dateWarnings(due);
+ return (
+
+ );
+}
+
+function AbschlussDialog({
+ projectId,
+ abschlussvermerk,
+ onClose,
+}: {
+ projectId: string;
+ abschlussvermerk: string | null;
+ onClose: () => void;
+}) {
const [url, setUrl] = useState('');
+ const [vermerk, setVermerk] = useState(abschlussvermerk ?? '');
+ const [checks, setChecks] = useState({ abnahme: false, aufraeumen: false, retro: false });
const navigate = useNavigate();
const utils = trpc.useUtils();
const mutation = trpc.project.archive.useMutation({
@@ -182,30 +1678,62 @@ function AbschlussDialog({ projectId, onClose }: { projectId: string; onClose: (
});
function submit(e: React.FormEvent) {
e.preventDefault();
- mutation.mutate({ projectId, lessonsLearnedUrl: url });
+ mutation.mutate({ projectId, lessonsLearnedUrl: url, abschlussvermerk: vermerk.trim() || undefined });
}
+ const checkItems: Array<{ key: keyof typeof checks; label: string }> = [
+ { key: 'abnahme', label: 'Abnahme — Liefergegenstände ohne offene Beanstandung abgenommen' },
+ { key: 'aufraeumen', label: 'Aufräumen — Rechnungen, Zugänge, Verträge, Übergabe in Betrieb' },
+ { key: 'retro', label: 'Retrospektive / Lessons Learned durchgeführt' },
+ ];
return (
e.stopPropagation()}
>
Projekt abschließen
- Lessons-Learned-URL ist Pflicht (Leitfaden §9 Punkt 6).
+ Ein Projekt endet bewusst (Leitfaden Phase 4).