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
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:
parent
fc956d4ceb
commit
a520d54bcf
|
|
@ -1,3 +1,3 @@
|
|||
APP_VERSION = "0.11.1-ap1.1"
|
||||
APP_VERSION = "0.11.2-ap1.1b"
|
||||
DB_SCHEMA_VERSION = "009"
|
||||
APP_NAME = "jinkendo-kairo"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "kairo-jinkendo-frontend",
|
||||
"version": "0.11.1-ap1.1",
|
||||
"version": "0.11.2-ap1.1b",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
|
|
|||
173
frontend/src/components/ActionHubCard.jsx
Normal file
173
frontend/src/components/ActionHubCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -13,22 +13,28 @@ export function BlockersSection({
|
|||
onUpdateStatus,
|
||||
onDelete,
|
||||
busy,
|
||||
heading = 'Blocker',
|
||||
lead = null,
|
||||
emptyMessage = 'Keine Blocker.',
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [formTitle, setFormTitle] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!title.trim()) return
|
||||
await onCreate({ title: title.trim() })
|
||||
setTitle('')
|
||||
if (!formTitle.trim()) return
|
||||
await onCreate({ title: formTitle.trim() })
|
||||
setFormTitle('')
|
||||
setShowForm(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<div className="section-header">
|
||||
<h2>Blocker</h2>
|
||||
<div>
|
||||
<h2>{heading}</h2>
|
||||
{lead && <p className="section-lead muted">{lead}</p>}
|
||||
</div>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -45,8 +51,8 @@ export function BlockersSection({
|
|||
<label>
|
||||
Titel
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
value={formTitle}
|
||||
onChange={(e) => setFormTitle(e.target.value)}
|
||||
maxLength={255}
|
||||
required
|
||||
/>
|
||||
|
|
@ -57,7 +63,7 @@ export function BlockersSection({
|
|||
</form>
|
||||
)}
|
||||
|
||||
{blockers.length === 0 && <EmptyState message="Keine Blocker." />}
|
||||
{blockers.length === 0 && <EmptyState message={emptyMessage} />}
|
||||
|
||||
<ul className="item-list">
|
||||
{blockers.map((blocker) => (
|
||||
|
|
|
|||
29
frontend/src/components/CollapsibleSection.jsx
Normal file
29
frontend/src/components/CollapsibleSection.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
frontend/src/components/InitiativeActionsHub.jsx
Normal file
103
frontend/src/components/InitiativeActionsHub.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,17 @@ import {
|
|||
import { StatusBadge } from './StatusBadge.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({
|
||||
milestones,
|
||||
canManage,
|
||||
|
|
@ -13,22 +24,36 @@ export function MilestonesSection({
|
|||
onUpdateStatus,
|
||||
onDelete,
|
||||
busy,
|
||||
compact = false,
|
||||
}) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [goalDescription, setGoalDescription] = useState('')
|
||||
const [targetDate, setTargetDate] = useState('')
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!title.trim()) return
|
||||
await onCreate({ title: title.trim() })
|
||||
await onCreate({
|
||||
title: title.trim(),
|
||||
goal_description: goalDescription.trim(),
|
||||
target_date: targetDate || undefined,
|
||||
})
|
||||
setTitle('')
|
||||
setGoalDescription('')
|
||||
setTargetDate('')
|
||||
setShowForm(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card">
|
||||
<section className={`card${compact ? ' card--secondary' : ''}`}>
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Meilensteine</h2>
|
||||
<p className="section-lead muted">
|
||||
Überprüfbare Zielpunkte — kein Task. Ziel und Termin machen den Horizont steuerbar.
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -41,7 +66,7 @@ export function MilestonesSection({
|
|||
</div>
|
||||
|
||||
{showForm && canManage && (
|
||||
<form className="inline-form-block" onSubmit={handleSubmit}>
|
||||
<form className="inline-form-block milestone-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Titel
|
||||
<input
|
||||
|
|
@ -49,6 +74,24 @@ export function MilestonesSection({
|
|||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={255}
|
||||
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>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
|
|
@ -57,18 +100,22 @@ export function MilestonesSection({
|
|||
</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) => (
|
||||
<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">
|
||||
<strong>{milestone.title}</strong>
|
||||
{milestone.goal_description && (
|
||||
<p className="list-item-desc">{milestone.goal_description}</p>
|
||||
)}
|
||||
{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 className="list-item-meta action-controls">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
LIFECYCLE_LABELS,
|
||||
PHASE_LABELS,
|
||||
|
|
@ -69,13 +68,28 @@ export function SteeringSnapshotPanel({
|
|||
? null
|
||||
: PHASE_DESCRIPTIONS[operating_phase] || ''
|
||||
|
||||
return (
|
||||
<section className="card steering-snapshot">
|
||||
<div className="section-header">
|
||||
<h2>Steuerungszustand</h2>
|
||||
<span className="badge status-badge status-active">{displayLabel}</span>
|
||||
</div>
|
||||
const statPills = [
|
||||
{ label: 'Offen', value: counts.actions_open, warn: false },
|
||||
{ label: 'Blockiert', value: counts.actions_blocked, warn: counts.actions_blocked > 0 },
|
||||
{ label: 'Review', value: counts.actions_review_required, warn: counts.actions_review_required > 0 },
|
||||
{ label: 'Blocker', value: counts.blockers_open, warn: counts.blockers_open > 0 },
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="card steering-snapshot steering-snapshot--hero">
|
||||
<header className="steering-hero-header">
|
||||
<div>
|
||||
<p className="steering-hero-kicker">Leitfrage</p>
|
||||
<h2 className="steering-hero-title">
|
||||
Wo stehe ich — und was ist als Nächstes dran?
|
||||
</h2>
|
||||
</div>
|
||||
<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>
|
||||
|
|
@ -99,6 +113,19 @@ export function SteeringSnapshotPanel({
|
|||
</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' : ''}`}
|
||||
>
|
||||
<span className="steering-stat-pill-value">{pill.value}</span>
|
||||
<span className="steering-stat-pill-label">{pill.label}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>}
|
||||
|
||||
{phase_signals?.length > 0 && (
|
||||
|
|
@ -107,9 +134,10 @@ export function SteeringSnapshotPanel({
|
|||
</p>
|
||||
)}
|
||||
|
||||
<div className="steering-hero-columns">
|
||||
{next_actions.length > 0 && (
|
||||
<div className="steering-next-actions">
|
||||
<h3>Nächste Schritte für dieses Vorhaben</h3>
|
||||
<h3>Nächste Schritte</h3>
|
||||
<ol className="item-list compact-list">
|
||||
{next_actions.map((item, i) => (
|
||||
<li key={`${item.kind}-${i}`} className="list-item card-list-item compact">
|
||||
|
|
@ -145,38 +173,16 @@ export function SteeringSnapshotPanel({
|
|||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<p className="muted snapshot-hint">
|
||||
Steuerungszustand über Standard Lifecycle
|
||||
{lifecycle_state ? ` (${lifecycle_state})` : ''}.
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,10 +50,8 @@ import {
|
|||
updateRecurring,
|
||||
deleteRecurring,
|
||||
} from '../api/recurring.js'
|
||||
import { ACTION_STATUSES, ACTION_STATUS_LABELS } from '../constants/status.js'
|
||||
import { StatusBadge } from '../components/StatusBadge.jsx'
|
||||
import { PriorityBadge } from '../components/PriorityBadge.jsx'
|
||||
import { ActionForm } from '../components/ActionForm.jsx'
|
||||
import { BlockersSection } from '../components/BlockersSection.jsx'
|
||||
import { BacklogSection } from '../components/BacklogSection.jsx'
|
||||
import { MilestonesSection } from '../components/MilestonesSection.jsx'
|
||||
|
|
@ -62,8 +60,9 @@ import { DecisionsSection } from '../components/DecisionsSection.jsx'
|
|||
import { ReviewsSection } from '../components/ReviewsSection.jsx'
|
||||
import { RecurringSection } from '../components/RecurringSection.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 { EmptyState } from '../components/EmptyState.jsx'
|
||||
import { ErrorState } from '../components/ErrorState.jsx'
|
||||
import { LoadingState } from '../components/LoadingState.jsx'
|
||||
import { useCapabilities } from '../hooks/useCapabilities.js'
|
||||
|
|
@ -165,6 +164,20 @@ export function InitiativeDetailPage() {
|
|||
(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) {
|
||||
setMethodBusy(true)
|
||||
try {
|
||||
|
|
@ -523,140 +536,32 @@ export function InitiativeDetailPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
<section className="card">
|
||||
<div className="section-header">
|
||||
<h2>Maßnahmen</h2>
|
||||
<div className="section-actions">
|
||||
<label className="checkbox-label inline-filter">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={hideDone}
|
||||
onChange={(e) => setHideDone(e.target.checked)}
|
||||
/>
|
||||
Erledigte ausblenden
|
||||
</label>
|
||||
{capabilities.has('kairo.action.manage') && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-block-mobile"
|
||||
onClick={() => { setShowActionForm((v) => !v); setEditingAction(null) }}
|
||||
>
|
||||
{showActionForm ? 'Abbrechen' : 'Maßnahme anlegen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showActionForm && (
|
||||
<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)
|
||||
<InitiativeActionsHub
|
||||
actions={visibleActions}
|
||||
actionContextById={actionContextById}
|
||||
hideDone={hideDone}
|
||||
onHideDoneChange={setHideDone}
|
||||
showForm={showActionForm}
|
||||
onToggleForm={() => {
|
||||
setShowActionForm((v) => !v)
|
||||
setEditingAction(null)
|
||||
}}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
editingActionId={editingAction}
|
||||
onEditAction={setEditingAction}
|
||||
onCancelEdit={() => setEditingAction(null)}
|
||||
onCreateAction={handleCreateAction}
|
||||
onUpdateAction={handleUpdateAction}
|
||||
onQuickStatus={handleQuickStatus}
|
||||
onCreateBlockerForAction={handleCreateBlockerForAction}
|
||||
canManage={capabilities.has('kairo.action.manage')}
|
||||
canManageBlocker={capabilities.has('kairo.blocker.manage')}
|
||||
formBusy={formBusy}
|
||||
actors={actors}
|
||||
actorsLoading={actorsLoading}
|
||||
actorsError={actorsError}
|
||||
actorsUsedFallback={actorsUsedFallback}
|
||||
onReloadActors={reloadActors}
|
||||
/>
|
||||
|
||||
{capabilities.has('kairo.initiative.read') && (
|
||||
<MilestonesSection
|
||||
|
|
@ -670,6 +575,8 @@ export function InitiativeDetailPage() {
|
|||
)}
|
||||
|
||||
{capabilities.has('kairo.initiative.read') && (
|
||||
<CollapsibleSection title="Erweitert" summary={advancedSummary}>
|
||||
<div className="initiative-advanced-stack">
|
||||
<BacklogSection
|
||||
items={backlogItems}
|
||||
canManage={capabilities.has('kairo.backlog.manage')}
|
||||
|
|
@ -679,31 +586,6 @@ export function InitiativeDetailPage() {
|
|||
onDelete={handleDeleteBacklog}
|
||||
busy={formBusy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{capabilities.has('kairo.initiative.read') && (
|
||||
<BlockersSection
|
||||
blockers={blockers}
|
||||
canManage={capabilities.has('kairo.blocker.manage')}
|
||||
onCreate={handleCreateBlocker}
|
||||
onUpdateStatus={handleBlockerStatus}
|
||||
onDelete={handleDeleteBlocker}
|
||||
busy={formBusy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{capabilities.has('kairo.initiative.read') && (
|
||||
<EvidenceSection
|
||||
items={evidenceItems}
|
||||
canManage={capabilities.has('kairo.evidence.manage')}
|
||||
onCreate={handleCreateEvidence}
|
||||
onUpdateStatus={handleEvidenceStatus}
|
||||
onDelete={handleDeleteEvidence}
|
||||
busy={formBusy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{capabilities.has('kairo.initiative.read') && (
|
||||
<DecisionsSection
|
||||
items={decisions}
|
||||
canManage={capabilities.has('kairo.decision.manage')}
|
||||
|
|
@ -712,20 +594,6 @@ export function InitiativeDetailPage() {
|
|||
onDelete={handleDeleteDecision}
|
||||
busy={formBusy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{capabilities.has('kairo.initiative.read') && (
|
||||
<ReviewsSection
|
||||
items={reviews}
|
||||
canManage={capabilities.has('kairo.review.manage')}
|
||||
onCreate={handleCreateReview}
|
||||
onUpdateStatus={handleReviewStatus}
|
||||
onDelete={handleDeleteReview}
|
||||
busy={formBusy}
|
||||
/>
|
||||
)}
|
||||
|
||||
{capabilities.has('kairo.initiative.read') && (
|
||||
<RecurringSection
|
||||
items={recurringItems}
|
||||
canManage={capabilities.has('kairo.recurring.manage')}
|
||||
|
|
@ -734,6 +602,35 @@ export function InitiativeDetailPage() {
|
|||
onDelete={handleDeleteRecurring}
|
||||
busy={formBusy}
|
||||
/>
|
||||
<BlockersSection
|
||||
blockers={unlinkedBlockers}
|
||||
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')}
|
||||
onCreate={handleCreateBlocker}
|
||||
onUpdateStatus={handleBlockerStatus}
|
||||
onDelete={handleDeleteBlocker}
|
||||
busy={formBusy}
|
||||
/>
|
||||
<EvidenceSection
|
||||
items={evidenceItems}
|
||||
canManage={capabilities.has('kairo.evidence.manage')}
|
||||
onCreate={handleCreateEvidence}
|
||||
onUpdateStatus={handleEvidenceStatus}
|
||||
onDelete={handleDeleteEvidence}
|
||||
busy={formBusy}
|
||||
/>
|
||||
<ReviewsSection
|
||||
items={reviews}
|
||||
canManage={capabilities.has('kairo.review.manage')}
|
||||
onCreate={handleCreateReview}
|
||||
onUpdateStatus={handleReviewStatus}
|
||||
onDelete={handleDeleteReview}
|
||||
busy={formBusy}
|
||||
/>
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -690,3 +690,280 @@
|
|||
.action-links {
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user