feat(A1): Activity Set am Gate-Detail — Übungen an Zielzustand binden
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 4m54s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 13s
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 4m54s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 13s
Gate-Detail: Übungen anlegen mit Rhythmus, Gate aktivieren, Link zu Today. API: /roadmap-items/{id}/practices und roadmap_item_id auf Recurring-Create. Damit ist A1 ohne Starter-Kit manuell durchspielbar.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
17add13d9d
commit
87ecee5301
|
|
@ -132,6 +132,7 @@ class RecurringCreateRequest(BaseModel):
|
|||
status: Literal["active", "paused", "ended"] = "active"
|
||||
interval_days: Optional[int] = Field(default=None, gt=0)
|
||||
next_due_at: Optional[str] = None
|
||||
roadmap_item_id: Optional[str] = None
|
||||
|
||||
|
||||
def _parse_optional_datetime(value: Optional[str], field_name: str) -> Optional[datetime]:
|
||||
|
|
@ -648,6 +649,7 @@ def create_initiative_recurring(
|
|||
status=body.status,
|
||||
interval_days=body.interval_days,
|
||||
next_due_at=next_due_at,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ class RecurringUpdateRequest(BaseModel):
|
|||
clear_interval_days: bool = False
|
||||
next_due_at: Optional[str] = None
|
||||
clear_next_due_at: bool = False
|
||||
roadmap_item_id: Optional[str] = None
|
||||
clear_roadmap_item_id: bool = False
|
||||
|
||||
|
||||
class RecurringCompleteRequest(BaseModel):
|
||||
|
|
@ -69,6 +71,8 @@ def update_recurring_element(
|
|||
clear_interval_days=body.clear_interval_days,
|
||||
next_due_at=next_due_at,
|
||||
clear_next_due_at=body.clear_next_due_at,
|
||||
roadmap_item_id=body.roadmap_item_id,
|
||||
clear_roadmap_item_id=body.clear_roadmap_item_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
|
|
|||
|
|
@ -87,6 +87,13 @@ class ReopenRequest(BaseModel):
|
|||
reason: str = ""
|
||||
|
||||
|
||||
class GatePracticeCreateRequest(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
description: str = ""
|
||||
status: Literal["active", "paused", "ended"] = "active"
|
||||
interval_days: int = Field(default=1, gt=0)
|
||||
|
||||
|
||||
class PlanSnapshotCreateRequest(BaseModel):
|
||||
reason: str = Field(default="", max_length=2000)
|
||||
|
||||
|
|
@ -309,6 +316,63 @@ def delete_roadmap_item(
|
|||
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
|
||||
|
||||
|
||||
def _work_gate_or_400(*, tenant_id: str, item_id: str) -> dict[str, Any]:
|
||||
item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
|
||||
if item.get("item_type") == "join_gate":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Join-Gates haben kein Activity Set (Übungen)",
|
||||
)
|
||||
return item
|
||||
|
||||
|
||||
@items_router.get("/{item_id}/practices")
|
||||
def list_gate_practices(
|
||||
item_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.recurring.read")),
|
||||
):
|
||||
from services import recurring as recurring_service
|
||||
|
||||
item = _work_gate_or_400(tenant_id=ctx.tenant_id, item_id=item_id)
|
||||
try:
|
||||
return recurring_service.list_recurring_for_gate(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=str(item["initiative_id"]),
|
||||
gate_id=item_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@items_router.post("/{item_id}/practices", status_code=201)
|
||||
def create_gate_practice(
|
||||
item_id: str,
|
||||
body: GatePracticeCreateRequest,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
|
||||
):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from services import recurring as recurring_service
|
||||
|
||||
item = _work_gate_or_400(tenant_id=ctx.tenant_id, item_id=item_id)
|
||||
try:
|
||||
return recurring_service.create_recurring_element(
|
||||
tenant_id=ctx.tenant_id,
|
||||
initiative_id=str(item["initiative_id"]),
|
||||
title=body.title,
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
interval_days=body.interval_days,
|
||||
next_due_at=datetime.now(timezone.utc),
|
||||
roadmap_item_id=item_id,
|
||||
user_id=ctx.user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@items_router.post("/{item_id}/verify-reached")
|
||||
def verify_roadmap_item_reached(
|
||||
item_id: str,
|
||||
|
|
|
|||
|
|
@ -134,6 +134,15 @@ def list_recurring_for_initiative(
|
|||
return attach_open_instances_to_recurring_list(rows, tenant_id=tenant_id)
|
||||
|
||||
|
||||
def list_recurring_for_gate(
|
||||
*, tenant_id: str, initiative_id: str, gate_id: str
|
||||
) -> list[dict[str, Any]]:
|
||||
items = list_recurring_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
return [item for item in items if str(item.get("roadmap_item_id") or "") == str(gate_id)]
|
||||
|
||||
|
||||
def get_recurring_element(
|
||||
*, tenant_id: str, recurring_id: str
|
||||
) -> Optional[dict[str, Any]]:
|
||||
|
|
|
|||
94
backend/tests/test_a1_gate_practices.py
Normal file
94
backend/tests/test_a1_gate_practices.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
"""Gate Activity Set API — A1 minimal usability."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.factories import provision_user_in_tenant
|
||||
from tests.test_initiatives_actions import _auth, _create_initiative, _login
|
||||
|
||||
|
||||
def test_gate_practice_create_and_list(client):
|
||||
user = provision_user_in_tenant(tenant_role="admin")
|
||||
token = _login(client, user)
|
||||
|
||||
created = _create_initiative(
|
||||
client,
|
||||
token,
|
||||
title="A1 Gate Practices",
|
||||
archetype_key="initiative.maturity_journey",
|
||||
apply_starter_kit=False,
|
||||
)
|
||||
initiative_id = created.json()["id"]
|
||||
|
||||
gate = client.post(
|
||||
f"/api/initiatives/{initiative_id}/roadmap/items",
|
||||
json={
|
||||
"title": "Dehnung — Einstieg",
|
||||
"item_type": "maturity_stage",
|
||||
"status": "active",
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert gate.status_code == 201
|
||||
gate_id = gate.json()["id"]
|
||||
|
||||
empty = client.get(
|
||||
f"/api/roadmap-items/{gate_id}/practices",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert empty.status_code == 200
|
||||
assert empty.json() == []
|
||||
|
||||
created_practice = client.post(
|
||||
f"/api/roadmap-items/{gate_id}/practices",
|
||||
json={
|
||||
"title": "Vorbeuge halten",
|
||||
"description": "3×30 Sek.",
|
||||
"interval_days": 1,
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert created_practice.status_code == 201
|
||||
body = created_practice.json()
|
||||
assert body["roadmap_item_id"] == gate_id
|
||||
assert body["interval_days"] == 1
|
||||
|
||||
listed = client.get(
|
||||
f"/api/roadmap-items/{gate_id}/practices",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert listed.status_code == 200
|
||||
assert len(listed.json()) == 1
|
||||
assert listed.json()[0]["title"] == "Vorbeuge halten"
|
||||
|
||||
|
||||
def test_join_gate_rejects_practices(client):
|
||||
user = provision_user_in_tenant(tenant_role="admin")
|
||||
token = _login(client, user)
|
||||
|
||||
created = _create_initiative(
|
||||
client,
|
||||
token,
|
||||
title="Join no practices",
|
||||
archetype_key="initiative.maturity_journey",
|
||||
apply_starter_kit=False,
|
||||
)
|
||||
initiative_id = created.json()["id"]
|
||||
|
||||
join = client.post(
|
||||
f"/api/initiatives/{initiative_id}/roadmap/items",
|
||||
json={
|
||||
"title": "Konsolidierung",
|
||||
"item_type": "join_gate",
|
||||
"status": "planned",
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert join.status_code == 201
|
||||
join_id = join.json()["id"]
|
||||
|
||||
rejected = client.post(
|
||||
f"/api/roadmap-items/{join_id}/practices",
|
||||
json={"title": "Nope", "interval_days": 1},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert rejected.status_code == 400
|
||||
|
|
@ -140,7 +140,7 @@ Alle Kanten: **Voraussetzung** (`requires`). Kein Parallelgruppe/Blockiert nöti
|
|||
| **2** | Operating Context | `GET …/initiatives/{id}/operating-context` oder Kontrolle | `maturity_stage`, `recurring_rhythm`; Default-Route Kontrolle | | |
|
||||
| **3** | Gates + Join im Designer | Plan → Zielzustände → **Designer** | 5 Knoten: 4× `Reifegrad-Stufe` + 1× **Join/Schaltknoten**; D1+H1 **active**, Rest **planned** | | |
|
||||
| **4** | Kanten ziehen | Designer, Kantentyp **Voraussetzung** | Join→D1, Join→H1, D2→Join, H2→Join (4 Kanten) | | |
|
||||
| **5** | Activity Sets | Gate-Detail je Work-Gate: Recurring/Übung anlegen oder Kriterium + Übung | D1 und H1 je ≥1 Übung; Join **ohne** Checkliste/Verify-Button | | |
|
||||
| **5** | Activity Sets | Gate-Detail (Designer → Gate öffnen): **Übung anlegen** mit Rhythmus | Je aktivem Work-Gate ≥1 Übung; Join **ohne** Übungen | | |
|
||||
| **6** | Today (Start) | `/work/today` (Initiative-Scope) | Übungen von **beiden** aktiven Gates; **keine** flache AP-Wand; Join **nicht** in Today | | |
|
||||
| **7** | Verify Pfad 1 | Gate D1: Evidence + **Verify** | D1 `reached`; Join wartet auf H1; **kein** auto-next auf D2; Today nur noch H1-Übungen | | |
|
||||
| **8** | Join schaltet | Gate H1: Evidence + **Verify** | Join `reached` (automatisch); D2 + H2 **active**; neue Übungen in Today | | |
|
||||
|
|
|
|||
|
|
@ -70,6 +70,17 @@ export function listRoadmapItemCriteria(itemId) {
|
|||
return apiFetch(`/api/roadmap-items/${itemId}/criteria`)
|
||||
}
|
||||
|
||||
export function listGatePractices(gateId) {
|
||||
return apiFetch(`/api/roadmap-items/${gateId}/practices`)
|
||||
}
|
||||
|
||||
export function createGatePractice(gateId, body) {
|
||||
return apiFetch(`/api/roadmap-items/${gateId}/practices`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export function createRoadmapItemCriterion(itemId, body) {
|
||||
return apiFetch(`/api/roadmap-items/${itemId}/criteria`, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
250
frontend/src/components/GateActivitySetSection.jsx
Normal file
250
frontend/src/components/GateActivitySetSection.jsx
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
createGatePractice,
|
||||
listGatePractices,
|
||||
} from '../api/roadmap.js'
|
||||
import { deleteRecurring, updateRecurring } from '../api/recurring.js'
|
||||
import { RECURRING_STATUSES, RECURRING_STATUS_LABELS } from '../constants/status.js'
|
||||
import { EmptyState } from './EmptyState.jsx'
|
||||
import { StatusBadge } from './StatusBadge.jsx'
|
||||
import { scopedPath } from '../utils/routes.js'
|
||||
|
||||
function formatDueAt(iso) {
|
||||
if (!iso) return null
|
||||
try {
|
||||
return new Date(iso).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' })
|
||||
} catch {
|
||||
return iso
|
||||
}
|
||||
}
|
||||
|
||||
export function GateActivitySetSection({
|
||||
gateId,
|
||||
initiativeId,
|
||||
gateStatus,
|
||||
canManage = false,
|
||||
}) {
|
||||
const [practices, setPractices] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [intervalDays, setIntervalDays] = useState('1')
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const items = await listGatePractices(gateId)
|
||||
setPractices(Array.isArray(items) ? items : [])
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [gateId])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [load])
|
||||
|
||||
async function runAction(fn) {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await fn()
|
||||
await load()
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(e) {
|
||||
e.preventDefault()
|
||||
if (!title.trim()) return
|
||||
const interval = Number.parseInt(intervalDays, 10)
|
||||
if (!Number.isFinite(interval) || interval < 1) {
|
||||
setError('Intervall muss mindestens 1 Tag sein.')
|
||||
return
|
||||
}
|
||||
await runAction(async () => {
|
||||
await createGatePractice(gateId, {
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
interval_days: interval,
|
||||
status: 'active',
|
||||
})
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setIntervalDays('1')
|
||||
setShowForm(false)
|
||||
})
|
||||
}
|
||||
|
||||
const isActiveGate = gateStatus === 'active' || gateStatus === 'at_risk'
|
||||
|
||||
return (
|
||||
<section className="card gate-activity-set">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h3>Übungen (Activity Set)</h3>
|
||||
<p className="section-lead muted">
|
||||
Wiederkehrende Routinen an diesem Zielzustand — erscheinen in Today, wenn das Gate{' '}
|
||||
<strong>aktiv</strong> ist.
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setShowForm((value) => !value)}
|
||||
disabled={busy}
|
||||
>
|
||||
{showForm ? 'Abbrechen' : 'Übung anlegen'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="error">{error}</p>}
|
||||
|
||||
{!isActiveGate && (
|
||||
<p className="muted gate-activity-set__hint">
|
||||
Gate-Status ist <strong>{gateStatus}</strong> — Übungen werden in Today sichtbar, sobald
|
||||
das Gate auf <strong>aktiv</strong> steht (Stammdaten bearbeiten).
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isActiveGate && practices.some((item) => item.status === 'active') && initiativeId && (
|
||||
<p className="muted gate-activity-set__hint">
|
||||
<Link to={scopedPath('/work/today', { initiativeId })} className="link-inline">
|
||||
Ausführen → Today
|
||||
</Link>{' '}
|
||||
— fällige Übungen dieses Gates abhaken.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showForm && canManage && (
|
||||
<form className="inline-form-block gate-activity-set__form" onSubmit={handleCreate}>
|
||||
<label>
|
||||
Titel
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={255}
|
||||
required
|
||||
placeholder="z. B. Vorbeuge halten"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Beschreibung (optional)
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="Hinweise zur Ausführung"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Rhythmus (Tage)
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={intervalDays}
|
||||
onChange={(e) => setIntervalDays(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
Übung speichern
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<p className="muted">Übungen werden geladen…</p>
|
||||
) : practices.length === 0 ? (
|
||||
<EmptyState message="Noch keine Übungen an diesem Gate — mindestens eine für aktive Gates anlegen." />
|
||||
) : (
|
||||
<ul className="item-list gate-activity-set__list">
|
||||
{practices.map((practice) => (
|
||||
<li key={practice.id} className="list-item card-list-item">
|
||||
<div className="list-item-main">
|
||||
<strong>{practice.title}</strong>
|
||||
{practice.description && (
|
||||
<p className="muted list-item-sub">{practice.description}</p>
|
||||
)}
|
||||
<p className="muted list-item-sub">
|
||||
{practice.interval_days
|
||||
? `Alle ${practice.interval_days} Tag(e)`
|
||||
: 'Kein Intervall'}
|
||||
{practice.open_cadence_instance?.due_at
|
||||
? ` · Fällig: ${formatDueAt(practice.open_cadence_instance.due_at)}`
|
||||
: ''}
|
||||
{practice.today_completed ? ' · heute erledigt' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="list-item-meta action-controls">
|
||||
<StatusBadge kind="recurring" status={practice.status} />
|
||||
<select
|
||||
className="inline-select"
|
||||
value={practice.status}
|
||||
disabled={busy}
|
||||
onChange={(e) =>
|
||||
runAction(() =>
|
||||
updateRecurring(practice.id, { status: e.target.value }),
|
||||
)
|
||||
}
|
||||
aria-label="Übungs-Status"
|
||||
>
|
||||
{RECURRING_STATUSES.map((status) => (
|
||||
<option key={status} value={status}>
|
||||
{RECURRING_STATUS_LABELS[status]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
const next = window.prompt(
|
||||
'Neues Intervall in Tagen:',
|
||||
String(practice.interval_days || 1),
|
||||
)
|
||||
if (next == null) return
|
||||
const days = Number.parseInt(next, 10)
|
||||
if (!Number.isFinite(days) || days < 1) {
|
||||
setError('Intervall muss mindestens 1 Tag sein.')
|
||||
return
|
||||
}
|
||||
runAction(() => updateRecurring(practice.id, { interval_days: days }))
|
||||
}}
|
||||
>
|
||||
Rhythmus
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!window.confirm(`„${practice.title}" löschen?`)) return
|
||||
runAction(() => deleteRecurring(practice.id))
|
||||
}}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
@ -78,10 +78,14 @@ export function RecurringRhythmPanel({
|
|||
</ul>
|
||||
<p className="muted recurring-rhythm-hint">
|
||||
{canManage
|
||||
? 'Übungen anlegen, pausieren oder löschen unter '
|
||||
: 'Rhythmen pflegen unter '}
|
||||
<Link to={scopedPath('/control/journey', { initiativeId })} className="link-inline">
|
||||
Kontrolle → Journey → Wiederkehrend
|
||||
? 'Übungen anlegen und Rhythmus pflegen am '
|
||||
: 'Übungen am '}
|
||||
<Link to={scopedPath('/plan/gates', { initiativeId })} className="link-inline">
|
||||
Zielzustand (Gate-Detail)
|
||||
</Link>
|
||||
{' '}— Activity Set pro Gate. Heute erledigen unter{' '}
|
||||
<Link to={scopedPath('/work/today', { initiativeId })} className="link-inline">
|
||||
Ausführen → Today
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ export function RecurringSection({ items, canManage, onCreate, onUpdateStatus, o
|
|||
)}
|
||||
</div>
|
||||
|
||||
<p className="muted form-hint">
|
||||
Für Reifegrad-Vorhaben (A1): Übungen am{' '}
|
||||
<strong>Zielzustand (Gate-Detail)</strong> anlegen — dort Activity Set mit Rhythmus.
|
||||
Diese Liste ist für initiative-weite Elemente ohne Gate-Bezug.
|
||||
</p>
|
||||
|
||||
{showForm && canManage && (
|
||||
<form className="inline-form-block" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { useCapabilities } from '../../hooks/useCapabilities.js'
|
|||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||
import { scopedPath } from '../../utils/routes.js'
|
||||
import { listGateContributions } from '../../api/journey.js'
|
||||
import { GateActivitySetSection } from '../../components/GateActivitySetSection.jsx'
|
||||
import { GateContributionsSection } from '../../components/GateContributionsSection.jsx'
|
||||
import { GateDependenciesSection } from '../../components/GateDependenciesSection.jsx'
|
||||
import { GateCriteriaSection } from '../../components/GateCriteriaSection.jsx'
|
||||
|
|
@ -172,6 +173,20 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
</div>
|
||||
<div className="section-header__actions">
|
||||
<StatusBadge kind="milestone" status={item.status} />
|
||||
{canManage && item.status === 'planned' && !joinGate && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runAction(async () => {
|
||||
await updateRoadmapItem(itemId, { status: 'active' })
|
||||
})
|
||||
}
|
||||
>
|
||||
Gate aktivieren
|
||||
</button>
|
||||
)}
|
||||
{canManage && item.status !== 'reached' && (
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -221,6 +236,15 @@ export function RoadmapItemDetailPage({ overrideItemId }) {
|
|||
onDelete={(dependencyId) => runAction(() => deleteRoadmapDependency(dependencyId))}
|
||||
/>
|
||||
|
||||
{!joinGate && (
|
||||
<GateActivitySetSection
|
||||
gateId={itemId}
|
||||
initiativeId={initiativeId || item.initiative_id}
|
||||
gateStatus={item.status}
|
||||
canManage={capabilities.has('kairo.recurring.manage')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{joinGate ? (
|
||||
<section className="card join-gate-detail">
|
||||
<h3>Join / Schaltknoten</h3>
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user