AP1.1b: Vorhaben-Detail als Steuerungsfläche — Action-Hub statt paralleler Listen
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 1m26s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s

Leitfrage oben, Maßnahmen mit verknüpftem Kontext aus dem Snapshot, vollständiges Meilenstein-Formular, Backlog/Entscheidungen/Nachweise unter Erweitert. Version 0.11.2-ap1.1b.
This commit is contained in:
Lars 2026-07-05 17:39:18 +02:00
parent fc956d4ceb
commit a520d54bcf
10 changed files with 852 additions and 314 deletions

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.11.1-ap1.1" APP_VERSION = "0.11.2-ap1.1b"
DB_SCHEMA_VERSION = "009" DB_SCHEMA_VERSION = "009"
APP_NAME = "jinkendo-kairo" APP_NAME = "jinkendo-kairo"

View File

@ -1,6 +1,6 @@
{ {
"name": "kairo-jinkendo-frontend", "name": "kairo-jinkendo-frontend",
"version": "0.11.1-ap1.1", "version": "0.11.2-ap1.1b",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@ -0,0 +1,173 @@
import { ACTION_STATUSES, ACTION_STATUS_LABELS } from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
import { PriorityBadge } from './PriorityBadge.jsx'
import { ActionForm } from './ActionForm.jsx'
function formatDue(iso) {
if (!iso) return null
try {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
} catch {
return iso
}
}
function LinkedItems({ title, items, renderItem }) {
if (!items?.length) return null
return (
<div className="action-hub-linked">
<h4 className="action-hub-linked-title">{title}</h4>
<ul className="action-hub-linked-list">
{items.map((item) => (
<li key={item.id}>{renderItem(item)}</li>
))}
</ul>
</div>
)
}
export function ActionHubCard({
action,
context,
editing,
onEdit,
onCancelEdit,
onSubmitEdit,
onQuickStatus,
onCreateBlocker,
canManage,
canManageBlocker,
formBusy,
actors,
actorsLoading,
actorsError,
actorsUsedFallback,
onReloadActors,
}) {
const due = formatDue(action.due_at)
const isBlocked = action.status === 'blocked' || context?.has_open_blocker
if (editing) {
return (
<article className="action-hub-card action-hub-card--editing">
<ActionForm
initial={action}
actors={actors}
actorsLoading={actorsLoading}
actorsError={actorsError}
actorsUsedFallback={actorsUsedFallback}
onReloadActors={onReloadActors}
onSubmit={onSubmitEdit}
onCancel={onCancelEdit}
busy={formBusy}
submitLabel="Speichern"
/>
</article>
)
}
return (
<article
className={`action-hub-card${isBlocked ? ' action-hub-card--blocked' : ''}`}
>
<header className="action-hub-card-header">
<div className="action-hub-card-title-row">
<h3 className="action-hub-card-title">{action.title}</h3>
<div className="action-hub-card-badges">
<StatusBadge status={action.status} />
<PriorityBadge priority={action.priority} />
</div>
</div>
{action.description && (
<p className="action-hub-card-desc">{action.description}</p>
)}
<div className="action-hub-card-meta muted">
{action.assigned_actor_ids?.length > 0 && (
<span>{action.assigned_actor_ids.length} zugewiesen</span>
)}
{due && <span>Fällig: {due}</span>}
</div>
</header>
{context && (
<>
<LinkedItems
title="Blocker"
items={context.blockers?.filter((b) =>
['open', 'in_progress'].includes(b.status)
)}
renderItem={(b) => (
<>
<StatusBadge kind="blocker" status={b.status} />
<strong>{b.title}</strong>
</>
)}
/>
<LinkedItems
title="Nachweise"
items={context.evidence}
renderItem={(e) => (
<>
<StatusBadge kind="evidence" status={e.status} />
{e.title}
</>
)}
/>
<LinkedItems
title="Reviews"
items={context.reviews}
renderItem={(r) => (
<>
<StatusBadge kind="review" status={r.status} />
{r.title}
{r.due_at && (
<span className="muted"> · fällig {formatDue(r.due_at)}</span>
)}
</>
)}
/>
</>
)}
{canManage && (
<footer className="action-hub-card-actions">
<select
className="inline-select"
value={action.status}
onChange={(e) => onQuickStatus(e.target.value)}
aria-label="Status ändern"
>
{ACTION_STATUSES.map((s) => (
<option key={s} value={s}>
{ACTION_STATUS_LABELS[s]}
</option>
))}
</select>
{canManageBlocker && action.status !== 'done' && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={onCreateBlocker}
disabled={formBusy}
>
Blocker melden
</button>
)}
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onEdit}
>
Bearbeiten
</button>
</footer>
)}
</article>
)
}

View File

@ -13,22 +13,28 @@ export function BlockersSection({
onUpdateStatus, onUpdateStatus,
onDelete, onDelete,
busy, busy,
heading = 'Blocker',
lead = null,
emptyMessage = 'Keine Blocker.',
}) { }) {
const [title, setTitle] = useState('') const [formTitle, setFormTitle] = useState('')
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault() e.preventDefault()
if (!title.trim()) return if (!formTitle.trim()) return
await onCreate({ title: title.trim() }) await onCreate({ title: formTitle.trim() })
setTitle('') setFormTitle('')
setShowForm(false) setShowForm(false)
} }
return ( return (
<section className="card"> <section className="card">
<div className="section-header"> <div className="section-header">
<h2>Blocker</h2> <div>
<h2>{heading}</h2>
{lead && <p className="section-lead muted">{lead}</p>}
</div>
{canManage && ( {canManage && (
<button <button
type="button" type="button"
@ -45,8 +51,8 @@ export function BlockersSection({
<label> <label>
Titel Titel
<input <input
value={title} value={formTitle}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setFormTitle(e.target.value)}
maxLength={255} maxLength={255}
required required
/> />
@ -57,7 +63,7 @@ export function BlockersSection({
</form> </form>
)} )}
{blockers.length === 0 && <EmptyState message="Keine Blocker." />} {blockers.length === 0 && <EmptyState message={emptyMessage} />}
<ul className="item-list"> <ul className="item-list">
{blockers.map((blocker) => ( {blockers.map((blocker) => (

View File

@ -0,0 +1,29 @@
import { useState } from 'react'
export function CollapsibleSection({
title,
summary,
defaultOpen = false,
children,
className = '',
}) {
const [open, setOpen] = useState(defaultOpen)
return (
<section className={`collapsible-section card ${className}`.trim()}>
<button
type="button"
className="collapsible-section-trigger"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span className="collapsible-section-title">{title}</span>
{summary && <span className="collapsible-section-summary muted">{summary}</span>}
<span className="collapsible-section-chevron" aria-hidden="true">
{open ? '▾' : '▸'}
</span>
</button>
{open && <div className="collapsible-section-body">{children}</div>}
</section>
)
}

View File

@ -0,0 +1,103 @@
import { ActionHubCard } from './ActionHubCard.jsx'
import { ActionForm } from './ActionForm.jsx'
import { EmptyState } from './EmptyState.jsx'
export function InitiativeActionsHub({
actions,
actionContextById,
hideDone,
onHideDoneChange,
showForm,
onToggleForm,
editingActionId,
onEditAction,
onCancelEdit,
onCreateAction,
onUpdateAction,
onQuickStatus,
onCreateBlockerForAction,
canManage,
canManageBlocker,
formBusy,
actors,
actorsLoading,
actorsError,
actorsUsedFallback,
onReloadActors,
}) {
return (
<section className="card initiative-actions-hub">
<div className="section-header">
<div>
<h2>Maßnahmen operative Arbeit</h2>
<p className="section-lead muted">
Blocker, Nachweise und Reviews hängen an der Maßnahme, nicht lose im Vorhaben.
</p>
</div>
<div className="section-actions">
<label className="checkbox-label inline-filter">
<input
type="checkbox"
checked={hideDone}
onChange={(e) => onHideDoneChange(e.target.checked)}
/>
Erledigte ausblenden
</label>
{canManage && (
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={onToggleForm}
>
{showForm ? 'Abbrechen' : 'Maßnahme anlegen'}
</button>
)}
</div>
</div>
{showForm && canManage && (
<div className="inline-form-block">
<ActionForm
actors={actors}
actorsLoading={actorsLoading}
actorsError={actorsError}
actorsUsedFallback={actorsUsedFallback}
onReloadActors={onReloadActors}
onSubmit={onCreateAction}
onCancel={onToggleForm}
busy={formBusy}
submitLabel="Maßnahme anlegen"
/>
</div>
)}
{actions.length === 0 && (
<EmptyState message="Noch keine Maßnahmen — lege die erste operative Maßnahme an oder wandle Backlog um." />
)}
<div className="action-hub-grid">
{actions.map((action) => (
<ActionHubCard
key={action.id}
action={action}
context={actionContextById[action.id]}
editing={editingActionId === action.id}
onEdit={() => onEditAction(action.id)}
onCancelEdit={onCancelEdit}
onSubmitEdit={(payload) => onUpdateAction(action.id, payload)}
onQuickStatus={(status) => onQuickStatus(action, status)}
onCreateBlocker={() => onCreateBlockerForAction(action.id)}
canManage={canManage}
canManageBlocker={canManageBlocker}
formBusy={formBusy}
actors={actors}
actorsLoading={actorsLoading}
actorsError={actorsError}
actorsUsedFallback={actorsUsedFallback}
onReloadActors={onReloadActors}
/>
))}
</div>
</section>
)
}

View File

@ -6,6 +6,17 @@ import {
import { StatusBadge } from './StatusBadge.jsx' import { StatusBadge } from './StatusBadge.jsx'
import { EmptyState } from './EmptyState.jsx' import { EmptyState } from './EmptyState.jsx'
function formatDate(value) {
if (!value) return null
try {
return new Date(value.includes('T') ? value : `${value}T12:00:00`).toLocaleDateString(
'de-DE'
)
} catch {
return value
}
}
export function MilestonesSection({ export function MilestonesSection({
milestones, milestones,
canManage, canManage,
@ -13,22 +24,36 @@ export function MilestonesSection({
onUpdateStatus, onUpdateStatus,
onDelete, onDelete,
busy, busy,
compact = false,
}) { }) {
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [goalDescription, setGoalDescription] = useState('')
const [targetDate, setTargetDate] = useState('')
const [showForm, setShowForm] = useState(false) const [showForm, setShowForm] = useState(false)
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault() e.preventDefault()
if (!title.trim()) return if (!title.trim()) return
await onCreate({ title: title.trim() }) await onCreate({
title: title.trim(),
goal_description: goalDescription.trim(),
target_date: targetDate || undefined,
})
setTitle('') setTitle('')
setGoalDescription('')
setTargetDate('')
setShowForm(false) setShowForm(false)
} }
return ( return (
<section className="card"> <section className={`card${compact ? ' card--secondary' : ''}`}>
<div className="section-header"> <div className="section-header">
<h2>Meilensteine</h2> <div>
<h2>Meilensteine</h2>
<p className="section-lead muted">
Überprüfbare Zielpunkte kein Task. Ziel und Termin machen den Horizont steuerbar.
</p>
</div>
{canManage && ( {canManage && (
<button <button
type="button" type="button"
@ -41,7 +66,7 @@ export function MilestonesSection({
</div> </div>
{showForm && canManage && ( {showForm && canManage && (
<form className="inline-form-block" onSubmit={handleSubmit}> <form className="inline-form-block milestone-form" onSubmit={handleSubmit}>
<label> <label>
Titel Titel
<input <input
@ -49,6 +74,24 @@ export function MilestonesSection({
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
maxLength={255} maxLength={255}
required required
placeholder="z. B. MVP nutzbar im Alltag"
/>
</label>
<label>
Ziel / Definition of Done
<textarea
value={goalDescription}
onChange={(e) => setGoalDescription(e.target.value)}
rows={3}
placeholder="Woran erkennst du, dass dieser Meilenstein erreicht ist?"
/>
</label>
<label>
Zieltermin
<input
type="date"
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
/> />
</label> </label>
<button type="submit" className="btn btn-primary" disabled={busy}> <button type="submit" className="btn btn-primary" disabled={busy}>
@ -57,18 +100,22 @@ export function MilestonesSection({
</form> </form>
)} )}
{milestones.length === 0 && <EmptyState message="Keine Meilensteine." />} {milestones.length === 0 && (
<EmptyState message="Noch keine Meilensteine — strukturiere das Vorhaben in überprüfbare Zielpunkte." />
)}
<ul className="item-list"> <ul className="item-list milestone-list">
{milestones.map((milestone) => ( {milestones.map((milestone) => (
<li key={milestone.id} className="list-item card-list-item"> <li key={milestone.id} className="list-item card-list-item milestone-list-item">
<div className="list-item-main"> <div className="list-item-main">
<strong>{milestone.title}</strong> <strong>{milestone.title}</strong>
{milestone.goal_description && ( {milestone.goal_description && (
<p className="list-item-desc">{milestone.goal_description}</p> <p className="list-item-desc">{milestone.goal_description}</p>
)} )}
{milestone.target_date && ( {milestone.target_date && (
<p className="muted list-item-sub">Ziel: {milestone.target_date}</p> <p className="muted list-item-sub">
Zieltermin: {formatDate(milestone.target_date)}
</p>
)} )}
</div> </div>
<div className="list-item-meta action-controls"> <div className="list-item-meta action-controls">

View File

@ -1,4 +1,3 @@
import { Link } from 'react-router-dom'
import { import {
LIFECYCLE_LABELS, LIFECYCLE_LABELS,
PHASE_LABELS, PHASE_LABELS,
@ -69,35 +68,63 @@ export function SteeringSnapshotPanel({
? null ? null
: PHASE_DESCRIPTIONS[operating_phase] || '' : PHASE_DESCRIPTIONS[operating_phase] || ''
return ( const statPills = [
<section className="card steering-snapshot"> { label: 'Offen', value: counts.actions_open, warn: false },
<div className="section-header"> { label: 'Blockiert', value: counts.actions_blocked, warn: counts.actions_blocked > 0 },
<h2>Steuerungszustand</h2> { label: 'Review', value: counts.actions_review_required, warn: counts.actions_review_required > 0 },
<span className="badge status-badge status-active">{displayLabel}</span> { label: 'Blocker', value: counts.blockers_open, warn: counts.blockers_open > 0 },
</div> ]
{(method_label || canManageMethod) && ( return (
<p className="steering-method-row"> <section className="card steering-snapshot steering-snapshot--hero">
<span className="muted">Methode: </span> <header className="steering-hero-header">
{canManageMethod && methods.length > 0 ? ( <div>
<select <p className="steering-hero-kicker">Leitfrage</p>
className="inline-select steering-method-select" <h2 className="steering-hero-title">
value={method_key || 'generic_operating'} Wo stehe ich und was ist als Nächstes dran?
disabled={methodBusy} </h2>
onChange={(e) => onMethodChange?.(e.target.value)} </div>
aria-label="Steuerungsmethode" <span className="badge status-badge status-active steering-lifecycle-badge">
{displayLabel}
</span>
</header>
<div className="steering-hero-toolbar">
{(method_label || canManageMethod) && (
<p className="steering-method-row">
<span className="muted">Methode: </span>
{canManageMethod && methods.length > 0 ? (
<select
className="inline-select steering-method-select"
value={method_key || 'generic_operating'}
disabled={methodBusy}
onChange={(e) => onMethodChange?.(e.target.value)}
aria-label="Steuerungsmethode"
>
{methods.map((m) => (
<option key={m.key} value={m.key}>
{m.label}
</option>
))}
</select>
) : (
<strong>{method_label || method_key}</strong>
)}
</p>
)}
<ul className="steering-stat-pills" aria-label="Kurzüberblick">
{statPills.map((pill) => (
<li
key={pill.label}
className={`steering-stat-pill${pill.warn ? ' steering-stat-pill--warn' : ''}`}
> >
{methods.map((m) => ( <span className="steering-stat-pill-value">{pill.value}</span>
<option key={m.key} value={m.key}> <span className="steering-stat-pill-label">{pill.label}</span>
{m.label} </li>
</option> ))}
))} </ul>
</select> </div>
) : (
<strong>{method_label || method_key}</strong>
)}
</p>
)}
{phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>} {phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>}
@ -107,76 +134,55 @@ export function SteeringSnapshotPanel({
</p> </p>
)} )}
{next_actions.length > 0 && ( <div className="steering-hero-columns">
<div className="steering-next-actions"> {next_actions.length > 0 && (
<h3>Nächste Schritte für dieses Vorhaben</h3> <div className="steering-next-actions">
<ol className="item-list compact-list"> <h3>Nächste Schritte</h3>
{next_actions.map((item, i) => ( <ol className="item-list compact-list">
<li key={`${item.kind}-${i}`} className="list-item card-list-item compact"> {next_actions.map((item, i) => (
<div className="list-item-main"> <li key={`${item.kind}-${i}`} className="list-item card-list-item compact">
<span className="next-action-rank">{i + 1}</span> <div className="list-item-main">
<strong>{item.title}</strong> <span className="next-action-rank">{i + 1}</span>
<span className="muted list-item-sub"> <strong>{item.title}</strong>
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind} <span className="muted list-item-sub">
{item.recommended_action ? `${item.recommended_action}` : ''} {NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
</span> {item.recommended_action ? `${item.recommended_action}` : ''}
</div> </span>
</li> </div>
))} </li>
</ol> ))}
</div> </ol>
)}
{upcoming_milestones.length > 0 && (
<div className="steering-milestones">
<h3>Meilenstein-Horizont</h3>
<ul className="item-list compact-list">
{upcoming_milestones.map((ms) => (
<li key={ms.id} className="list-item card-list-item compact">
<div className="list-item-main">
<strong>{ms.title}</strong>
<span className="muted list-item-sub">
{MILESTONE_STATUS_LABELS[ms.status] || ms.status}
{ms.target_date ? ` · Ziel: ${formatDate(ms.target_date)}` : ''}
</span>
</div>
</li>
))}
</ul>
</div>
)}
<dl className="snapshot-counts">
<div>
<dt>Offene Maßnahmen</dt>
<dd>{counts.actions_open}</dd>
</div>
<div>
<dt>Blockiert</dt>
<dd>{counts.actions_blocked}</dd>
</div>
<div>
<dt>Review nötig</dt>
<dd>{counts.actions_review_required}</dd>
</div>
<div>
<dt>Offene Blocker</dt>
<dd>{counts.blockers_open}</dd>
</div>
{(counts.unlinked_blockers > 0 || counts.unlinked_evidence > 0) && (
<div className="snapshot-warn">
<dt>Unverknüpft</dt>
<dd>
{counts.unlinked_blockers} Blocker, {counts.unlinked_evidence} Evidence
</dd>
</div> </div>
)} )}
</dl>
<p className="muted snapshot-hint"> {upcoming_milestones.length > 0 && (
Steuerungszustand über Standard Lifecycle <div className="steering-milestones">
{lifecycle_state ? ` (${lifecycle_state})` : ''}. <h3>Meilenstein-Horizont</h3>
</p> <ul className="item-list compact-list">
{upcoming_milestones.map((ms) => (
<li key={ms.id} className="list-item card-list-item compact">
<div className="list-item-main">
<strong>{ms.title}</strong>
<span className="muted list-item-sub">
{MILESTONE_STATUS_LABELS[ms.status] || ms.status}
{ms.target_date ? ` · Ziel: ${formatDate(ms.target_date)}` : ''}
</span>
</div>
</li>
))}
</ul>
</div>
)}
</div>
{(counts.unlinked_blockers > 0 || counts.unlinked_evidence > 0) && (
<p className="steering-unlinked-hint muted">
{counts.unlinked_blockers > 0 && `${counts.unlinked_blockers} Blocker ohne Maßnahme`}
{counts.unlinked_blockers > 0 && counts.unlinked_evidence > 0 && ' · '}
{counts.unlinked_evidence > 0 && `${counts.unlinked_evidence} Nachweise ohne Maßnahme`}
{' — unter „Erweitert“ verknüpfen oder aufräumen.'}
</p>
)}
</section> </section>
) )
} }

View File

@ -50,10 +50,8 @@ import {
updateRecurring, updateRecurring,
deleteRecurring, deleteRecurring,
} from '../api/recurring.js' } from '../api/recurring.js'
import { ACTION_STATUSES, ACTION_STATUS_LABELS } from '../constants/status.js'
import { StatusBadge } from '../components/StatusBadge.jsx' import { StatusBadge } from '../components/StatusBadge.jsx'
import { PriorityBadge } from '../components/PriorityBadge.jsx' import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { ActionForm } from '../components/ActionForm.jsx'
import { BlockersSection } from '../components/BlockersSection.jsx' import { BlockersSection } from '../components/BlockersSection.jsx'
import { BacklogSection } from '../components/BacklogSection.jsx' import { BacklogSection } from '../components/BacklogSection.jsx'
import { MilestonesSection } from '../components/MilestonesSection.jsx' import { MilestonesSection } from '../components/MilestonesSection.jsx'
@ -62,8 +60,9 @@ import { DecisionsSection } from '../components/DecisionsSection.jsx'
import { ReviewsSection } from '../components/ReviewsSection.jsx' import { ReviewsSection } from '../components/ReviewsSection.jsx'
import { RecurringSection } from '../components/RecurringSection.jsx' import { RecurringSection } from '../components/RecurringSection.jsx'
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx' import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
import { InitiativeActionsHub } from '../components/InitiativeActionsHub.jsx'
import { CollapsibleSection } from '../components/CollapsibleSection.jsx'
import { listSteeringMethods, updateInitiativeSteeringMethod } from '../api/steering.js' import { listSteeringMethods, updateInitiativeSteeringMethod } from '../api/steering.js'
import { EmptyState } from '../components/EmptyState.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 { useCapabilities } from '../hooks/useCapabilities.js' import { useCapabilities } from '../hooks/useCapabilities.js'
@ -165,6 +164,20 @@ export function InitiativeDetailPage() {
(steeringSnapshot?.actions || []).map((a) => [a.id, a]) (steeringSnapshot?.actions || []).map((a) => [a.id, a])
) )
const unlinkedBlockers = blockers.filter((b) => !b.action_id)
const advancedSummary = (() => {
const c = steeringSnapshot?.counts
const parts = []
if (backlogItems.length) parts.push(`${backlogItems.length} Backlog`)
if (decisions.length) parts.push(`${decisions.length} Entscheidungen`)
if (recurringItems.length) parts.push(`${recurringItems.length} Wiederkehrend`)
if (unlinkedBlockers.length) parts.push(`${unlinkedBlockers.length} lose Blocker`)
if (c?.unlinked_evidence) parts.push(`${c.unlinked_evidence} lose Nachweise`)
if (reviews.length) parts.push(`${reviews.length} Reviews`)
return parts.length ? parts.join(' · ') : 'Backlog, Entscheidungen, Nachweise …'
})()
async function handleMethodChange(methodKey) { async function handleMethodChange(methodKey) {
setMethodBusy(true) setMethodBusy(true)
try { try {
@ -523,140 +536,32 @@ export function InitiativeDetailPage() {
/> />
)} )}
<section className="card"> <InitiativeActionsHub
<div className="section-header"> actions={visibleActions}
<h2>Maßnahmen</h2> actionContextById={actionContextById}
<div className="section-actions"> hideDone={hideDone}
<label className="checkbox-label inline-filter"> onHideDoneChange={setHideDone}
<input showForm={showActionForm}
type="checkbox" onToggleForm={() => {
checked={hideDone} setShowActionForm((v) => !v)
onChange={(e) => setHideDone(e.target.checked)} setEditingAction(null)
/> }}
Erledigte ausblenden editingActionId={editingAction}
</label> onEditAction={setEditingAction}
{capabilities.has('kairo.action.manage') && ( onCancelEdit={() => setEditingAction(null)}
<button onCreateAction={handleCreateAction}
type="button" onUpdateAction={handleUpdateAction}
className="btn btn-primary btn-block-mobile" onQuickStatus={handleQuickStatus}
onClick={() => { setShowActionForm((v) => !v); setEditingAction(null) }} onCreateBlockerForAction={handleCreateBlockerForAction}
> canManage={capabilities.has('kairo.action.manage')}
{showActionForm ? 'Abbrechen' : 'Maßnahme anlegen'} canManageBlocker={capabilities.has('kairo.blocker.manage')}
</button> formBusy={formBusy}
)} actors={actors}
</div> actorsLoading={actorsLoading}
</div> actorsError={actorsError}
actorsUsedFallback={actorsUsedFallback}
{showActionForm && ( onReloadActors={reloadActors}
<div className="inline-form-block"> />
<ActionForm
actors={actors}
actorsLoading={actorsLoading}
actorsError={actorsError}
actorsUsedFallback={actorsUsedFallback}
onReloadActors={reloadActors}
onSubmit={handleCreateAction}
onCancel={() => setShowActionForm(false)}
busy={formBusy}
submitLabel="Maßnahme anlegen"
/>
</div>
)}
{visibleActions.length === 0 && (
<EmptyState message="Noch keine Maßnahmen in diesem Vorhaben." />
)}
<ul className="item-list actions-detail-list">
{visibleActions.map((action) => {
const ctx = actionContextById[action.id]
return (
<li key={action.id} className="list-item card-list-item">
{editingAction === action.id ? (
<ActionForm
initial={action}
actors={actors}
actorsLoading={actorsLoading}
actorsError={actorsError}
actorsUsedFallback={actorsUsedFallback}
onReloadActors={reloadActors}
onSubmit={(payload) => handleUpdateAction(action.id, payload)}
onCancel={() => setEditingAction(null)}
busy={formBusy}
submitLabel="Speichern"
/>
) : (
<>
<div className="list-item-main">
<strong>{action.title}</strong>
{action.description && (
<p className="list-item-desc">{action.description}</p>
)}
{action.assigned_actor_ids?.length > 0 && (
<p className="muted list-item-sub">
Zugewiesen: {action.assigned_actor_ids.length} Actor(s)
</p>
)}
{action.due_at && (
<p className="muted list-item-sub">
Fällig: {new Date(action.due_at).toLocaleString('de-DE')}
</p>
)}
{ctx && (ctx.open_blocker_count > 0 || ctx.evidence?.length > 0 || ctx.reviews?.length > 0) && (
<p className="muted list-item-sub action-links">
{ctx.open_blocker_count > 0 && `${ctx.open_blocker_count} Blocker · `}
{ctx.evidence?.length > 0 && `${ctx.evidence.length} Evidence · `}
{ctx.reviews?.length > 0 && `${ctx.reviews.length} Review(s)`}
</p>
)}
</div>
<div className="list-item-meta action-controls">
<StatusBadge status={action.status} />
<PriorityBadge priority={action.priority} />
{capabilities.has('kairo.action.manage') && (
<>
<select
className="inline-select"
value={action.status}
onChange={(e) => handleQuickStatus(action, e.target.value)}
aria-label="Status ändern"
>
{ACTION_STATUSES.map((s) => (
<option key={s} value={s}>
{ACTION_STATUS_LABELS[s]}
</option>
))}
</select>
{capabilities.has('kairo.blocker.manage') && action.status !== 'done' && (
<button
type="button"
className="btn btn-secondary"
onClick={() => handleCreateBlockerForAction(action.id)}
disabled={formBusy}
>
Blocker
</button>
)}
<button
type="button"
className="btn btn-secondary"
onClick={() => {
setEditingAction(action.id)
setShowActionForm(false)
}}
>
Bearbeiten
</button>
</>
)}
</div>
</>
)}
</li>
)
})}
</ul>
</section>
{capabilities.has('kairo.initiative.read') && ( {capabilities.has('kairo.initiative.read') && (
<MilestonesSection <MilestonesSection
@ -670,70 +575,62 @@ export function InitiativeDetailPage() {
)} )}
{capabilities.has('kairo.initiative.read') && ( {capabilities.has('kairo.initiative.read') && (
<BacklogSection <CollapsibleSection title="Erweitert" summary={advancedSummary}>
items={backlogItems} <div className="initiative-advanced-stack">
canManage={capabilities.has('kairo.backlog.manage')} <BacklogSection
onCreate={handleCreateBacklog} items={backlogItems}
onUpdateStatus={handleBacklogStatus} canManage={capabilities.has('kairo.backlog.manage')}
onConvert={handleConvertBacklog} onCreate={handleCreateBacklog}
onDelete={handleDeleteBacklog} onUpdateStatus={handleBacklogStatus}
busy={formBusy} onConvert={handleConvertBacklog}
/> onDelete={handleDeleteBacklog}
)} busy={formBusy}
/>
{capabilities.has('kairo.initiative.read') && ( <DecisionsSection
<BlockersSection items={decisions}
blockers={blockers} canManage={capabilities.has('kairo.decision.manage')}
canManage={capabilities.has('kairo.blocker.manage')} onCreate={handleCreateDecision}
onCreate={handleCreateBlocker} onUpdateStatus={handleDecisionStatus}
onUpdateStatus={handleBlockerStatus} onDelete={handleDeleteDecision}
onDelete={handleDeleteBlocker} busy={formBusy}
busy={formBusy} />
/> <RecurringSection
)} items={recurringItems}
canManage={capabilities.has('kairo.recurring.manage')}
{capabilities.has('kairo.initiative.read') && ( onCreate={handleCreateRecurring}
<EvidenceSection onUpdateStatus={handleRecurringStatus}
items={evidenceItems} onDelete={handleDeleteRecurring}
canManage={capabilities.has('kairo.evidence.manage')} busy={formBusy}
onCreate={handleCreateEvidence} />
onUpdateStatus={handleEvidenceStatus} <BlockersSection
onDelete={handleDeleteEvidence} blockers={unlinkedBlockers}
busy={formBusy} heading="Vorhaben-Blocker"
/> lead="Ohne Maßnahmenbezug — verknüpfte Blocker erscheinen am Maßnahmen-Hub."
)} emptyMessage="Keine vorhabenweiten Blocker."
canManage={capabilities.has('kairo.blocker.manage')}
{capabilities.has('kairo.initiative.read') && ( onCreate={handleCreateBlocker}
<DecisionsSection onUpdateStatus={handleBlockerStatus}
items={decisions} onDelete={handleDeleteBlocker}
canManage={capabilities.has('kairo.decision.manage')} busy={formBusy}
onCreate={handleCreateDecision} />
onUpdateStatus={handleDecisionStatus} <EvidenceSection
onDelete={handleDeleteDecision} items={evidenceItems}
busy={formBusy} canManage={capabilities.has('kairo.evidence.manage')}
/> onCreate={handleCreateEvidence}
)} onUpdateStatus={handleEvidenceStatus}
onDelete={handleDeleteEvidence}
{capabilities.has('kairo.initiative.read') && ( busy={formBusy}
<ReviewsSection />
items={reviews} <ReviewsSection
canManage={capabilities.has('kairo.review.manage')} items={reviews}
onCreate={handleCreateReview} canManage={capabilities.has('kairo.review.manage')}
onUpdateStatus={handleReviewStatus} onCreate={handleCreateReview}
onDelete={handleDeleteReview} onUpdateStatus={handleReviewStatus}
busy={formBusy} onDelete={handleDeleteReview}
/> busy={formBusy}
)} />
</div>
{capabilities.has('kairo.initiative.read') && ( </CollapsibleSection>
<RecurringSection
items={recurringItems}
canManage={capabilities.has('kairo.recurring.manage')}
onCreate={handleCreateRecurring}
onUpdateStatus={handleRecurringStatus}
onDelete={handleDeleteRecurring}
busy={formBusy}
/>
)} )}
</div> </div>
) )

View File

@ -690,3 +690,280 @@
.action-links { .action-links {
font-size: 0.85rem; font-size: 0.85rem;
} }
/* AP1.1b — Initiative detail steering surface */
.section-lead {
margin: 0.25rem 0 0;
font-size: 0.875rem;
max-width: 42rem;
}
.steering-snapshot--hero {
border-color: var(--jk-accent-muted, #dbeafe);
background: linear-gradient(
180deg,
var(--jk-surface-raised, #f8fafc) 0%,
var(--jk-surface, #fff) 100%
);
}
.steering-hero-header {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem 1rem;
margin-bottom: 0.75rem;
}
.steering-hero-kicker {
margin: 0;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--jk-accent, #2563eb);
}
.steering-hero-title {
margin: 0.15rem 0 0;
font-size: 1.15rem;
line-height: 1.35;
}
.steering-lifecycle-badge {
flex-shrink: 0;
}
.steering-hero-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1.25rem;
margin-bottom: 0.75rem;
}
.steering-stat-pills {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
list-style: none;
margin: 0;
padding: 0;
}
.steering-stat-pill {
display: flex;
flex-direction: column;
align-items: center;
min-width: 3.25rem;
padding: 0.35rem 0.6rem;
border-radius: 0.5rem;
background: var(--jk-surface, #fff);
border: 1px solid var(--jk-border, #e5e7eb);
}
.steering-stat-pill--warn {
border-color: var(--jk-warning-border, #fcd34d);
background: var(--jk-warning-bg, #fffbeb);
}
.steering-stat-pill-value {
font-weight: 700;
font-size: 1rem;
line-height: 1.2;
}
.steering-stat-pill-label {
font-size: 0.7rem;
color: var(--jk-text-muted, #6b7280);
}
.steering-hero-columns {
display: grid;
gap: 1rem;
}
@media (min-width: 768px) {
.steering-hero-columns {
grid-template-columns: 1fr 1fr;
}
}
.steering-unlinked-hint {
margin: 0.75rem 0 0;
font-size: 0.85rem;
}
.initiative-actions-hub {
border-width: 2px;
}
.action-hub-grid {
display: grid;
gap: 0.75rem;
}
.action-hub-card {
border: 1px solid var(--jk-border, #e5e7eb);
border-radius: 0.65rem;
padding: 0.85rem 1rem;
background: var(--jk-surface, #fff);
}
.action-hub-card--blocked {
border-color: var(--jk-warning-border, #fbbf24);
background: var(--jk-warning-bg, #fffbeb);
}
.action-hub-card--editing {
padding: 0.5rem;
}
.action-hub-card-header {
margin-bottom: 0.5rem;
}
.action-hub-card-title-row {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.action-hub-card-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.action-hub-card-badges {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.action-hub-card-desc {
margin: 0.35rem 0 0;
font-size: 0.9rem;
color: var(--jk-text, #111);
}
.action-hub-card-meta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
margin-top: 0.35rem;
font-size: 0.8rem;
}
.action-hub-linked {
margin-top: 0.65rem;
padding-top: 0.65rem;
border-top: 1px dashed var(--jk-border, #e5e7eb);
}
.action-hub-linked-title {
margin: 0 0 0.35rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--jk-text-muted, #6b7280);
}
.action-hub-linked-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.action-hub-linked-list li {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
}
.action-hub-card-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.65rem;
border-top: 1px solid var(--jk-border, #e5e7eb);
}
.btn-sm {
padding: 0.25rem 0.55rem;
font-size: 0.8rem;
}
.card--secondary {
background: var(--jk-surface-raised, #f9fafb);
}
.milestone-form textarea {
min-height: 4.5rem;
resize: vertical;
}
.collapsible-section {
padding: 0;
overflow: hidden;
}
.collapsible-section-trigger {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 1rem;
width: 100%;
padding: 1rem 1.15rem;
border: none;
background: transparent;
text-align: left;
cursor: pointer;
font: inherit;
color: inherit;
}
.collapsible-section-trigger:hover {
background: var(--jk-surface-raised, #f9fafb);
}
.collapsible-section-title {
font-size: 1.05rem;
font-weight: 600;
}
.collapsible-section-summary {
flex: 1;
font-size: 0.85rem;
min-width: 8rem;
}
.collapsible-section-chevron {
margin-left: auto;
color: var(--jk-text-muted, #6b7280);
}
.collapsible-section-body {
padding: 0 1rem 1rem;
}
.initiative-advanced-stack {
display: grid;
gap: 0.75rem;
}
.initiative-advanced-stack .card {
box-shadow: none;
border-style: dashed;
}