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
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:
parent
94e815e88a
commit
33831a8c2c
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
81
frontend/src/components/InitiativeLifecycleBar.jsx
Normal file
81
frontend/src/components/InitiativeLifecycleBar.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
140
frontend/src/components/ProjectForm.jsx
Normal file
140
frontend/src/components/ProjectForm.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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 & 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
326
frontend/src/pages/initiative/ProjectDetailPage.jsx
Normal file
326
frontend/src/pages/initiative/ProjectDetailPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user