feat(projekt): Projektmanagement-Tiefe nach Buch & Leitfaden v3.0
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:
1 parent
d72aa9fbf3
commit
864a1cef6a
22 files changed
+9237
-60
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1580
-52
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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
@@ -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() });
|
||||
@@ -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>;
|
||||
@@ -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() });
|
||||
Reference in new issue
Block a user