AP1.5e: Projekt-Detail, Bearbeitung und Lifecycle in der Ausführung.
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 1m48s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped

Detail-Route mit vollem Formular, Struktur-Navigator statt Inline-CRUD, Lifecycle sichtbar im Vorhaben-Header und auf Ausführung (0.15.1-ap1.5e).
This commit is contained in:
Lars 2026-07-08 11:06:10 +02:00
parent 94e815e88a
commit 33831a8c2c
13 changed files with 827 additions and 173 deletions

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.15.0-ap1.6" APP_VERSION = "0.15.1-ap1.5e"
DB_SCHEMA_VERSION = "014" DB_SCHEMA_VERSION = "014"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -7,6 +7,7 @@ import { ActionDetailPage } from './pages/ActionDetailPage.jsx'
import { InitiativeOverviewPage } from './pages/initiative/InitiativeOverviewPage.jsx' import { InitiativeOverviewPage } from './pages/initiative/InitiativeOverviewPage.jsx'
import { InitiativePlanPage } from './pages/initiative/InitiativePlanPage.jsx' import { InitiativePlanPage } from './pages/initiative/InitiativePlanPage.jsx'
import { InitiativeExecutionPage } from './pages/initiative/InitiativeExecutionPage.jsx' import { InitiativeExecutionPage } from './pages/initiative/InitiativeExecutionPage.jsx'
import { ProjectDetailPage } from './pages/initiative/ProjectDetailPage.jsx'
import { InitiativeInboxPage } from './pages/initiative/InitiativeInboxPage.jsx' import { InitiativeInboxPage } from './pages/initiative/InitiativeInboxPage.jsx'
import { InitiativeJourneyPage } from './pages/initiative/InitiativeJourneyPage.jsx' import { InitiativeJourneyPage } from './pages/initiative/InitiativeJourneyPage.jsx'
import { RoadmapItemDetailPage } from './pages/initiative/RoadmapItemDetailPage.jsx' import { RoadmapItemDetailPage } from './pages/initiative/RoadmapItemDetailPage.jsx'
@ -42,6 +43,7 @@ function AppRoutes() {
<Route path="plan" element={<InitiativePlanPage />} /> <Route path="plan" element={<InitiativePlanPage />} />
<Route path="plan/items/:itemId" element={<RoadmapItemDetailPage />} /> <Route path="plan/items/:itemId" element={<RoadmapItemDetailPage />} />
<Route path="execution" element={<InitiativeExecutionPage />} /> <Route path="execution" element={<InitiativeExecutionPage />} />
<Route path="execution/projects/:projectId" element={<ProjectDetailPage />} />
<Route path="inbox" element={<InitiativeInboxPage />} /> <Route path="inbox" element={<InitiativeInboxPage />} />
<Route path="journey" element={<InitiativeJourneyPage />} /> <Route path="journey" element={<InitiativeJourneyPage />} />
<Route path="actions/:actionId" element={<ActionDetailPage nested />} /> <Route path="actions/:actionId" element={<ActionDetailPage nested />} />

View File

@ -4,6 +4,10 @@ export function listInitiativeProjects(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/projects`) return apiFetch(`/api/initiatives/${initiativeId}/projects`)
} }
export function getProject(projectId) {
return apiFetch(`/api/projects/${projectId}`)
}
export function createInitiativeProject(initiativeId, body) { export function createInitiativeProject(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/projects`, { return apiFetch(`/api/initiatives/${initiativeId}/projects`, {
method: 'POST', method: 'POST',

View File

@ -0,0 +1,81 @@
import { LIFECYCLE_LABELS } from '../constants/operating.js'
import { Link } from 'react-router-dom'
export function InitiativeLifecycleBar({
snapshot,
loading,
error,
initiativeId,
compact = false,
}) {
if (loading) {
return (
<section className="initiative-lifecycle-bar card card--flat">
<p className="muted">Steuerungszustand wird geladen </p>
</section>
)
}
if (error || !snapshot) {
return (
<section className="initiative-lifecycle-bar card card--flat initiative-lifecycle-bar--muted">
<p className="muted">
Steuerungszustand nicht verfügbar siehe Tab{' '}
<Link to={`/initiatives/${initiativeId}`}>Steuerung</Link>.
</p>
</section>
)
}
const displayLabel =
snapshot.lifecycle_label ||
LIFECYCLE_LABELS[snapshot.lifecycle_state] ||
snapshot.lifecycle_state
const stats = [
{ label: 'Offen', value: snapshot.counts?.actions_open ?? 0, warn: false },
{
label: 'Blockiert',
value: snapshot.counts?.actions_blocked ?? 0,
warn: (snapshot.counts?.actions_blocked ?? 0) > 0,
},
{
label: 'Review',
value: snapshot.counts?.actions_review_required ?? 0,
warn: (snapshot.counts?.actions_review_required ?? 0) > 0,
},
{
label: 'Blocker',
value: snapshot.counts?.blockers_open ?? 0,
warn: (snapshot.counts?.blockers_open ?? 0) > 0,
},
]
return (
<section
className={`initiative-lifecycle-bar card card--flat${compact ? ' initiative-lifecycle-bar--compact' : ''}`}
>
<div className="initiative-lifecycle-bar-main">
<div>
<p className="initiative-lifecycle-kicker">Programm-Fortschritt</p>
<span className="badge status-badge status-active steering-lifecycle-badge">
{displayLabel}
</span>
</div>
<Link className="btn btn-secondary btn-sm" to={`/initiatives/${initiativeId}`}>
Steuerung
</Link>
</div>
<div className="initiative-lifecycle-stats">
{stats.map(({ label, value, warn }) => (
<span
key={label}
className={`initiative-lifecycle-stat${warn ? ' initiative-lifecycle-stat--warn' : ''}`}
>
<strong>{value}</strong> {label}
</span>
))}
</div>
</section>
)
}

View File

@ -0,0 +1,140 @@
import { INITIATIVE_STATUSES, INITIATIVE_STATUS_LABELS } from '../constants/status.js'
import { GateSelect } from './GateSelect.jsx'
import { flattenProjectOptions } from '../utils/projectTree.js'
const CONTAINER_KINDS = [
{ value: '', label: '— Standard —' },
{ value: 'project', label: 'Projekt' },
{ value: 'stream', label: 'Stream' },
{ value: 'phase', label: 'Phase' },
{ value: 'release', label: 'Release' },
]
export function ProjectForm({
initial = {},
projects = [],
roadmapItems = [],
excludeProjectId = '',
onSubmit,
onCancel,
busy = false,
submitLabel = 'Speichern',
}) {
const parentOptions = flattenProjectOptions(
projects.filter((p) => p.id !== excludeProjectId),
)
async function handleSubmit(e) {
e.preventDefault()
const form = e.target
const parentValue = form.parent_project_id?.value ?? ''
const kindValue = form.container_kind?.value ?? ''
const gateValue = form.roadmap_item_id?.value ?? ''
const targetValue = form.target_date?.value ?? ''
const sortValue = form.sort_order?.value ?? '0'
await onSubmit({
title: form.title.value.trim(),
description: form.description.value,
status: form.status.value,
parent_project_id: parentValue || undefined,
clear_parent_project: parentValue === '',
container_kind: kindValue || undefined,
clear_container_kind: kindValue === '',
roadmap_item_id: gateValue || undefined,
clear_roadmap_item: gateValue === '',
target_date: targetValue || undefined,
clear_target_date: !targetValue,
sort_order: Number.parseInt(sortValue, 10) || 0,
})
}
return (
<form className="form workspace-form project-form" onSubmit={handleSubmit}>
<label>
Titel
<input
name="title"
defaultValue={initial.title || ''}
required
maxLength={255}
placeholder="z. B. Phase 1 — Grundlagen"
/>
</label>
<label>
Beschreibung
<textarea name="description" rows={4} defaultValue={initial.description || ''} />
</label>
<div className="form-row form-row--2">
<label>
Status
<select name="status" defaultValue={initial.status || 'active'}>
{INITIATIVE_STATUSES.map((s) => (
<option key={s} value={s}>
{INITIATIVE_STATUS_LABELS[s]}
</option>
))}
</select>
</label>
<label>
Art
<select name="container_kind" defaultValue={initial.container_kind || ''}>
{CONTAINER_KINDS.map(({ value, label }) => (
<option key={value || 'none'} value={value}>
{label}
</option>
))}
</select>
</label>
</div>
{parentOptions.length > 0 && (
<label>
Übergeordnet (optional)
<select name="parent_project_id" defaultValue={initial.parent_project_id || ''}>
<option value=""> oberste Ebene </option>
{parentOptions.map(({ project, label }) => (
<option key={project.id} value={project.id}>
{label}
</option>
))}
</select>
</label>
)}
{roadmapItems.length > 0 && (
<GateSelect
roadmapItems={roadmapItems}
defaultValue={initial.roadmap_item_id || ''}
/>
)}
<div className="form-row form-row--2">
<label>
Zieldatum (optional)
<input
type="date"
name="target_date"
defaultValue={initial.target_date || ''}
/>
</label>
<label>
Sortierung
<input
type="number"
name="sort_order"
defaultValue={String(initial.sort_order ?? 0)}
min={0}
/>
</label>
</div>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy}>
{busy ? 'Speichern …' : submitLabel}
</button>
{onCancel && (
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={busy}>
Abbrechen
</button>
)}
</div>
</form>
)
}

View File

@ -1,18 +1,18 @@
import { useMemo, useState } from 'react' import { Link } from 'react-router-dom'
import { useMemo } from 'react'
import { EmptyState } from './EmptyState.jsx' import { EmptyState } from './EmptyState.jsx'
import { StatusBadge } from './StatusBadge.jsx' import { StatusBadge } from './StatusBadge.jsx'
import { import {
buildProjectsByParent, buildProjectsByParent,
containerKindLabel, containerKindLabel,
flattenProjectOptions,
} from '../utils/projectTree.js' } from '../utils/projectTree.js'
import { GateSelect, gateTitleById } from './GateSelect.jsx' import { gateTitleById } from './GateSelect.jsx'
function ProjectTreeNodes({ function ProjectTreeNodes({
projects, projects,
roadmapItems, roadmapItems,
initiativeId,
parentId, parentId,
depth,
selectedProjectId, selectedProjectId,
onSelectProject, onSelectProject,
canManage, canManage,
@ -28,17 +28,16 @@ function ProjectTreeNodes({
<ul className="project-tree-children"> <ul className="project-tree-children">
{nodes.map((project) => ( {nodes.map((project) => (
<li key={project.id} className="project-tree-branch"> <li key={project.id} className="project-tree-branch">
<div className="list-item card-list-item project-tree-item"> <div
<div className="list-item-main"> className={`list-item card-list-item project-tree-item${selectedProjectId === project.id ? ' project-tree-item--selected' : ''}`}
<button >
type="button" <div className="list-item-main project-tree-item-main">
className="link-button" <Link
onClick={() => to={`/initiatives/${initiativeId}/execution/projects/${project.id}`}
onSelectProject(selectedProjectId === project.id ? '' : project.id) className="project-tree-title-link"
}
> >
<strong>{project.title}</strong> <strong>{project.title}</strong>
</button> </Link>
{project.container_kind && ( {project.container_kind && (
<span className="project-kind-badge muted"> <span className="project-kind-badge muted">
{containerKindLabel(project.container_kind)} {containerKindLabel(project.container_kind)}
@ -49,18 +48,29 @@ function ProjectTreeNodes({
Gate: {gateTitleById(roadmapItems, project.roadmap_item_id)} Gate: {gateTitleById(roadmapItems, project.roadmap_item_id)}
</span> </span>
)} )}
{project.description && (
<p className="list-item-desc muted">{project.description}</p>
)}
</div> </div>
<div className="list-item-meta action-controls"> <div className="list-item-meta action-controls project-tree-item-actions">
<StatusBadge kind="initiative" status={project.status} /> <StatusBadge kind="project" status={project.status} />
<button
type="button"
className={`btn btn-secondary btn-sm${selectedProjectId === project.id ? ' btn-primary' : ''}`}
title="Arbeitspakete filtern"
onClick={() =>
onSelectProject(selectedProjectId === project.id ? '' : project.id)
}
>
{selectedProjectId === project.id ? 'Filter aktiv' : 'Filtern'}
</button>
{canManage && ( {canManage && (
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary btn-sm"
disabled={busy} disabled={busy}
onClick={() => onDelete(project.id)} onClick={() => {
if (window.confirm(`Projekt „${project.title}“ wirklich löschen?`)) {
onDelete(project.id)
}
}}
> >
Löschen Löschen
</button> </button>
@ -70,8 +80,8 @@ function ProjectTreeNodes({
<ProjectTreeNodes <ProjectTreeNodes
projects={projects} projects={projects}
roadmapItems={roadmapItems} roadmapItems={roadmapItems}
initiativeId={initiativeId}
parentId={project.id} parentId={project.id}
depth={depth + 1}
selectedProjectId={selectedProjectId} selectedProjectId={selectedProjectId}
onSelectProject={onSelectProject} onSelectProject={onSelectProject}
canManage={canManage} canManage={canManage}
@ -85,157 +95,46 @@ function ProjectTreeNodes({
} }
export function ProjectsSection({ export function ProjectsSection({
initiativeId,
projects, projects,
roadmapItems = [], roadmapItems = [],
selectedProjectId, selectedProjectId,
onSelectProject, onSelectProject,
canManage, canManage,
onCreate,
onDelete, onDelete,
busy, busy,
}) { }) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [parentProjectId, setParentProjectId] = useState('')
const [containerKind, setContainerKind] = useState('')
const [gateId, setGateId] = useState('')
const [showForm, setShowForm] = useState(false)
const parentOptions = useMemo(
() => flattenProjectOptions(projects),
[projects]
)
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim()) return
const ok = await onCreate({
title: title.trim(),
description: description.trim(),
parent_project_id: parentProjectId || undefined,
container_kind: containerKind || undefined,
roadmap_item_id: gateId || undefined,
})
if (!ok) return
setTitle('')
setDescription('')
setParentProjectId('')
setContainerKind('')
setGateId('')
setShowForm(false)
}
function openFormUnderSelected() {
setParentProjectId(selectedProjectId || '')
setShowForm(true)
}
return ( return (
<section className="card projects-section"> <section className="card projects-section">
<div className="section-header"> <div className="section-header">
<div> <div>
<h2>Projekte</h2> <h2>Struktur</h2>
<p className="section-lead muted"> <p className="section-lead muted">
Operative Struktur verschachtelbar für große Vorhaben. Arbeitspakete nur an Projekte, Streams und Phasen Klick öffnet Detail &amp; Bearbeitung. Filter schränkt
Blatt-Projekten. Gates optional. Arbeitspakete ein; Anlage nur an Blatt-Ebenen für Actions.
</p> </p>
</div> </div>
{canManage && ( {canManage && (
<div className="section-header-actions"> <Link
{selectedProjectId && (
<button
type="button"
className="btn btn-secondary btn-block-mobile"
onClick={openFormUnderSelected}
>
Unterprojekt
</button>
)}
<button
type="button"
className="btn btn-primary btn-block-mobile" className="btn btn-primary btn-block-mobile"
onClick={() => setShowForm((v) => !v)} to={`/initiatives/${initiativeId}/execution/projects/new`}
> >
{showForm ? 'Abbrechen' : 'Projekt'} Neues Projekt
</button> </Link>
</div>
)} )}
</div> </div>
{showForm && canManage && (
<form className="inline-form-block" onSubmit={handleSubmit}>
<label>
Titel
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={255}
required
placeholder="z. B. Phase 1 — Grundlagen"
/>
</label>
<label>
Beschreibung
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
/>
</label>
{parentOptions.length > 0 && (
<label>
Unter (optional)
<select
value={parentProjectId}
onChange={(e) => setParentProjectId(e.target.value)}
>
<option value=""> oberste Ebene </option>
{parentOptions.map(({ project, label }) => (
<option key={project.id} value={project.id}>
{label}
</option>
))}
</select>
</label>
)}
<label>
Art (optional)
<select value={containerKind} onChange={(e) => setContainerKind(e.target.value)}>
<option value=""> Standard </option>
<option value="project">Projekt</option>
<option value="stream">Stream</option>
<option value="phase">Phase</option>
<option value="release">Release</option>
</select>
</label>
{roadmapItems.length > 0 && (
<label>
Zielzustand (Gate)
<select value={gateId} onChange={(e) => setGateId(e.target.value)}>
<option value=""> keins </option>
{roadmapItems.map((item) => (
<option key={item.id} value={item.id}>
{item.title}
</option>
))}
</select>
</label>
)}
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
</form>
)}
{projects.length === 0 && ( {projects.length === 0 && (
<EmptyState message="Noch keine Projekte — optional. Du kannst auch direkt Arbeitspakete anlegen." /> <EmptyState message="Noch keine Struktur — lege ein Projekt an oder starte direkt mit Arbeitspaketen." />
)} )}
{projects.length > 0 && (
<>
<ul className="item-list project-filter-list project-tree-list"> <ul className="item-list project-filter-list project-tree-list">
<li className="list-item card-list-item"> <li className="list-item card-list-item">
<button <button
type="button" type="button"
className={`btn btn-secondary${selectedProjectId === '' ? ' btn-primary' : ''}`} className={`btn btn-secondary btn-sm${selectedProjectId === '' ? ' btn-primary' : ''}`}
onClick={() => onSelectProject('')} onClick={() => onSelectProject('')}
> >
Alle Arbeitspakete Alle Arbeitspakete
@ -245,14 +144,16 @@ export function ProjectsSection({
<ProjectTreeNodes <ProjectTreeNodes
projects={projects} projects={projects}
roadmapItems={roadmapItems} roadmapItems={roadmapItems}
initiativeId={initiativeId}
parentId="" parentId=""
depth={1}
selectedProjectId={selectedProjectId} selectedProjectId={selectedProjectId}
onSelectProject={onSelectProject} onSelectProject={onSelectProject}
canManage={canManage} canManage={canManage}
onDelete={onDelete} onDelete={onDelete}
busy={busy} busy={busy}
/> />
</>
)}
</section> </section>
) )
} }

View File

@ -1,3 +1,12 @@
export const PROJECT_STATUSES = ['active', 'paused', 'completed', 'archived']
export const PROJECT_STATUS_LABELS = {
active: 'Aktiv',
paused: 'Pausiert',
completed: 'Abgeschlossen',
archived: 'Archiviert',
}
export const INITIATIVE_STATUSES = ['active', 'paused', 'completed', 'archived'] export const INITIATIVE_STATUSES = ['active', 'paused', 'completed', 'archived']
export const ACTION_STATUSES = [ export const ACTION_STATUSES = [
'open', 'open',
@ -130,6 +139,7 @@ export const PRIORITY_LABELS = {
export const STATUS_LABELS_BY_KIND = { export const STATUS_LABELS_BY_KIND = {
initiative: INITIATIVE_STATUS_LABELS, initiative: INITIATIVE_STATUS_LABELS,
project: PROJECT_STATUS_LABELS,
action: ACTION_STATUS_LABELS, action: ACTION_STATUS_LABELS,
blocker: BLOCKER_STATUS_LABELS, blocker: BLOCKER_STATUS_LABELS,
backlog: BACKLOG_STATUS_LABELS, backlog: BACKLOG_STATUS_LABELS,

View File

@ -37,6 +37,7 @@ import {
import { import {
listInitiativeProjects, listInitiativeProjects,
createInitiativeProject, createInitiativeProject,
updateProject,
deleteProject, deleteProject,
} from '../api/projects.js' } from '../api/projects.js'
import { import {
@ -432,15 +433,30 @@ export function InitiativeOperationsProvider({ children }) {
setFormBusy(true) setFormBusy(true)
setError(null) setError(null)
try { try {
await createInitiativeProject(id, body) const created = await createInitiativeProject(id, body)
try { try {
setProjects(await listInitiativeProjects(id)) setProjects(await listInitiativeProjects(id))
} catch (refreshErr) { } catch (refreshErr) {
setError( setError(
`Projekt angelegt, aber die Liste konnte nicht aktualisiert werden: ${refreshErr.message}`, `Projekt angelegt, aber die Liste konnte nicht aktualisiert werden: ${refreshErr.message}`,
) )
return false return created
} }
return created
} catch (err) {
setError(err.message)
return null
} finally {
setFormBusy(false)
}
}
async function handleUpdateProject(projectId, body) {
setFormBusy(true)
setError(null)
try {
await updateProject(projectId, body)
setProjects(await listInitiativeProjects(id))
return true return true
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
@ -454,9 +470,11 @@ export function InitiativeOperationsProvider({ children }) {
try { try {
await deleteProject(projectId) await deleteProject(projectId)
if (selectedProjectId === projectId) setSelectedProjectId('') if (selectedProjectId === projectId) setSelectedProjectId('')
await load() setProjects(await listInitiativeProjects(id))
return true
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
return false
} }
} }
@ -647,6 +665,7 @@ export function InitiativeOperationsProvider({ children }) {
handleMilestoneStatus: handleRoadmapItemStatus, handleMilestoneStatus: handleRoadmapItemStatus,
handleDeleteMilestone: handleDeleteRoadmapItem, handleDeleteMilestone: handleDeleteRoadmapItem,
handleCreateProject, handleCreateProject,
handleUpdateProject,
handleDeleteProject, handleDeleteProject,
handleCreateEvidence, handleCreateEvidence,
handleEvidenceStatus, handleEvidenceStatus,

View File

@ -5,9 +5,10 @@ import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { InitiativeSubNav } from '../components/InitiativeSubNav.jsx' import { InitiativeSubNav } from '../components/InitiativeSubNav.jsx'
import { ErrorState } from '../components/ErrorState.jsx' import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx' import { LoadingState } from '../components/LoadingState.jsx'
import { LIFECYCLE_LABELS } from '../constants/operating.js'
function InitiativeLayoutInner() { function InitiativeLayoutInner() {
const { initiative, loading, error, reload } = useInitiativeOperations() const { initiative, loading, error, reload, steeringSnapshot } = useInitiativeOperations()
if (loading) { if (loading) {
return ( return (
@ -31,6 +32,11 @@ function InitiativeLayoutInner() {
) )
} }
const lifecycleLabel =
steeringSnapshot?.lifecycle_label ||
LIFECYCLE_LABELS[steeringSnapshot?.lifecycle_state] ||
steeringSnapshot?.lifecycle_state
return ( return (
<div className="app-page initiative-layout"> <div className="app-page initiative-layout">
<nav className="breadcrumb"> <nav className="breadcrumb">
@ -48,6 +54,11 @@ function InitiativeLayoutInner() {
<div className="detail-badges"> <div className="detail-badges">
<StatusBadge kind="initiative" status={initiative.status} /> <StatusBadge kind="initiative" status={initiative.status} />
<PriorityBadge priority={initiative.priority} /> <PriorityBadge priority={initiative.priority} />
{lifecycleLabel && (
<span className="badge status-badge status-active initiative-header-lifecycle">
{lifecycleLabel}
</span>
)}
</div> </div>
</div> </div>
</header> </header>

View File

@ -2,6 +2,7 @@ import { useInitiativeOperations } from '../../context/InitiativeOperationsConte
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx' import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
import { ProjectsSection } from '../../components/ProjectsSection.jsx' import { ProjectsSection } from '../../components/ProjectsSection.jsx'
import { BlockersSection } from '../../components/BlockersSection.jsx' import { BlockersSection } from '../../components/BlockersSection.jsx'
import { InitiativeLifecycleBar } from '../../components/InitiativeLifecycleBar.jsx'
export function InitiativeExecutionPage() { export function InitiativeExecutionPage() {
const ops = useInitiativeOperations() const ops = useInitiativeOperations()
@ -23,19 +24,30 @@ export function InitiativeExecutionPage() {
unlinkedBlockers, unlinkedBlockers,
initiativeId, initiativeId,
error, error,
steeringSnapshot,
steeringSnapshotLoading,
steeringSnapshotError,
} = ops } = ops
return ( return (
<> <>
{error && <p className="error">{error}</p>} {error && <p className="error">{error}</p>}
<InitiativeLifecycleBar
snapshot={steeringSnapshot}
loading={steeringSnapshotLoading}
error={steeringSnapshotError}
initiativeId={initiativeId}
compact
/>
<ProjectsSection <ProjectsSection
initiativeId={initiativeId}
projects={projects} projects={projects}
roadmapItems={roadmapItems} roadmapItems={roadmapItems}
selectedProjectId={selectedProjectId} selectedProjectId={selectedProjectId}
onSelectProject={setSelectedProjectId} onSelectProject={setSelectedProjectId}
canManage={capabilities.has('kairo.project.manage')} canManage={capabilities.has('kairo.project.manage')}
onCreate={ops.handleCreateProject}
onDelete={ops.handleDeleteProject} onDelete={ops.handleDeleteProject}
busy={formBusy} busy={formBusy}
/> />

View File

@ -0,0 +1,326 @@
import { useMemo, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { ProjectForm } from '../../components/ProjectForm.jsx'
import { StatusBadge } from '../../components/StatusBadge.jsx'
import { EmptyState } from '../../components/EmptyState.jsx'
import { ErrorState } from '../../components/ErrorState.jsx'
import { gateTitleById } from '../../components/GateSelect.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import {
buildProjectPath,
buildProjectsByParent,
collectProjectSubtreeIds,
containerKindLabel,
} from '../../utils/projectTree.js'
function formatDate(iso) {
if (!iso) return '—'
try {
return new Date(iso).toLocaleDateString('de-DE')
} catch {
return iso
}
}
function ProjectDetailView({
project,
projects,
roadmapItems,
initiativeId,
childProjects,
linkedActions,
canManage,
onEdit,
onDelete,
onFilter,
busy,
}) {
const path = buildProjectPath(projects, project.id)
const gateTitle = gateTitleById(roadmapItems, project.roadmap_item_id)
return (
<div className="project-detail-view">
<dl className="detail-grid">
<div>
<dt>Status</dt>
<dd>
<StatusBadge kind="project" status={project.status} />
</dd>
</div>
<div>
<dt>Art</dt>
<dd>{containerKindLabel(project.container_kind) || 'Standard'}</dd>
</div>
<div>
<dt>Ebene</dt>
<dd>
{project.depth ?? path.length} · {project.is_leaf ? 'Blatt (Arbeitspakete möglich)' : 'Container'}
</dd>
</div>
<div>
<dt>Zieldatum</dt>
<dd>{formatDate(project.target_date)}</dd>
</div>
<div>
<dt>Gate</dt>
<dd>
{gateTitle ? (
<Link to={`/initiatives/${initiativeId}/plan/items/${project.roadmap_item_id}`}>
{gateTitle}
</Link>
) : (
'—'
)}
</dd>
</div>
<div>
<dt>Sortierung</dt>
<dd>{project.sort_order ?? 0}</dd>
</div>
</dl>
{project.description && (
<section className="project-detail-section">
<h3>Beschreibung</h3>
<p className="detail-text">{project.description}</p>
</section>
)}
{path.length > 1 && (
<section className="project-detail-section">
<h3>Pfad</h3>
<nav className="project-path-breadcrumb" aria-label="Projektpfad">
{path.map((node, index) => (
<span key={node.id}>
{index > 0 && <span aria-hidden="true"> </span>}
{index === path.length - 1 ? (
<strong>{node.title}</strong>
) : (
<Link to={`/initiatives/${initiativeId}/execution/projects/${node.id}`}>
{node.title}
</Link>
)}
</span>
))}
</nav>
</section>
)}
{childProjects.length > 0 && (
<section className="project-detail-section">
<h3>Unterprojekte ({childProjects.length})</h3>
<ul className="item-list">
{childProjects.map((child) => (
<li key={child.id} className="list-item card-list-item">
<Link to={`/initiatives/${initiativeId}/execution/projects/${child.id}`}>
<strong>{child.title}</strong>
</Link>
<StatusBadge kind="project" status={child.status} />
</li>
))}
</ul>
</section>
)}
<section className="project-detail-section">
<h3>Arbeitspakete ({linkedActions.length})</h3>
{linkedActions.length === 0 ? (
<EmptyState
message={
project.is_leaf
? 'Noch keine Arbeitspakete an diesem Projekt.'
: 'Arbeitspakete nur an Blatt-Projekten — wähle ein Unterprojekt.'
}
/>
) : (
<ul className="item-list">
{linkedActions.map((action) => (
<li key={action.id} className="list-item card-list-item">
<Link to={`/initiatives/${initiativeId}/actions/${action.id}`}>
<strong>{action.title}</strong>
</Link>
<StatusBadge kind="action" status={action.status} />
</li>
))}
</ul>
)}
</section>
<div className="project-detail-actions">
{canManage && (
<button type="button" className="btn btn-primary" onClick={onEdit} disabled={busy}>
Bearbeiten
</button>
)}
<button type="button" className="btn btn-secondary" onClick={onFilter} disabled={busy}>
In Ausführung filtern
</button>
{canManage && (
<Link
className="btn btn-secondary"
to={`/initiatives/${initiativeId}/execution/projects/new?parent=${project.id}`}
>
Unterprojekt
</Link>
)}
{canManage && (
<button type="button" className="btn btn-secondary" onClick={onDelete} disabled={busy}>
Löschen
</button>
)}
</div>
</div>
)
}
export function ProjectDetailPage() {
const { projectId } = useParams()
const navigate = useNavigate()
const ops = useInitiativeOperations()
const isNew = projectId === 'new'
const [editing, setEditing] = useState(isNew)
const [localError, setLocalError] = useState(null)
const project = useMemo(
() => (isNew ? null : ops.projects.find((p) => p.id === projectId)),
[isNew, ops.projects, projectId],
)
const childProjects = useMemo(() => {
if (!project) return []
const byParent = buildProjectsByParent(ops.projects)
return byParent.get(project.id) || []
}, [project, ops.projects])
const linkedActions = useMemo(() => {
if (!project) return []
const ids = collectProjectSubtreeIds(ops.projects, project.id)
return ops.actions.filter((a) => a.project_id && ids.has(a.project_id))
}, [project, ops.projects, ops.actions])
const canManage = ops.capabilities.has('kairo.project.manage')
const parentPreset =
typeof window !== 'undefined'
? new URLSearchParams(window.location.search).get('parent') || ''
: ''
if (!isNew && !project && !ops.loading) {
return <ErrorState message="Projekt nicht gefunden." onRetry={ops.reload} />
}
async function handleCreate(payload) {
setLocalError(null)
const created = await ops.handleCreateProject(payload)
if (created?.id) {
navigate(`/initiatives/${ops.initiativeId}/execution/projects/${created.id}`, { replace: true })
} else if (ops.error) {
setLocalError(ops.error)
}
}
async function handleUpdate(payload) {
setLocalError(null)
const ok = await ops.handleUpdateProject(project.id, payload)
if (ok) {
setEditing(false)
} else if (ops.error) {
setLocalError(ops.error)
}
}
async function handleDelete() {
if (!project || !window.confirm(`Projekt „${project.title}“ wirklich löschen?`)) return
setLocalError(null)
const ok = await ops.handleDeleteProject(project.id)
if (ok) {
navigate(`/initiatives/${ops.initiativeId}/execution`)
} else if (ops.error) {
setLocalError(ops.error)
}
}
function handleFilter() {
if (project) ops.setSelectedProjectId(project.id)
navigate(`/initiatives/${ops.initiativeId}/execution`)
}
const title = isNew ? 'Neues Projekt' : project?.title || 'Projekt'
return (
<section className="card project-detail-page">
<nav className="breadcrumb breadcrumb--inline">
<Link to={`/initiatives/${ops.initiativeId}/execution`}>Ausführung</Link>
<span aria-hidden="true"> / </span>
<span>{title}</span>
</nav>
<header className="section-header">
<div>
<h2>{title}</h2>
{!isNew && project && (
<div className="detail-badges">
<StatusBadge kind="project" status={project.status} />
{project.container_kind && (
<span className="project-kind-badge">{containerKindLabel(project.container_kind)}</span>
)}
</div>
)}
{isNew && (
<p className="section-lead muted">
Struktur-Container unter dem Vorhaben Bearbeitung und Status hier pflegen, nicht in der Liste.
</p>
)}
</div>
{!isNew && canManage && !editing && (
<button
type="button"
className="btn btn-secondary"
onClick={() => setEditing(true)}
disabled={ops.formBusy}
>
Bearbeiten
</button>
)}
</header>
{(localError || ops.error) && <p className="error">{localError || ops.error}</p>}
{editing ? (
<ProjectForm
initial={
isNew
? { parent_project_id: parentPreset, status: 'active' }
: project
}
projects={ops.projects}
roadmapItems={ops.roadmapItems}
excludeProjectId={project?.id || ''}
onSubmit={isNew ? handleCreate : handleUpdate}
onCancel={
isNew
? () => navigate(`/initiatives/${ops.initiativeId}/execution`)
: () => setEditing(false)
}
busy={ops.formBusy}
submitLabel={isNew ? 'Anlegen' : 'Speichern'}
/>
) : (
project && (
<ProjectDetailView
project={project}
projects={ops.projects}
roadmapItems={ops.roadmapItems}
initiativeId={ops.initiativeId}
childProjects={childProjects}
linkedActions={linkedActions}
canManage={canManage}
onEdit={() => setEditing(true)}
onDelete={handleDelete}
onFilter={handleFilter}
busy={ops.formBusy}
/>
)
)}
</section>
)
}

View File

@ -1221,3 +1221,140 @@
margin: 0; margin: 0;
} }
.card--flat {
box-shadow: none;
border: 1px solid var(--jk-border, #e2e8f0);
}
.initiative-header-lifecycle {
font-size: 0.85rem;
}
.initiative-lifecycle-bar {
margin-bottom: 1rem;
padding: 0.85rem 1rem;
}
.initiative-lifecycle-bar--compact {
padding: 0.65rem 0.85rem;
}
.initiative-lifecycle-bar-main {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.initiative-lifecycle-kicker {
margin: 0 0 0.25rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--jk-muted, #64748b);
}
.initiative-lifecycle-stats {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
margin-top: 0.65rem;
font-size: 0.85rem;
color: var(--jk-muted, #64748b);
}
.initiative-lifecycle-stat strong {
color: var(--jk-text, #0f172a);
}
.initiative-lifecycle-stat--warn strong {
color: var(--jk-danger, #b91c1c);
}
.project-tree-item--selected {
border-color: var(--jk-accent, #2563eb);
background: var(--jk-surface-raised, #f8fafc);
}
.project-tree-item-main {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.35rem;
}
.project-tree-title-link {
color: inherit;
text-decoration: none;
}
.project-tree-title-link:hover {
text-decoration: underline;
}
.project-tree-item-actions {
gap: 0.35rem;
}
.project-detail-page .breadcrumb--inline {
margin-bottom: 0.75rem;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
gap: 0.75rem 1.25rem;
margin: 0 0 1rem;
}
.detail-grid dt {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--jk-muted, #64748b);
margin: 0 0 0.2rem;
}
.detail-grid dd {
margin: 0;
}
.project-detail-section {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--jk-border, #e2e8f0);
}
.project-detail-section h3 {
margin: 0 0 0.5rem;
font-size: 1rem;
}
.project-detail-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px solid var(--jk-border, #e2e8f0);
}
.project-path-breadcrumb {
font-size: 0.9rem;
}
.form-row {
display: grid;
gap: 0.75rem;
}
.form-row--2 {
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
}
.project-form textarea {
min-height: 5rem;
resize: vertical;
}

View File

@ -12,6 +12,17 @@ export function containerKindLabel(kind) {
return CONTAINER_KIND_LABELS[kind] || kind return CONTAINER_KIND_LABELS[kind] || kind
} }
export function buildProjectPath(projects, projectId) {
const byId = Object.fromEntries(projects.map((p) => [p.id, p]))
const path = []
let current = byId[projectId]
while (current) {
path.unshift(current)
current = current.parent_project_id ? byId[current.parent_project_id] : null
}
return path
}
export function buildProjectsByParent(projects) { export function buildProjectsByParent(projects) {
const byParent = new Map() const byParent = new Map()
for (const project of projects) { for (const project of projects) {