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
Reference in new issue
Block a user