feat(projekt): Projektmanagement-Tiefe nach Buch & Leitfaden v3.0
ci / quality (push) Failing after 1m0s
ci / build (push) Has been skipped

Operationalisiert den ToolCTRL-Projektmanagement-Leitfaden auf der
Projekt-Detailseite (hybrid: Cockpit-Primitive nativ, Schwergewichtiges
per Outline-Link):

- Projekt-Modus (Relax/Standard/Eskalation/Taskforce) + Dashboard-Surfacing
- Risiko- & Chancen-Register (Wahrscheinlichkeit×Auswirkung, Showstopper,
  Owner, Strategie) — offene Showstopper landen auf dem Dashboard
- Stakeholder-Analyse mit Macht/Interesse-Matrix
- Ziel & Definition: SMART-Ziel, Nicht-Ziele, Liefergegenstaende, Maxime,
  Zieldreieck (welche Ecke gibt nach)
- Meilensteine editierbar inkl. Ergebnis/Kundennutzen, Freitags-/
  Feiertags-Warnung, Mindest-2-Hinweis
- Phasen-Lebenszyklus (Definition→Planung→Umsetzung→Abschluss) mit Gate-Reviews
- Projekt-Statusbericht (Ampel/Trend/Text, extern-Flag, Verlauf) — speist
  statusCache & Pflicht-Minimum
- Änderungs-Log (Scope-Creep-Schutz), strukturierter Abschluss mit
  Checkliste + Abschlussvermerk

Migrationen 0009_pm_insights + 0010_leitfaden_phases. Enthält auch die
gemeinsame schema.ts (inkl. Lead-Tabellen aus dem Vor-Commit) und Seed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dejanandClaude Opus 4.8 committed 2026-06-05 17:31:30 +02:00
1 parent d72aa9fbf3
commit 864a1cef6a
22 files changed
+9237 -60

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
// Anzeige-Metadaten für die PM-Insights-Features (Modus, Risiken, Stakeholder).
export type ProjectModus = 'relax' | 'standard' | 'eskalation' | 'taskforce';
export const MODUS_META: Record<ProjectModus, { label: string; badge: string; hint: string }> = {
relax: {
label: 'Relax',
badge: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]',
hint: 'Ruhige Phase, kein Druck.',
},
standard: {
label: 'Standard',
badge: 'bg-black/5 text-[var(--color-ink-muted)]',
hint: 'Normaler Projektbetrieb.',
},
eskalation: {
label: 'Eskalation',
badge: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]',
hint: 'Erhöhte Aufmerksamkeit nötig.',
},
taskforce: {
label: 'Taskforce',
badge: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]',
hint: 'Alle Kräfte gebündelt, höchste Priorität.',
},
};
export const MODI: ProjectModus[] = ['relax', 'standard', 'eskalation', 'taskforce'];
export type Level = 'niedrig' | 'mittel' | 'hoch';
export const LEVEL_META: Record<Level, { label: string; badge: string }> = {
niedrig: { label: 'Niedrig', badge: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]' },
mittel: { label: 'Mittel', badge: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]' },
hoch: { label: 'Hoch', badge: 'bg-[var(--color-ampel-red-bg)] text-[var(--color-ampel-red-fg)]' },
};
export const LEVELS: Level[] = ['niedrig', 'mittel', 'hoch'];
export type RiskStatus = 'offen' | 'beobachtet' | 'eingetreten' | 'geschlossen';
export const RISK_STATUS_LABEL: Record<RiskStatus, string> = {
offen: 'Offen',
beobachtet: 'Beobachtet',
eingetreten: 'Eingetreten',
geschlossen: 'Geschlossen',
};
export const RISK_STATUSES: RiskStatus[] = ['offen', 'beobachtet', 'eingetreten', 'geschlossen'];
export type Haltung = 'pro' | 'neutral' | 'contra';
export const HALTUNG_META: Record<Haltung, { label: string; dot: string }> = {
pro: { label: 'Pro', dot: 'bg-[var(--color-ampel-green-fg)]' },
neutral: { label: 'Neutral', dot: 'bg-[var(--color-ink-faint)]' },
contra: { label: 'Contra', dot: 'bg-[var(--color-ampel-red-fg)]' },
};
export const HALTUNGEN: Haltung[] = ['pro', 'neutral', 'contra'];
// Empfehlung je Quadrant der Macht/Interesse-Matrix (Einfluss × Interesse).
export function stakeholderStrategie(einfluss: Level, interesse: Level): string {
const hochEinfluss = einfluss === 'hoch';
const hochInteresse = interesse === 'hoch';
if (hochEinfluss && hochInteresse) return 'Eng managen';
if (hochEinfluss && !hochInteresse) return 'Zufrieden halten';
if (!hochEinfluss && hochInteresse) return 'Informiert halten';
return 'Beobachten';
}
export type ProjectPhase = 'definition' | 'planung' | 'umsetzung' | 'abschluss';
export const PHASES: ProjectPhase[] = ['definition', 'planung', 'umsetzung', 'abschluss'];
export const PHASE_META: Record<ProjectPhase, { label: string; gateZiel: string }> = {
definition: { label: 'Definition', gateZiel: 'Steckbrief abgestimmt und freigegeben' },
planung: { label: 'Planung', gateZiel: 'Plan steht — Backlog zerlegt, Meilensteine & Risiken erfasst' },
umsetzung: { label: 'Umsetzung', gateZiel: 'Liefergegenstände abgenommen' },
abschluss: { label: 'Abschluss', gateZiel: 'Projekt beendet, Erkenntnisse gesichert' },
};
export type ZieldreieckFlex = 'leistung' | 'zeit' | 'aufwand';
export const ZIELDREIECK_LABEL: Record<ZieldreieckFlex, string> = {
leistung: 'Leistung / Umfang',
zeit: 'Zeit',
aufwand: 'Aufwand',
};
export const ZIELDREIECK_FLEX: ZieldreieckFlex[] = ['leistung', 'zeit', 'aufwand'];
export type RiskStrategie = 'vermeiden' | 'verlagern' | 'mindern' | 'akzeptieren';
export const RISK_STRATEGIE_LABEL: Record<RiskStrategie, string> = {
vermeiden: 'Vermeiden',
verlagern: 'Verlagern',
mindern: 'Mindern',
akzeptieren: 'Akzeptieren',
};
export const RISK_STRATEGIEN: RiskStrategie[] = ['vermeiden', 'verlagern', 'mindern', 'akzeptieren'];
export type ChangeEntscheidung = 'offen' | 'genehmigt' | 'abgelehnt';
export const CHANGE_ENTSCHEIDUNG_META: Record<ChangeEntscheidung, { label: string; badge: string }> = {
offen: { label: 'Offen', badge: 'bg-[var(--color-ampel-amber-bg)] text-[var(--color-ampel-amber-fg)]' },
genehmigt: { label: 'Genehmigt', badge: 'bg-[var(--color-ampel-green-bg)] text-[var(--color-ampel-green-fg)]' },
abgelehnt: { label: 'Abgelehnt', badge: 'bg-black/5 text-[var(--color-ink-faint)]' },
};
export const CHANGE_ENTSCHEIDUNGEN: ChangeEntscheidung[] = ['offen', 'genehmigt', 'abgelehnt'];
// Datums-Warnungen nach Buch-Regeln (keine Meilensteine Freitag / vor Feiertagen).
export function dateWarnings(iso: string | null | undefined): string[] {
if (!iso) return [];
const d = new Date(`${iso}T00:00:00`);
if (Number.isNaN(d.getTime())) return [];
const out: string[] = [];
const wd = d.getDay(); // 0=So .. 6=Sa
if (wd === 5) out.push('Fällt auf einen Freitag — Buch-Regel: keine Meilensteine/Abgaben freitags.');
if (wd === 0 || wd === 6) out.push('Fällt aufs Wochenende.');
const month = d.getMonth();
const day = d.getDate();
if ((month === 11 && day >= 20) || (month === 0 && day <= 6)) {
out.push('Liegt in der Weihnachts-/Neujahrszeit — besser einige Wochen davor oder danach.');
}
return out;
}
+80 -2
View File
@@ -7,6 +7,8 @@ import { Button } from '../components/ui/Button';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { toast } from 'sonner';
import { clsx } from 'clsx';
import { LEAD_STATUS_META, type LeadStatus } from '../lib/lead-meta';
import { MODUS_META, type ProjectModus } from '../lib/project-meta';
const BEREICHE: Array<{ slug: 'entwicklung' | 'vertrieb' | 'marketing'; label: string }> = [
{ slug: 'entwicklung', label: 'Entwicklung' },
@@ -85,6 +87,7 @@ function Section({
name: string;
statusCache: 'green' | 'amber' | 'red' | null;
deadline: string | null;
modus: ProjectModus;
pflichtMinimumState: Record<string, boolean>;
}>;
}) {
@@ -104,7 +107,19 @@ function Section({
<Card.Root className="cursor-pointer hover:shadow-[var(--shadow-md)]">
<Card.Body className="flex items-center justify-between">
<div>
<p className="font-medium">{p.name}</p>
<p className="flex items-center gap-2 font-medium">
{p.name}
{p.modus !== 'standard' && (
<span
className={clsx(
'rounded-full px-2 py-0.5 text-[10px] font-medium',
MODUS_META[p.modus].badge,
)}
>
{MODUS_META[p.modus].label}
</span>
)}
</p>
<p className="text-xs text-[var(--color-ink-muted)]">
{p.deadline ? `bis ${p.deadline}` : 'kein Deadline'}
</p>
@@ -297,7 +312,70 @@ function Field({ label, children }: { label: string; children: React.ReactNode }
}
function VertriebDetail() {
return <ProjektListeFuerBereich slug="vertrieb" />;
return (
<div className="space-y-6">
<LeadsTeaser />
<ProjektListeFuerBereich slug="vertrieb" />
</div>
);
}
function LeadsTeaser() {
const q = trpc.lead.list.useQuery();
const leads = q.data ?? [];
const offen = leads.filter((l) => l.status !== 'won' && l.status !== 'lost');
const top = [...offen]
.sort((a, b) => b.score - a.score)
.slice(0, 3);
return (
<section>
<div className="mb-2 flex items-center justify-between">
<h2 className="text-lg font-semibold">Leads</h2>
<Link to="/leads" className="text-sm text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:underline">
Alle Leads ({leads.length}) →
</Link>
</div>
<Card.Root>
<Card.Body>
{q.isPending ? (
<p className="text-sm text-[var(--color-ink-muted)]">Lädt…</p>
) : offen.length === 0 ? (
<p className="text-sm text-[var(--color-ink-muted)]">
Keine offenen Leads.{' '}
<Link to="/leads" className="hover:underline">
Lege einen an.
</Link>
</p>
) : (
<ul className="divide-y divide-[var(--color-border)]">
{top.map((l) => {
const meta = LEAD_STATUS_META[l.status as LeadStatus];
return (
<li key={l.id}>
<Link
to={`/lead/${l.id}`}
className="flex items-center justify-between gap-2 py-2 hover:opacity-80"
>
<span className="flex items-center gap-2 text-sm">
<span className={clsx('h-2 w-2 rounded-full', meta.dot)} />
<span className="font-medium">{l.firma}</span>
{l.subSegment && (
<span className="text-xs text-[var(--color-ink-muted)]">{l.subSegment}</span>
)}
</span>
<span className="font-mono text-xs tabular-nums text-[var(--color-ink-muted)]">
{l.score}/10
</span>
</Link>
</li>
);
})}
</ul>
)}
</Card.Body>
</Card.Root>
</section>
);
}
function MarketingDetail() {
+57
View File
@@ -5,6 +5,8 @@ import { Card } from '../components/ui/Card';
import { Button } from '../components/ui/Button';
import { SkeletonCard } from '../components/ui/SkeletonCard';
import { TeamTimeSection } from '../components/time/TeamTimeSection';
import { MODUS_META, type ProjectModus } from '../lib/project-meta';
import { AlertTriangle } from 'lucide-react';
import { clsx } from 'clsx';
type Ampel = 'green' | 'amber' | 'red' | 'grey';
@@ -72,6 +74,61 @@ export function DashboardRoute() {
</div>
</section>
{q.data.showstoppers.length > 0 && (
<section>
<Card.Root className="border-[var(--color-ampel-red-fg)]">
<Card.Header>
<h2 className="flex items-center gap-2 text-lg font-semibold text-[var(--color-ampel-red-fg)]">
<AlertTriangle size={18} /> Showstopper
</h2>
</Card.Header>
<Card.Body>
<ul className="space-y-2">
{q.data.showstoppers.map((s) => (
<li
key={s.id}
className="flex items-start justify-between gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] p-3"
>
<span className="text-sm">{s.title}</span>
<Link
to={`/projekt/${s.projectId}`}
className="shrink-0 text-xs text-[var(--color-ink-muted)] hover:text-[var(--color-ink)] hover:underline"
>
{s.projectName} →
</Link>
</li>
))}
</ul>
</Card.Body>
</Card.Root>
</section>
)}
{q.data.kritischeModi.length > 0 && (
<section>
<h2 className="mb-2 text-lg font-semibold">Projekte in Eskalation / Taskforce</h2>
<div className="flex flex-wrap gap-2">
{q.data.kritischeModi.map((p) => (
<Link
key={p.id}
to={`/projekt/${p.id}`}
className="inline-flex items-center gap-2 rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-1.5 text-sm hover:border-[var(--color-ink)]"
>
<span
className={clsx(
'rounded-full px-2 py-0.5 text-[10px] font-medium',
MODUS_META[p.modus as ProjectModus].badge,
)}
>
{MODUS_META[p.modus as ProjectModus].label}
</span>
{p.name}
</Link>
))}
</div>
</section>
)}
<section className="grid grid-cols-1 gap-6 md:grid-cols-3">
<Card.Root>
<Card.Header>
File diff suppressed because it is too large. Load diff