Feature: Lessons-Learned-Vorlage beim Projekt-Abschluss

Der Abschluss verlangte eine URL + freie Vermerk-Textarea, half aber nicht beim
Strukturieren der Retro. Jetzt gefuehrte Felder (Ziel erreicht? / Aufwand vs.
Plan / was lief gut / was nicht), gespeichert als JSONB project.lessons_learned
(Migration 0018). Anzeige im Projekt-Detail als "Retrospektive"; Altprojekte
fallen auf den freien Abschlussvermerk zurueck.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Raffael SheikhandClaude Opus 4.8 committed 2026-06-07 12:45:19 +02:00
1 parent 3e66ba3156
commit 62945201b1
7 files changed
+3443 -20

No files matched your search

+1
View File
@@ -137,6 +137,7 @@ export const projectRouter = router({
phase: 'abschluss',
lessonsLearnedUrl: input.lessonsLearnedUrl,
abschlussvermerk: input.abschlussvermerk ?? p.abschlussvermerk,
lessonsLearned: input.lessonsLearned ?? p.lessonsLearned,
pflichtMinimumState: { ...p.pflichtMinimumState, retrospektive: true },
})
.where(eq(project.id, input.projectId))
+66 -20
View File
@@ -143,7 +143,7 @@ export function ProjektRoute() {
{abschlussOpen && (
<AbschlussDialog
projectId={p.id}
abschlussvermerk={p.abschlussvermerk}
lessonsLearned={p.lessonsLearned}
onClose={() => setAbschlussOpen(false)}
/>
)}
@@ -205,15 +205,29 @@ export function ProjektRoute() {
</Card.Root>
)}
{p.abschlussvermerk && (
{p.lessonsLearned ? (
<Card.Root>
<Card.Header>
<h2 className="text-lg font-semibold">Abschlussvermerk</h2>
<h2 className="text-lg font-semibold">Retrospektive</h2>
</Card.Header>
<Card.Body>
<p className="whitespace-pre-wrap text-sm">{p.abschlussvermerk}</p>
<Card.Body className="space-y-3 text-sm">
<RetroField label="Ziel erreicht?" value={p.lessonsLearned.zielErreicht} />
<RetroField label="Aufwand vs. Plan" value={p.lessonsLearned.aufwand} />
<RetroField label="Was lief gut?" value={p.lessonsLearned.liefGut} />
<RetroField label="Was lief nicht gut?" value={p.lessonsLearned.liefSchlecht} />
</Card.Body>
</Card.Root>
) : (
p.abschlussvermerk && (
<Card.Root>
<Card.Header>
<h2 className="text-lg font-semibold">Abschlussvermerk</h2>
</Card.Header>
<Card.Body>
<p className="whitespace-pre-wrap text-sm">{p.abschlussvermerk}</p>
</Card.Body>
</Card.Root>
)
)}
</div>
);
@@ -2305,17 +2319,37 @@ function AddMilestoneForm({ projectId, onDone }: { projectId: string; onDone: ()
);
}
function RetroField({ label, value }: { label: string; value: string }) {
if (!value?.trim()) return null;
return (
<div>
<p className="text-xs font-medium text-[var(--color-ink-muted)]">{label}</p>
<p className="whitespace-pre-wrap">{value}</p>
</div>
);
}
function AbschlussDialog({
projectId,
abschlussvermerk,
lessonsLearned,
onClose,
}: {
projectId: string;
abschlussvermerk: string | null;
lessonsLearned: {
zielErreicht: string;
aufwand: string;
liefGut: string;
liefSchlecht: string;
} | null;
onClose: () => void;
}) {
const [url, setUrl] = useState('');
const [vermerk, setVermerk] = useState(abschlussvermerk ?? '');
const [ll, setLl] = useState({
zielErreicht: lessonsLearned?.zielErreicht ?? '',
aufwand: lessonsLearned?.aufwand ?? '',
liefGut: lessonsLearned?.liefGut ?? '',
liefSchlecht: lessonsLearned?.liefSchlecht ?? '',
});
const [checks, setChecks] = useState({ abnahme: false, aufraeumen: false, retro: false });
const navigate = useNavigate();
const utils = trpc.useUtils();
@@ -2330,10 +2364,11 @@ function AbschlussDialog({
});
function submit(e: React.FormEvent) {
e.preventDefault();
const hasLl = Object.values(ll).some((v) => v.trim());
mutation.mutate({
projectId,
lessonsLearnedUrl: url,
abschlussvermerk: vermerk.trim() || undefined,
lessonsLearned: hasLl ? ll : undefined,
});
}
const checkItems: Array<{ key: keyof typeof checks; label: string }> = [
@@ -2360,17 +2395,28 @@ function AbschlussDialog({
</label>
))}
</div>
<label className="block">
<span className="text-sm font-medium">Abschlussvermerk</span>
<textarea
value={vermerk}
onChange={(e) => setVermerk(e.target.value)}
rows={3}
maxLength={2000}
placeholder="Ziel erreicht? Aufwand gegen Plan? Wichtigste Erkenntnisse — eine halbe Seite, kein Roman."
className={clsx(inputCls, 'mt-1')}
/>
</label>
<div className="space-y-2">
<p className="text-sm font-medium">Retrospektive</p>
{(
[
['zielErreicht', 'Ziel erreicht?'],
['aufwand', 'Aufwand vs. Plan'],
['liefGut', 'Was lief gut?'],
['liefSchlecht', 'Was lief nicht gut?'],
] as const
).map(([key, label]) => (
<label key={key} className="block">
<span className="text-xs text-[var(--color-ink-muted)]">{label}</span>
<textarea
value={ll[key]}
onChange={(e) => setLl((s) => ({ ...s, [key]: e.target.value }))}
rows={2}
maxLength={2000}
className={clsx(inputCls, 'mt-0.5')}
/>
</label>
))}
</div>
<label className="block">
<span className="text-sm font-medium">Lessons-Learned-URL (Outline)</span>
<input
@@ -0,0 +1 @@
ALTER TABLE "project" ADD COLUMN "lessons_learned" jsonb;
File diff suppressed because it is too large. Load diff
@@ -127,6 +127,13 @@
"when": 1780826387755,
"tag": "0017_rare_oracle",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1780828837324,
"tag": "0018_last_lake",
"breakpoints": true
}
]
}
+8
View File
@@ -166,6 +166,14 @@ export const project = pgTable(
lessonsLearnedUrl: text('lessons_learned_url'),
// Abschluss (Leitfaden Phase 4): kurzer Abschlussvermerk — Ziel erreicht? Aufwand vs. Plan?
abschlussvermerk: text('abschlussvermerk'),
// Strukturierte Retrospektive (Lessons-Learned-Vorlage). Ersetzt die freie
// Vermerk-Textarea durch geführte Felder; alte Einträge nutzen abschlussvermerk.
lessonsLearned: jsonb('lessons_learned').$type<{
zielErreicht: string;
aufwand: string;
liefGut: string;
liefSchlecht: string;
} | 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(),
+10
View File
@@ -44,10 +44,20 @@ export const createProjectInput = z.object({
});
export type CreateProjectInput = z.infer<typeof createProjectInput>;
export const lessonsLearnedSchema = z.object({
zielErreicht: z.string().max(2000),
aufwand: z.string().max(2000),
liefGut: z.string().max(2000),
liefSchlecht: z.string().max(2000),
});
export type LessonsLearned = z.infer<typeof lessonsLearnedSchema>;
export const archiveProjectInput = z.object({
projectId: z.string().uuid(),
lessonsLearnedUrl: z.string().url(),
abschlussvermerk: z.string().max(2000).optional(),
// Strukturierte Retrospektive (Lessons-Learned-Vorlage), optional.
lessonsLearned: lessonsLearnedSchema.optional(),
});
export type ArchiveProjectInput = z.infer<typeof archiveProjectInput>;