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 };
}),
});
+114
View File
@@ -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<ProjectModus, { label: string; badge: string; hint: string }> = {
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<Level, { label: string; badge: string }> = {
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<RiskStatus, string> = {
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<Haltung, { label: string; dot: string }> = {
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<ProjectPhase, { label: string; gateZiel: string }> = {
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<ZieldreieckFlex, string> = {
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<RiskStrategie, string> = {
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<ChangeEntscheidung, { label: string; badge: string }> = {
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;
}
+80 -2
View File
@@ -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<string, boolean>;
}>;
}) {
@@ -104,7 +107,19 @@ function Section({
<Card.Root className="cursor-pointer hover:shadow-[var(--shadow-md)]">
<Card.Body className="flex items-center justify-between">
<div>
<p className="font-medium">{p.name}</p>
<p className="flex items-center gap-2 font-medium">
{p.name}
{p.modus !== 'standard' && (
<span
className={clsx(
'rounded-full px-2 py-0.5 text-[10px] font-medium',
MODUS_META[p.modus].badge,
)}
>
{MODUS_META[p.modus].label}
</span>
)}
</p>
<p className="text-xs text-[var(--color-ink-muted)]">
{p.deadline ? `bis ${p.deadline}` : 'kein Deadline'}
</p>
@@ -297,7 +312,70 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
}
function VertriebDetail() {
return <ProjektListeFuerBereich slug="vertrieb" />;
return (
<div className="space-y-6">
<LeadsTeaser />
<ProjektListeFuerBereich slug="vertrieb" />
</div>
);
}
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 (
<section>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-lg font-semibold">Leads</h2>
<Link to="/leads" className="text-sm text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:underline">
Alle Leads ({leads.length}) →
</Link>
</div>
<Card.Root>
<Card.Body>
{q.isPending ? (
<p className="text-sm text-[var(--color-ink-muted)]">Lädt…</p>
) : offen.length === 0 ? (
<p className="text-sm text-[var(--color-ink-muted)]">
Keine offenen Leads.{' '}
<Link to="/leads" className="hover:underline">
Lege einen an.
</Link>
</p>
) : (
<ul className="divide-y divide-[var(--color-border)]">
{top.map((l) => {
const meta = LEAD_STATUS_META[l.status as LeadStatus];
return (
<li key={l.id}>
<Link
to={`/lead/${l.id}`}
className="flex items-center justify-between gap-2 py-2 hover:opacity-80"
>
<span className="flex items-center gap-2 text-sm">
<span className={clsx('h-2 w-2 rounded-full', meta.dot)} />
<span className="font-medium">{l.firma}</span>
{l.subSegment && (
<span className="text-xs text-[var(--color-ink-muted)]">{l.subSegment}</span>
)}
</span>
<span className="font-mono text-xs tabular-nums text-[var(--color-ink-muted)]">
{l.score}/10
</span>
</Link>
</li>
);
})}
</ul>
)}
</Card.Body>
</Card.Root>
</section>
);
}
function MarketingDetail() {
+57
View File
@@ -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() {
</div>
</section>
{q.data.showstoppers.length > 0 && (
<section>
<Card.Root className="border-[var(--color-ampel-red-fg)]">
<Card.Header>
<h2 className="flex items-center gap-2 text-lg font-semibold text-[var(--color-ampel-red-fg)]">
<AlertTriangle size={18} /> Showstopper
</h2>
</Card.Header>
<Card.Body>
<ul className="space-y-2">
{q.data.showstoppers.map((s) => (
<li
key={s.id}
className="flex items-start justify-between gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] p-3"
>
<span className="text-sm">{s.title}</span>
<Link
to={`/projekt/${s.projectId}`}
className="shrink-0 text-xs text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:underline"
>
{s.projectName} →
</Link>
</li>
))}
</ul>
</Card.Body>
</Card.Root>
</section>
)}
{q.data.kritischeModi.length > 0 && (
<section>
<h2 className="mb-2 text-lg font-semibold">Projekte in Eskalation / Taskforce</h2>
<div className="flex flex-wrap gap-2">
{q.data.kritischeModi.map((p) => (
<Link
key={p.id}
to={`/projekt/${p.id}`}
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-sm hover:border-[var(--color-ink)]"
>
<span
className={clsx(
'rounded-full px-2 py-0.5 text-[10px] font-medium',
MODUS_META[p.modus as ProjectModus].badge,
)}
>
{MODUS_META[p.modus as ProjectModus].label}
</span>
{p.name}
</Link>
))}
</div>
</section>
)}
<section className="grid grid-cols-1 gap-6 md:grid-cols-3">
<Card.Root>
<Card.Header>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,40 @@
CREATE TABLE "risk" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"project_id" uuid NOT NULL,
"kind" text DEFAULT 'risiko' NOT NULL,
"title" text NOT NULL,
"wahrscheinlichkeit" text DEFAULT 'mittel' NOT NULL,
"auswirkung" text DEFAULT 'mittel' NOT NULL,
"herkunft" text,
"showstopper" boolean DEFAULT false NOT NULL,
"massnahme" text,
"status" text DEFAULT 'offen' NOT NULL,
"created_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "stakeholder" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"project_id" uuid NOT NULL,
"name" text NOT NULL,
"rolle" text,
"interesse" text DEFAULT 'mittel' NOT NULL,
"einfluss" text DEFAULT 'mittel' NOT NULL,
"haltung" text DEFAULT 'neutral' NOT NULL,
"beduerfnis" text,
"notiz" text,
"order" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "milestone" ADD COLUMN "ergebnis" text;--> statement-breakpoint
ALTER TABLE "milestone" ADD COLUMN "kundennutzen" text;--> statement-breakpoint
ALTER TABLE "project" ADD COLUMN "maxime" text;--> statement-breakpoint
ALTER TABLE "project" ADD COLUMN "modus" text DEFAULT 'standard' NOT NULL;--> statement-breakpoint
ALTER TABLE "risk" ADD CONSTRAINT "risk_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "risk" ADD CONSTRAINT "risk_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "stakeholder" ADD CONSTRAINT "stakeholder_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_risk_project" ON "risk" USING btree ("project_id");--> statement-breakpoint
CREATE INDEX "idx_risk_showstopper" ON "risk" USING btree ("showstopper");--> statement-breakpoint
CREATE INDEX "idx_stakeholder_project" ON "stakeholder" USING btree ("project_id");
@@ -0,0 +1,49 @@
CREATE TABLE "change_entry" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"project_id" uuid NOT NULL,
"titel" text NOT NULL,
"auswirkung" text,
"entscheidung" text DEFAULT 'offen' NOT NULL,
"created_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "phase_gate" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"project_id" uuid NOT NULL,
"phase" text NOT NULL,
"ergebnis_erreicht" boolean NOT NULL,
"notiz" text,
"reviewed_by" uuid,
"reviewed_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "project_status" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"project_id" uuid NOT NULL,
"ampel" text NOT NULL,
"trend" text,
"text" text,
"extern_sichtbar" boolean DEFAULT false NOT NULL,
"created_by" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "project" ADD COLUMN "ziel" text;--> statement-breakpoint
ALTER TABLE "project" ADD COLUMN "nicht_ziele" text;--> statement-breakpoint
ALTER TABLE "project" ADD COLUMN "liefergegenstaende" text;--> statement-breakpoint
ALTER TABLE "project" ADD COLUMN "zieldreieck_flex" text;--> statement-breakpoint
ALTER TABLE "project" ADD COLUMN "phase" text DEFAULT 'definition' NOT NULL;--> statement-breakpoint
ALTER TABLE "project" ADD COLUMN "abschlussvermerk" text;--> statement-breakpoint
ALTER TABLE "risk" ADD COLUMN "strategie" text;--> statement-breakpoint
ALTER TABLE "risk" ADD COLUMN "owner_user_id" uuid;--> statement-breakpoint
ALTER TABLE "change_entry" ADD CONSTRAINT "change_entry_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "change_entry" ADD CONSTRAINT "change_entry_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "phase_gate" ADD CONSTRAINT "phase_gate_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "phase_gate" ADD CONSTRAINT "phase_gate_reviewed_by_user_id_fk" FOREIGN KEY ("reviewed_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "project_status" ADD CONSTRAINT "project_status_project_id_project_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."project"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "project_status" ADD CONSTRAINT "project_status_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "idx_change_entry_project" ON "change_entry" USING btree ("project_id");--> statement-breakpoint
CREATE INDEX "idx_phase_gate_project" ON "phase_gate" USING btree ("project_id");--> statement-breakpoint
CREATE INDEX "idx_project_status_project" ON "project_status" USING btree ("project_id");--> statement-breakpoint
ALTER TABLE "risk" ADD CONSTRAINT "risk_owner_user_id_user_id_fk" FOREIGN KEY ("owner_user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+21
View File
@@ -57,6 +57,27 @@
"when": 1780415208906,
"tag": "0007_project_description",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1780577284868,
"tag": "0008_lead_crm",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1780669990802,
"tag": "0009_pm_insights",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1780672129310,
"tag": "0010_leitfaden_phases",
"breakpoints": true
}
]
}
+333
View File
@@ -125,11 +125,27 @@ export const project = pgTable(
.references(() => area.id, { onDelete: 'restrict' }),
name: text('name').notNull(),
description: text('description'),
// Maxime / roter Faden (ein Satz) — Leitsatz fürs ganze Team (Buch Kap. 4 Strategie).
maxime: text('maxime'),
// Definition (Leitfaden Phase 1) — Kurzfassung; Volltext-Steckbrief lebt in Outline.
ziel: text('ziel'), // SMART formuliert
nichtZiele: text('nicht_ziele'), // Scope-Schutz
liefergegenstaende: text('liefergegenstaende'),
// Zieldreieck (Leitfaden Kap. 1): welche Ecke gibt zuerst nach, wenn es eng wird.
zieldreieckFlex: text('zieldreieck_flex', { enum: ['leistung', 'zeit', 'aufwand'] }),
kind: text('kind', { enum: ['kundenprojekt', 'internes_projekt'] }).notNull(),
verantwortlicheUserId: uuid('verantwortliche_user_id').references(() => user.id, {
onDelete: 'set null',
}),
arbeitsmodus: text('arbeitsmodus', { enum: ['cycle', 'kanban'] }),
// Projekt-Modus / Intensität — muss laut Buch jederzeit ans Team kommuniziert sein.
modus: text('modus', { enum: ['relax', 'standard', 'eskalation', 'taskforce'] })
.notNull()
.default('standard'),
// Projektlebenszyklus (Leitfaden Kap. 2): aktuelle Phase.
phase: text('phase', { enum: ['definition', 'planung', 'umsetzung', 'abschluss'] })
.notNull()
.default('definition'),
outlineSteckbriefUrl: text('outline_steckbrief_url'),
steckbriefFreigegeben: boolean('steckbrief_freigegeben').notNull().default(false),
planeProjectUrl: text('plane_project_url'),
@@ -146,6 +162,8 @@ export const project = pgTable(
archiviert: boolean('archiviert').notNull().default(false),
archiviertAm: timestamp('archiviert_am', { withTimezone: true }),
lessonsLearnedUrl: text('lessons_learned_url'),
// Abschluss (Leitfaden Phase 4): kurzer Abschlussvermerk — Ziel erreicht? Aufwand vs. Plan?
abschlussvermerk: text('abschlussvermerk'),
createdBy: uuid('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
@@ -174,6 +192,9 @@ export const milestone = pgTable(
.notNull()
.default('open'),
description: text('description'),
// Ergebnis-Übersicht („Patchnotes") + konkreter Kundennutzen je Meilenstein (Buch Kap. 4).
ergebnis: text('ergebnis'),
kundennutzen: text('kundennutzen'),
order: integer('order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
@@ -429,6 +450,294 @@ export const blocker = pgTable(
}),
);
// ----------------------------------------------------------------------------
// lead — qualitatives Vertriebs-Dossier (CRM). Reicher als `deal`: Stammdaten,
// Pain, Geschäftliches, Soft Factors, Strategie. Ein Lead kann optional zu einem
// Pipeline-`deal` promoted werden (promoted_deal_id). Spiegelt die interne
// Lead-Schablone (9 Abschnitte).
// ----------------------------------------------------------------------------
export const lead = pgTable(
'lead',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
areaId: uuid('area_id')
.notNull()
.references(() => area.id, { onDelete: 'restrict' }),
// 1. Stammdaten
firma: text('firma').notNull(),
status: text('status', { enum: ['cold', 'warm', 'hot', 'won', 'lost'] })
.notNull()
.default('cold'),
score: integer('score').notNull().default(0), // 0-10
ownerUserId: uuid('owner_user_id').references(() => user.id, { onDelete: 'set null' }),
branche: text('branche'),
subSegment: text('sub_segment'), // Nische
standorte: text('standorte'),
mitarbeiterzahl: text('mitarbeiterzahl'),
umsatz: text('umsatz'),
website: text('website'),
quelle: text('quelle'), // wie auf uns aufmerksam / wie gefunden
// 3. Pain & Bedarf
painPoint: text('pain_point'),
statusQuoKosten: text('status_quo_kosten'),
bestehendeLoesung: text('bestehende_loesung'),
wechselgrund: text('wechselgrund'),
persoenlicherErfolg: text('persoenlicher_erfolg'),
// 4. Geschäftliches
budgetIndikation: text('budget_indikation'),
entscheidungshorizont: text('entscheidungshorizont'),
compellingEvent: text('compelling_event'),
mitbewerber: text('mitbewerber'),
buyingCenter: text('buying_center'),
beschaffungsweg: text('beschaffungsweg'),
// 5. Soft Factors
kommunikationsstil: text('kommunikationsstil'),
vorlieben: text('vorlieben'),
hotButtons: text('hot_buttons'),
noGos: text('no_gos'),
druckpunkte: text('druckpunkte'),
anker: text('anker'),
vertrauenslevel: integer('vertrauenslevel'), // 1-5, nullable
// 6. Strategie
nutzenHypothese: text('nutzen_hypothese'),
differenzierer: text('differenzierer'),
naechsterSchritt: text('naechster_schritt'),
naechsterSchrittDatum: date('naechster_schritt_datum'),
risiken: text('risiken'),
planB: text('plan_b'),
// 8. Offene Fragen / 9. Anhänge (leichtgewichtige Listen als JSONB)
offeneFragen: jsonb('offene_fragen')
.$type<Array<{ text: string; done: boolean }>>()
.notNull()
.default([]),
anhaenge: jsonb('anhaenge')
.$type<Array<{ label: string; url: string }>>()
.notNull()
.default([]),
// Promotion → Pipeline-Deal
promotedDealId: uuid('promoted_deal_id').references(() => deal.id, { onDelete: 'set null' }),
createdBy: uuid('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxStatus: index('idx_lead_status').on(t.status),
idxOwner: index('idx_lead_owner').on(t.ownerUserId),
}),
);
// ----------------------------------------------------------------------------
// lead_contact — Kontaktperson(en) eines Leads (Schablone Abschnitt 2).
// ----------------------------------------------------------------------------
export const leadContact = pgTable(
'lead_contact',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
leadId: uuid('lead_id')
.notNull()
.references(() => lead.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
rolle: text('rolle'), // Rolle / Titel
funktion: text('funktion', {
enum: ['entscheider', 'influencer', 'gatekeeper', 'end_user', 'champion'],
}),
email: text('email'),
telefon: text('telefon'),
linkedin: text('linkedin'),
imBuyingCenterSeit: text('im_buying_center_seit'),
sprache: text('sprache'), // Sprache / bevorzugte Anrede
isPrimary: boolean('is_primary').notNull().default(false),
order: integer('order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxLead: index('idx_lead_contact_lead').on(t.leadId),
}),
);
// ----------------------------------------------------------------------------
// lead_touchpoint — Austausch-Log (Schablone Abschnitt 7). Jeder Kontakt eine Zeile.
// ----------------------------------------------------------------------------
export const leadTouchpoint = pgTable(
'lead_touchpoint',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
leadId: uuid('lead_id')
.notNull()
.references(() => lead.id, { onDelete: 'cascade' }),
datum: date('datum').notNull(),
kanal: text('kanal', {
enum: ['call', 'mail', 'linkedin', 'termin', 'event', 'sonstiges'],
}).notNull(),
bearbeiterUserId: uuid('bearbeiter_user_id').references(() => user.id, { onDelete: 'set null' }),
inhalt: text('inhalt').notNull(),
outcome: text('outcome'),
followUpBis: date('follow_up_bis'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxLead: index('idx_lead_touchpoint_lead').on(t.leadId),
idxFollowUp: index('idx_lead_touchpoint_followup').on(t.followUpBis),
}),
);
// ----------------------------------------------------------------------------
// stakeholder — Stakeholder-Analyse pro Projekt (Buch Kap. 4). Interesse ×
// Einfluss ergibt die Macht/Interesse-Matrix; Haltung = Grundeinstellung.
// ----------------------------------------------------------------------------
export const stakeholder = pgTable(
'stakeholder',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
projectId: uuid('project_id')
.notNull()
.references(() => project.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
rolle: text('rolle'),
interesse: text('interesse', { enum: ['niedrig', 'mittel', 'hoch'] })
.notNull()
.default('mittel'),
einfluss: text('einfluss', { enum: ['niedrig', 'mittel', 'hoch'] })
.notNull()
.default('mittel'),
haltung: text('haltung', { enum: ['pro', 'neutral', 'contra'] })
.notNull()
.default('neutral'),
beduerfnis: text('beduerfnis'), // Was braucht die Person, in welcher Priorität?
notiz: text('notiz'),
order: integer('order').notNull().default(0),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxProject: index('idx_stakeholder_project').on(t.projectId),
}),
);
// ----------------------------------------------------------------------------
// risk — Risiko- & Chancen-Register pro Projekt (Buch Kap. 4). `kind`
// unterscheidet Gefahr vs. Chance. Showstopper = bringt das Projekt zum Stillstand.
// ----------------------------------------------------------------------------
export const risk = pgTable(
'risk',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
projectId: uuid('project_id')
.notNull()
.references(() => project.id, { onDelete: 'cascade' }),
kind: text('kind', { enum: ['risiko', 'chance'] })
.notNull()
.default('risiko'),
title: text('title').notNull(), // ausformulierter Satz
wahrscheinlichkeit: text('wahrscheinlichkeit', { enum: ['niedrig', 'mittel', 'hoch'] })
.notNull()
.default('mittel'),
auswirkung: text('auswirkung', { enum: ['niedrig', 'mittel', 'hoch'] })
.notNull()
.default('mittel'),
herkunft: text('herkunft', { enum: ['intern', 'extern'] }),
showstopper: boolean('showstopper').notNull().default(false),
massnahme: text('massnahme'), // Gegenmaßnahme bzw. Nutzungsidee bei Chancen
// Strategie (Leitfaden Kap. 4): vermeiden / verlagern / mindern / akzeptieren.
strategie: text('strategie', { enum: ['vermeiden', 'verlagern', 'mindern', 'akzeptieren'] }),
ownerUserId: uuid('owner_user_id').references(() => user.id, { onDelete: 'set null' }),
status: text('status', { enum: ['offen', 'beobachtet', 'eingetreten', 'geschlossen'] })
.notNull()
.default('offen'),
createdBy: uuid('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxProject: index('idx_risk_project').on(t.projectId),
idxShowstopper: index('idx_risk_showstopper').on(t.showstopper),
}),
);
// ----------------------------------------------------------------------------
// project_status — Statusbericht pro Projekt (Leitfaden Kap. 5 „Status sichtbar
// machen"): Ampel + Trend + 2-3 Sätze. Jüngster Eintrag speist statusCache.
// ----------------------------------------------------------------------------
export const projectStatus = pgTable(
'project_status',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
projectId: uuid('project_id')
.notNull()
.references(() => project.id, { onDelete: 'cascade' }),
ampel: text('ampel', { enum: ['green', 'amber', 'red'] }).notNull(),
trend: text('trend', { enum: ['besser', 'gleich', 'schlechter'] }),
text: text('text'), // 2-3 Sätze Erläuterung
externSichtbar: boolean('extern_sichtbar').notNull().default(false),
createdBy: uuid('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxProject: index('idx_project_status_project').on(t.projectId),
}),
);
// ----------------------------------------------------------------------------
// phase_gate — Gate-Review am Phasenende (Leitfaden Kap. 5 „Reviews an
// Meilensteinen"): Ergebnis erreicht? Folgephase freigegeben?
// ----------------------------------------------------------------------------
export const phaseGate = pgTable(
'phase_gate',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
projectId: uuid('project_id')
.notNull()
.references(() => project.id, { onDelete: 'cascade' }),
phase: text('phase', { enum: ['definition', 'planung', 'umsetzung', 'abschluss'] }).notNull(),
ergebnisErreicht: boolean('ergebnis_erreicht').notNull(),
notiz: text('notiz'),
reviewedBy: uuid('reviewed_by').references(() => user.id, { onDelete: 'set null' }),
reviewedAt: timestamp('reviewed_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxProject: index('idx_phase_gate_project').on(t.projectId),
}),
);
// ----------------------------------------------------------------------------
// change_entry — Änderungs-Log (Leitfaden Kap. 5 „Mit Änderungen umgehen"):
// bewusste Entscheidung über Änderungen, die das Zieldreieck bewegen.
// ----------------------------------------------------------------------------
export const changeEntry = pgTable(
'change_entry',
{
id: uuid('id')
.default(sql`gen_random_uuid()`)
.primaryKey(),
projectId: uuid('project_id')
.notNull()
.references(() => project.id, { onDelete: 'cascade' }),
titel: text('titel').notNull(),
auswirkung: text('auswirkung'), // Auswirkung aufs Zieldreieck
entscheidung: text('entscheidung', { enum: ['offen', 'genehmigt', 'abgelehnt'] })
.notNull()
.default('offen'),
createdBy: uuid('created_by').references(() => user.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
},
(t) => ({
idxProject: index('idx_change_entry_project').on(t.projectId),
}),
);
// ----------------------------------------------------------------------------
// Type-Helper für Code, der mit den Entitäten arbeitet.
// ----------------------------------------------------------------------------
@@ -476,3 +785,27 @@ export type NewCampaign = typeof campaign.$inferInsert;
export type LeadHandover = typeof leadHandover.$inferSelect;
export type NewLeadHandover = typeof leadHandover.$inferInsert;
export type Lead = typeof lead.$inferSelect;
export type NewLead = typeof lead.$inferInsert;
export type LeadContact = typeof leadContact.$inferSelect;
export type NewLeadContact = typeof leadContact.$inferInsert;
export type LeadTouchpoint = typeof leadTouchpoint.$inferSelect;
export type NewLeadTouchpoint = typeof leadTouchpoint.$inferInsert;
export type Stakeholder = typeof stakeholder.$inferSelect;
export type NewStakeholder = typeof stakeholder.$inferInsert;
export type Risk = typeof risk.$inferSelect;
export type NewRisk = typeof risk.$inferInsert;
export type ProjectStatus = typeof projectStatus.$inferSelect;
export type NewProjectStatus = typeof projectStatus.$inferInsert;
export type PhaseGate = typeof phaseGate.$inferSelect;
export type NewPhaseGate = typeof phaseGate.$inferInsert;
export type ChangeEntry = typeof changeEntry.$inferSelect;
export type NewChangeEntry = typeof changeEntry.$inferInsert;
+218 -1
View File
@@ -1,7 +1,7 @@
// Seed-Skript: idempotent. Legt Areas, Test-Mitarbeiter (inkl. Admin Dejan) und
// Dummy-Daten (Projekte, Deals, Kampagnen, Tageseinträge, Blocker) an.
import { sql } from 'drizzle-orm';
import { sql, eq } from 'drizzle-orm';
import { createDbClient } from './client.js';
import {
area,
@@ -15,6 +15,13 @@ import {
dayEntry,
todo,
blocker,
lead,
leadContact,
leadTouchpoint,
risk,
stakeholder,
projectStatus,
changeEntry,
} from './schema.js';
const AREAS = [
@@ -366,6 +373,216 @@ export async function seed(connectionString: string): Promise<void> {
]);
}
// ----- Leads (CRM-Dossiers) -----
const existingLeads = await db.select().from(lead);
if (existingLeads.length === 0) {
const sales = areaBySlug.vertrieb!;
const [l1] = await db
.insert(lead)
.values({
areaId: sales.id,
firma: 'Alpenhof Hotels GmbH',
status: 'hot',
score: 8,
ownerUserId: anna.id,
branche: 'Hotellerie',
subSegment: 'Internationale/deutsche Hotelketten',
standorte: 'München, Wien, Zürich',
mitarbeiterzahl: '350+',
umsatz: '~ 80 Mio € (2025)',
website: 'https://alpenhof-hotels.example',
quelle: 'LinkedIn-Profil im MitApp-Verzeichnis',
painPoint:
'Projektmanagement-Workflows laufen über zig Excel-Listen, nichts ist zentral. Onboarding neuer Häuser dauert ewig.',
statusQuoKosten: 'Mehrere Personentage pro Woche an manueller Koordination.',
bestehendeLoesung: 'Excel + E-Mail, vereinzelt Trello',
wechselgrund: 'Skaliert nicht mit dem Wachstum, zu fehleranfällig.',
persoenlicherErfolg: 'Will als Onboarding-Manager sichtbar Zeit einsparen und entlasten.',
budgetIndikation: 'noch offen, vermutlich 5-stellig p.a.',
entscheidungshorizont: 'Q3',
compellingEvent: 'Zwei neue Häuser kommen im Herbst dazu.',
mitbewerber: 'keine konkret bekannt',
buyingCenter: 'Onboarding-Manager (Champion), COO (Entscheider)',
beschaffungsweg: 'direkt',
kommunikationsstil: 'freundlich, pragmatisch, mag konkrete Beispiele',
hotButtons: 'Zeitersparnis, weniger manuelle Pflege, KI-Unterstützung',
nutzenHypothese:
'KI-gestützte PM-Workflows sparen dem Onboarding-Team messbar Zeit bei jedem neuen Haus.',
differenzierer: 'Eigene Erfahrung in genau dieser Nische + KI-/Prozessautomatisierung.',
naechsterSchritt: 'Discovery-Call vereinbaren',
naechsterSchrittDatum: isoDateOffset(4),
vertrauenslevel: 3,
offeneFragen: [
{ text: 'Wer hält das Budget?', done: false },
{ text: 'Welche Tools sind heute schon im Einsatz?', done: true },
],
anhaenge: [],
createdBy: anna.id,
})
.returning();
if (l1) {
await db.insert(leadContact).values({
leadId: l1.id,
name: 'Sascha Haiss',
rolle: 'Onboarding Manager',
funktion: 'champion',
linkedin: 'https://www.linkedin.com/in/example',
sprache: 'Deutsch, per Du',
isPrimary: true,
order: 0,
});
await db.insert(leadTouchpoint).values([
{
leadId: l1.id,
datum: pickWerktag(2),
kanal: 'linkedin',
bearbeiterUserId: anna.id,
inhalt: 'Erste Nachricht über das MitApp-Profil geschickt, Bezug auf KI-Optimierung für PM.',
outcome: 'positiv geantwortet, Interesse an kurzem Austausch',
followUpBis: isoDateOffset(4),
},
]);
}
}
// ----- Risiken / Chancen / Stakeholder / Modus (PM-Insights) -----
const existingRisks = await db.select().from(risk);
if (existingRisks.length === 0) {
const someProject = (await db.select().from(project).orderBy(project.createdAt).limit(1))[0];
if (someProject) {
await db
.update(project)
.set({
modus: 'eskalation',
maxime: someProject.maxime ?? 'Pilot ohne Scope-Creep bis zum Stichtag liefern.',
})
.where(eq(project.id, someProject.id));
await db.insert(risk).values([
{
projectId: someProject.id,
kind: 'risiko',
title: 'Kunde liefert die Stripe-Account-Daten nicht rechtzeitig.',
wahrscheinlichkeit: 'hoch',
auswirkung: 'hoch',
herkunft: 'extern',
showstopper: true,
massnahme: 'Eskalation an Kundenkontakt, Deadline schriftlich fixieren.',
status: 'offen',
createdBy: dejan.id,
},
{
projectId: someProject.id,
kind: 'risiko',
title: 'Interne Ressourcen werden für ein anderes Projekt abgezogen.',
wahrscheinlichkeit: 'mittel',
auswirkung: 'hoch',
herkunft: 'intern',
massnahme: 'Kapazität mit der Geschäftsführung absichern.',
status: 'beobachtet',
createdBy: dejan.id,
},
{
projectId: someProject.id,
kind: 'chance',
title: 'Kunde will bei Erfolg weitere Standorte anbinden.',
wahrscheinlichkeit: 'mittel',
auswirkung: 'hoch',
massnahme: 'Pilot als Referenz sauber dokumentieren.',
status: 'offen',
createdBy: dejan.id,
},
]);
await db.insert(stakeholder).values([
{
projectId: someProject.id,
name: 'GF Schmidt',
rolle: 'Auftraggeber',
interesse: 'hoch',
einfluss: 'hoch',
haltung: 'pro',
beduerfnis: 'Pünktlicher Go-Live, klare Status-Reports',
order: 0,
},
{
projectId: someProject.id,
name: 'IT-Abteilung Kunde',
rolle: 'Technik',
interesse: 'mittel',
einfluss: 'hoch',
haltung: 'neutral',
beduerfnis: 'Saubere Schnittstellen, wenig Mehraufwand',
order: 1,
},
{
projectId: someProject.id,
name: 'Sachbearbeitung',
rolle: 'End-User',
interesse: 'hoch',
einfluss: 'niedrig',
haltung: 'pro',
beduerfnis: 'Einfaches, schnelles Auftragsformular',
order: 2,
},
]);
}
}
// ----- Statusbericht / Phase / Definition / Änderung (Leitfaden) -----
const existingStatus = await db.select().from(projectStatus);
if (existingStatus.length === 0) {
const someProject = (await db.select().from(project).orderBy(project.createdAt).limit(1))[0];
if (someProject) {
await db
.update(project)
.set({
phase: 'umsetzung',
ziel:
someProject.ziel ??
'Auftragsportal live bis 30.09., mind. 3 Auftragstypen, vom Kunden abgenommen.',
nichtZiele:
someProject.nichtZiele ??
'Keine Migration der Altbestellungen. Keine mobile App in dieser Phase.',
liefergegenstaende:
someProject.liefergegenstaende ??
'Portal-Frontend, Stripe-Anbindung, Admin-Übersicht, Schulungsunterlagen.',
zieldreieckFlex: someProject.zieldreieckFlex ?? 'aufwand',
statusCache: 'amber',
trendCache: 'gleich',
})
.where(eq(project.id, someProject.id));
await db.insert(projectStatus).values([
{
projectId: someProject.id,
ampel: 'green',
trend: 'besser',
text: 'Setup steht, erste Auftragstypen umgesetzt. Im Plan.',
externSichtbar: true,
createdBy: dejan.id,
},
{
projectId: someProject.id,
ampel: 'amber',
trend: 'gleich',
text: 'Stripe-Zugang vom Kunden noch offen — gefährdet den Pilottermin. Maßnahme läuft.',
externSichtbar: true,
createdBy: dejan.id,
},
]);
await db.insert(changeEntry).values({
projectId: someProject.id,
titel: 'Kunde wünscht zusätzlichen Auftragstyp „Express".',
auswirkung: 'Mehr Umfang → Aufwand steigt, Termin gefährdet.',
entscheidung: 'offen',
createdBy: dejan.id,
});
}
}
await db.update(user).set({ updatedAt: new Date() }).where(sql`true`);
} finally {
await pool.end();
+5 -1
View File
@@ -4,5 +4,9 @@ export * as ProjectSchemas from './schemas/project.js';
export * as DealSchemas from './schemas/deal.js';
export * as CampaignSchemas from './schemas/campaign.js';
export * as TimeEntrySchemas from './schemas/time-entry.js';
export * as LeadSchemas from './schemas/lead.js';
export * as RiskSchemas from './schemas/risk.js';
export * as StakeholderSchemas from './schemas/stakeholder.js';
export * as ChangeSchemas from './schemas/change.js';
export const SHARED_VERSION = '0.0.3';
export const SHARED_VERSION = '0.0.6';
+24
View File
@@ -0,0 +1,24 @@
import { z } from 'zod';
export const changeEntscheidungSchema = z.enum(['offen', 'genehmigt', 'abgelehnt']);
export const CHANGE_ENTSCHEIDUNGEN = changeEntscheidungSchema.options;
export const changeForProjectInput = z.object({ projectId: z.string().uuid() });
export const createChangeInput = z.object({
projectId: z.string().uuid(),
titel: z.string().min(1).max(300),
auswirkung: z.string().max(1000).optional(),
entscheidung: changeEntscheidungSchema.default('offen'),
});
export type CreateChangeInput = z.infer<typeof createChangeInput>;
export const updateChangeInput = z.object({
id: z.string().uuid(),
titel: z.string().min(1).max(300).optional(),
auswirkung: z.string().max(1000).nullable().optional(),
entscheidung: changeEntscheidungSchema.optional(),
});
export type UpdateChangeInput = z.infer<typeof updateChangeInput>;
export const removeChangeInput = z.object({ id: z.string().uuid() });
+90
View File
@@ -1,5 +1,8 @@
import { z } from 'zod';
const ampelSchema = z.enum(['green', 'amber', 'red']);
const trendSchema = z.enum(['besser', 'gleich', 'schlechter']);
export const createProjectInput = z.object({
areaId: z.string().uuid(),
name: z.string().min(1).max(200),
@@ -36,11 +39,98 @@ export type CreateProjectInput = z.infer<typeof createProjectInput>;
export const archiveProjectInput = z.object({
projectId: z.string().uuid(),
lessonsLearnedUrl: z.string().url(),
abschlussvermerk: z.string().max(2000).optional(),
});
export type ArchiveProjectInput = z.infer<typeof archiveProjectInput>;
// --- Zieldreieck (Leitfaden Kap. 1) ----------------------------------------
export const zieldreieckFlexSchema = z.enum(['leistung', 'zeit', 'aufwand']);
// --- Projektphasen + Gate-Review (Leitfaden Kap. 2 + 5) --------------------
export const projectPhaseSchema = z.enum(['definition', 'planung', 'umsetzung', 'abschluss']);
export const PROJECT_PHASES = projectPhaseSchema.options;
export const setPhaseInput = z.object({
projectId: z.string().uuid(),
phase: projectPhaseSchema,
});
export type SetPhaseInput = z.infer<typeof setPhaseInput>;
export const addPhaseGateInput = z.object({
projectId: z.string().uuid(),
phase: projectPhaseSchema,
ergebnisErreicht: z.boolean(),
notiz: z.string().max(1000).optional(),
advance: z.boolean().default(false), // Folgephase direkt freigeben
});
export type AddPhaseGateInput = z.infer<typeof addPhaseGateInput>;
export const phaseGatesInput = z.object({ projectId: z.string().uuid() });
// --- Projekt-Statusbericht (Leitfaden Kap. 5) ------------------------------
export const addProjectStatusInput = z.object({
projectId: z.string().uuid(),
ampel: ampelSchema,
trend: trendSchema.optional(),
text: z.string().max(1000).optional(),
externSichtbar: z.boolean().default(false),
});
export type AddProjectStatusInput = z.infer<typeof addProjectStatusInput>;
export const projectStatusHistoryInput = z.object({ projectId: z.string().uuid() });
export const setMilestoneStatusInput = z.object({
milestoneId: z.string().uuid(),
status: z.enum(['open', 'done', 'overdue']),
});
export type SetMilestoneStatusInput = z.infer<typeof setMilestoneStatusInput>;
// --- Projekt-Modus (Buch Kap. 4 Strategie) ---------------------------------
export const projectModusSchema = z.enum(['relax', 'standard', 'eskalation', 'taskforce']);
export const PROJECT_MODI = projectModusSchema.options;
export const setModusInput = z.object({
projectId: z.string().uuid(),
modus: projectModusSchema,
});
export type SetModusInput = z.infer<typeof setModusInput>;
// --- Ziel-Disziplin: Maxime / Beschreibung / DoD editierbar ----------------
export const updateProjectInput = z.object({
projectId: z.string().uuid(),
maxime: z.string().max(280).nullable().optional(),
description: z.string().max(2000).nullable().optional(),
dodText: z.string().max(2000).nullable().optional(),
ziel: z.string().max(1000).nullable().optional(),
nichtZiele: z.string().max(2000).nullable().optional(),
liefergegenstaende: z.string().max(2000).nullable().optional(),
zieldreieckFlex: zieldreieckFlexSchema.nullable().optional(),
});
export type UpdateProjectInput = z.infer<typeof updateProjectInput>;
// --- Meilenstein-Disziplin: anlegen, editieren, entfernen ------------------
export const addMilestoneInput = z.object({
projectId: z.string().uuid(),
title: z.string().min(1).max(200),
due: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.optional(),
});
export type AddMilestoneInput = z.infer<typeof addMilestoneInput>;
export const updateMilestoneInput = z.object({
milestoneId: z.string().uuid(),
title: z.string().min(1).max(200).optional(),
due: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/)
.nullable()
.optional(),
ergebnis: z.string().max(2000).nullable().optional(),
kundennutzen: z.string().max(1000).nullable().optional(),
status: z.enum(['open', 'done', 'overdue']).optional(),
});
export type UpdateMilestoneInput = z.infer<typeof updateMilestoneInput>;
export const removeMilestoneInput = z.object({ milestoneId: z.string().uuid() });
export type RemoveMilestoneInput = z.infer<typeof removeMilestoneInput>;
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod';
export const riskKindSchema = z.enum(['risiko', 'chance']);
export const riskLevelSchema = z.enum(['niedrig', 'mittel', 'hoch']);
export const riskHerkunftSchema = z.enum(['intern', 'extern']);
export const riskStatusSchema = z.enum(['offen', 'beobachtet', 'eingetreten', 'geschlossen']);
export const RISK_STATUSES = riskStatusSchema.options;
export const riskStrategieSchema = z.enum(['vermeiden', 'verlagern', 'mindern', 'akzeptieren']);
export const RISK_STRATEGIEN = riskStrategieSchema.options;
export const riskForProjectInput = z.object({ projectId: z.string().uuid() });
export const createRiskInput = z.object({
projectId: z.string().uuid(),
kind: riskKindSchema.default('risiko'),
title: z.string().min(1).max(500),
wahrscheinlichkeit: riskLevelSchema.default('mittel'),
auswirkung: riskLevelSchema.default('mittel'),
herkunft: riskHerkunftSchema.optional(),
showstopper: z.boolean().default(false),
massnahme: z.string().max(1000).optional(),
strategie: riskStrategieSchema.optional(),
ownerUserId: z.string().uuid().optional(),
status: riskStatusSchema.default('offen'),
});
export type CreateRiskInput = z.infer<typeof createRiskInput>;
export const updateRiskInput = z.object({
id: z.string().uuid(),
kind: riskKindSchema.optional(),
title: z.string().min(1).max(500).optional(),
wahrscheinlichkeit: riskLevelSchema.optional(),
auswirkung: riskLevelSchema.optional(),
herkunft: riskHerkunftSchema.nullable().optional(),
showstopper: z.boolean().optional(),
massnahme: z.string().max(1000).nullable().optional(),
strategie: riskStrategieSchema.nullable().optional(),
ownerUserId: z.string().uuid().nullable().optional(),
status: riskStatusSchema.optional(),
});
export type UpdateRiskInput = z.infer<typeof updateRiskInput>;
export const removeRiskInput = z.object({ id: z.string().uuid() });
@@ -0,0 +1,32 @@
import { z } from 'zod';
export const stakeholderLevelSchema = z.enum(['niedrig', 'mittel', 'hoch']);
export const stakeholderHaltungSchema = z.enum(['pro', 'neutral', 'contra']);
export const stakeholderForProjectInput = z.object({ projectId: z.string().uuid() });
export const createStakeholderInput = z.object({
projectId: z.string().uuid(),
name: z.string().min(1).max(200),
rolle: z.string().max(200).optional(),
interesse: stakeholderLevelSchema.default('mittel'),
einfluss: stakeholderLevelSchema.default('mittel'),
haltung: stakeholderHaltungSchema.default('neutral'),
beduerfnis: z.string().max(1000).optional(),
notiz: z.string().max(1000).optional(),
});
export type CreateStakeholderInput = z.infer<typeof createStakeholderInput>;
export const updateStakeholderInput = z.object({
id: z.string().uuid(),
name: z.string().min(1).max(200).optional(),
rolle: z.string().max(200).nullable().optional(),
interesse: stakeholderLevelSchema.optional(),
einfluss: stakeholderLevelSchema.optional(),
haltung: stakeholderHaltungSchema.optional(),
beduerfnis: z.string().max(1000).nullable().optional(),
notiz: z.string().max(1000).nullable().optional(),
});
export type UpdateStakeholderInput = z.infer<typeof updateStakeholderInput>;
export const removeStakeholderInput = z.object({ id: z.string().uuid() });