diff --git a/.cursor/rules/kairo-deployment-testing.mdc b/.cursor/rules/kairo-deployment-testing.mdc
index bdb92e3..141a655 100644
--- a/.cursor/rules/kairo-deployment-testing.mdc
+++ b/.cursor/rules/kairo-deployment-testing.mdc
@@ -19,9 +19,22 @@ Praktische Verifikation erfolgt durch **Deployment auf ein entferntes System (Ra
2. Deployment wird dadurch ausgelöst (CI/Deploy-Pipeline auf dem Raspberry)
3. Dort laufen Migrationen, Backend-Tests und Smoke-Checks gegen echte Infrastruktur
+**Dev-Instanz (kein localhost):** [https://dev.kairo.jinkendo.de](https://dev.kairo.jinkendo.de)
+Schnellcheck: `GET https://dev.kairo.jinkendo.de/api/health` (Version + DB).
+
+**Gitea Actions** (Test-Suite läuft nach Deploy automatisch):
+
+- Übersicht: `http://192.168.2.144:3000/Lars/Kairo-Jinkendo/actions`
+- Workflows: `deploy-dev.yml` (Deploy), `test.yml` (pytest, lint, k6, Playwright)
+- Filter z. B. `?workflow=test.yml` oder `?workflow=deploy-dev.yml`
+- Run-Detail: `/Lars/Kairo-Jinkendo/actions/runs/{id}`
+
+Nach Push auf `develop`: Deploy-Run grün → Health auf Dev → Test-Suite in Gitea Actions prüfen.
+
## Konsequenz für Agenten
- Code und Tests **schreiben** und dokumentieren — aber fehlende lokale DB nicht als Implementierungsblocker behandeln
- Frontend: `npm test` / `vite build` lokal weiterhin sinnvoll, wenn Node verfügbar
-- Backend: nach AP-Umsetzung Abschlussbericht mit „Remote-Verifikation nach Push auf develop“ kennzeichnen
+- Backend: Abnahme über Gitea Actions (`test.yml`) + Dev-Health — nicht lokales pytest erzwingen
+- Funktionale Checks gegen **https://dev.kairo.jinkendo.de**, nicht localhost
- **Nicht** proaktiv committen/pushen, es sei denn, der Nutzer bittet explizit darum
diff --git a/backend/version.py b/backend/version.py
index 8aaa9ed..825c34d 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,3 +1,3 @@
-APP_VERSION = "0.16.5-ap1.12a"
+APP_VERSION = "0.16.6-ap1.12b"
DB_SCHEMA_VERSION = "014"
APP_NAME = "jinkendo-kairo"
diff --git a/frontend/src/components/BacklogItemForm.jsx b/frontend/src/components/BacklogItemForm.jsx
new file mode 100644
index 0000000..4b9b3d2
--- /dev/null
+++ b/frontend/src/components/BacklogItemForm.jsx
@@ -0,0 +1,102 @@
+import {
+ BACKLOG_STATUSES,
+ BACKLOG_STATUS_LABELS,
+ PRIORITIES,
+ PRIORITY_LABELS,
+} from '../constants/status.js'
+import { GateSelect } from './GateSelect.jsx'
+
+export function BacklogItemForm({
+ initial = {},
+ roadmapItems = [],
+ onSubmit,
+ onCancel,
+ busy = false,
+ submitLabel = 'Speichern',
+ allowStatus = true,
+}) {
+ async function handleSubmit(e) {
+ e.preventDefault()
+ const form = e.target
+ const gateValue = form.roadmap_item_id?.value ?? ''
+ await onSubmit({
+ title: form.title.value.trim(),
+ description: form.description.value,
+ status: form.status?.value,
+ priority: form.priority.value,
+ roadmap_item_id: gateValue || undefined,
+ clear_roadmap_item: gateValue === '',
+ })
+ }
+
+ const statusLocked = initial.status === 'converted'
+
+ return (
+
+ )
+}
diff --git a/frontend/src/components/BacklogSection.jsx b/frontend/src/components/BacklogSection.jsx
index 7740865..aca310d 100644
--- a/frontend/src/components/BacklogSection.jsx
+++ b/frontend/src/components/BacklogSection.jsx
@@ -1,41 +1,54 @@
import { useState } from 'react'
-import {
- BACKLOG_STATUSES,
- BACKLOG_STATUS_LABELS,
-} from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
import { PriorityBadge } from './PriorityBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
-
-import { GateSelect, gateTitleById } from './GateSelect.jsx'
+import { Modal } from './Modal.jsx'
+import { BacklogItemForm } from './BacklogItemForm.jsx'
+import { gateTitleById } from './GateSelect.jsx'
export function BacklogSection({
items,
roadmapItems = [],
canManage,
onCreate,
- onUpdateGate,
- onUpdateStatus,
+ onUpdate,
onConvert,
onDelete,
busy,
}) {
- const [title, setTitle] = useState('')
- const [gateId, setGateId] = useState('')
- const [showForm, setShowForm] = useState(false)
+ const [modalMode, setModalMode] = useState(null)
- async function handleSubmit(e) {
- e.preventDefault()
- if (!title.trim()) return
- await onCreate({
- title: title.trim(),
- roadmap_item_id: gateId || undefined,
- })
- setTitle('')
- setGateId('')
- setShowForm(false)
+ function closeModal() {
+ setModalMode(null)
}
+ async function handleCreateSubmit(payload) {
+ await onCreate({
+ title: payload.title,
+ description: payload.description,
+ priority: payload.priority,
+ roadmap_item_id: payload.roadmap_item_id,
+ status: payload.status || 'new',
+ })
+ closeModal()
+ }
+
+ async function handleEditSubmit(payload) {
+ if (!modalMode?.item) return
+ const ok = await onUpdate(modalMode.item.id, {
+ title: payload.title,
+ description: payload.description,
+ status: payload.status,
+ priority: payload.priority,
+ roadmap_item_id: payload.roadmap_item_id,
+ clear_roadmap_item: payload.clear_roadmap_item,
+ })
+ if (ok !== false) closeModal()
+ }
+
+ const modalTitle =
+ modalMode?.kind === 'create' ? 'Backlog-Item anlegen' : 'Backlog-Item bearbeiten'
+
return (
@@ -44,49 +57,24 @@ export function BacklogSection({
setShowForm((v) => !v)}
+ onClick={() => setModalMode({ kind: 'create' })}
>
- {showForm ? 'Abbrechen' : 'Backlog-Item'}
+ Backlog-Item
)}
- {showForm && canManage && (
-
-
- Titel
- setTitle(e.target.value)}
- maxLength={255}
- required
- />
-
- {roadmapItems.length > 0 && (
-
- Zielzustand (Gate)
- setGateId(e.target.value)}>
- — keins —
- {roadmapItems.map((item) => (
-
- {item.title}
-
- ))}
-
-
- )}
-
- Anlegen
-
-
- )}
-
{items.length === 0 && }
{items.map((item) => (
-
+
canManage && setModalMode({ kind: 'edit', item })}
+ disabled={!canManage}
+ >
{item.title}
{item.description && (
{item.description}
@@ -96,43 +84,16 @@ export function BacklogSection({
Gate: {gateTitleById(roadmapItems, item.roadmap_item_id) || item.roadmap_item_id}
)}
-
+
- {canManage && item.status !== 'converted' && roadmapItems.length > 0 && (
-
onUpdateGate(item.id, e.target.value)}
- aria-label="Gate-Zuordnung"
- >
- Gate …
- {roadmapItems.map((gate) => (
-
- {gate.title}
-
- ))}
-
- )}
{canManage && item.status !== 'converted' && (
<>
-
onUpdateStatus(item.id, e.target.value)}
- aria-label="Backlog-Status"
- >
- {BACKLOG_STATUSES.filter((s) => s !== 'converted').map((s) => (
-
- {BACKLOG_STATUS_LABELS[s]}
-
- ))}
-
{item.status === 'accepted' && (
onConvert(item.id)}
disabled={busy}
>
@@ -141,7 +102,15 @@ export function BacklogSection({
)}
setModalMode({ kind: 'edit', item })}
+ disabled={busy}
+ >
+ Bearbeiten
+
+ onDelete(item.id)}
>
Löschen
@@ -152,6 +121,27 @@ export function BacklogSection({
))}
+
+
+ {modalMode?.kind === 'create' && (
+
+ )}
+ {modalMode?.kind === 'edit' && modalMode.item && (
+
+ )}
+
)
}
diff --git a/frontend/src/components/GateSelect.jsx b/frontend/src/components/GateSelect.jsx
index 1166764..b90927d 100644
--- a/frontend/src/components/GateSelect.jsx
+++ b/frontend/src/components/GateSelect.jsx
@@ -4,13 +4,14 @@ export function GateSelect({
defaultValue = '',
label = 'Zielzustand (Gate)',
allowEmpty = true,
+ disabled = false,
}) {
if (roadmapItems.length === 0) return null
return (
{label}
-
+
{allowEmpty && — keins — }
{roadmapItems.map((item) => (
diff --git a/frontend/src/components/Modal.jsx b/frontend/src/components/Modal.jsx
new file mode 100644
index 0000000..8346eb7
--- /dev/null
+++ b/frontend/src/components/Modal.jsx
@@ -0,0 +1,68 @@
+import { useEffect, useId, useRef } from 'react'
+
+/**
+ * Einfaches Modal (AP1.12b) — Fokus-Falle, Escape schließt.
+ *
+ * @param {{ open: boolean, title: string, onClose: () => void, children: import('react').ReactNode, size?: 'md'|'lg' }} props
+ */
+export function Modal({ open, title, onClose, children, size = 'md' }) {
+ const titleId = useId()
+ const dialogRef = useRef(null)
+
+ useEffect(() => {
+ if (!open) return undefined
+
+ const previousActive = document.activeElement
+ dialogRef.current?.focus()
+
+ function onKeyDown(event) {
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ onClose()
+ }
+ }
+
+ document.addEventListener('keydown', onKeyDown)
+ const prevOverflow = document.body.style.overflow
+ document.body.style.overflow = 'hidden'
+
+ return () => {
+ document.removeEventListener('keydown', onKeyDown)
+ document.body.style.overflow = prevOverflow
+ if (previousActive instanceof HTMLElement) {
+ previousActive.focus()
+ }
+ }
+ }, [open, onClose])
+
+ if (!open) return null
+
+ return (
+
+
event.stopPropagation()}
+ >
+
+
{children}
+
+
+ )
+}
diff --git a/frontend/src/components/ProjectsSection.jsx b/frontend/src/components/ProjectsSection.jsx
index 662070a..68393fa 100644
--- a/frontend/src/components/ProjectsSection.jsx
+++ b/frontend/src/components/ProjectsSection.jsx
@@ -1,7 +1,9 @@
import { Link } from 'react-router-dom'
-import { useMemo } from 'react'
+import { useMemo, useState } from 'react'
import { EmptyState } from './EmptyState.jsx'
import { StatusBadge } from './StatusBadge.jsx'
+import { Modal } from './Modal.jsx'
+import { ProjectForm } from './ProjectForm.jsx'
import {
buildProjectsByParent,
containerKindLabel,
@@ -12,11 +14,11 @@ import { projectPath } from '../utils/routes.js'
function ProjectTreeNodes({
projects,
roadmapItems,
- initiativeId,
parentId,
selectedProjectId,
onSelectProject,
canManage,
+ onEdit,
onDelete,
busy,
}) {
@@ -63,29 +65,39 @@ function ProjectTreeNodes({
{selectedProjectId === project.id ? 'Filter aktiv' : 'Filtern'}
{canManage && (
- {
- if (window.confirm(`Projekt „${project.title}“ wirklich löschen?`)) {
- onDelete(project.id)
- }
- }}
- >
- Löschen
-
+ <>
+ onEdit(project)}
+ >
+ Bearbeiten
+
+ {
+ if (window.confirm(`Projekt „${project.title}“ wirklich löschen?`)) {
+ onDelete(project.id)
+ }
+ }}
+ >
+ Löschen
+
+ >
)}
@@ -96,32 +108,54 @@ function ProjectTreeNodes({
}
export function ProjectsSection({
- initiativeId,
projects,
roadmapItems = [],
selectedProjectId,
onSelectProject,
canManage,
+ onCreate,
+ onUpdate,
onDelete,
busy,
}) {
+ const [modalMode, setModalMode] = useState(null)
+
+ function closeModal() {
+ setModalMode(null)
+ }
+
+ async function handleCreateSubmit(payload) {
+ const created = await onCreate(payload)
+ if (created) closeModal()
+ }
+
+ async function handleEditSubmit(payload) {
+ if (!modalMode?.project) return
+ const ok = await onUpdate(modalMode.project.id, payload)
+ if (ok) closeModal()
+ }
+
+ const modalTitle =
+ modalMode?.kind === 'create' ? 'Neues Projekt' : 'Projekt bearbeiten'
+
return (
Struktur
- Projekte, Streams und Phasen — Klick öffnet Detail & Bearbeitung. Filter schränkt
- Arbeitspakete ein; Anlage nur an Blatt-Ebenen für Actions.
+ Projekte, Streams und Phasen — Bearbeitung im Modal, Detailseite über den Titel.
+ Filter schränkt Arbeitspakete ein.
{canManage && (
-
setModalMode({ kind: 'create' })}
>
Neues Projekt
-
+
)}
@@ -145,16 +179,41 @@ export function ProjectsSection({
setModalMode({ kind: 'edit', project })}
onDelete={onDelete}
busy={busy}
/>
>
)}
+
+
+ {modalMode?.kind === 'create' && (
+
+ )}
+ {modalMode?.kind === 'edit' && modalMode.project && (
+
+ )}
+
)
}
diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx
index ec73b18..9cb8033 100644
--- a/frontend/src/context/InitiativeOperationsContext.jsx
+++ b/frontend/src/context/InitiativeOperationsContext.jsx
@@ -12,6 +12,7 @@ import {
getInitiativeSteeringSnapshot,
listInitiativeActions,
createInitiativeAction,
+ updateInitiative,
} from '../api/initiatives.js'
import { updateAction, setActionAssignments } from '../api/actions.js'
import {
@@ -309,6 +310,20 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
}
}
+ async function handleUpdateInitiative(body) {
+ setFormBusy(true)
+ try {
+ await updateInitiative(id, body)
+ await load()
+ return true
+ } catch (err) {
+ setError(err.message)
+ return false
+ } finally {
+ setFormBusy(false)
+ }
+ }
+
async function handleCreateBacklog(body) {
setFormBusy(true)
try {
@@ -321,6 +336,20 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
}
}
+ async function handleUpdateBacklog(itemId, body) {
+ setFormBusy(true)
+ try {
+ await updateBacklogItem(itemId, body)
+ await load()
+ return true
+ } catch (err) {
+ setError(err.message)
+ return false
+ } finally {
+ setFormBusy(false)
+ }
+ }
+
async function handleBacklogGate(itemId, roadmapItemId) {
try {
await updateBacklogItem(itemId, {
@@ -654,6 +683,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
handleBlockerStatus,
handleDeleteBlocker,
handleCreateBacklog,
+ handleUpdateBacklog,
+ handleUpdateInitiative,
handleBacklogGate,
handleBacklogStatus,
handleConvertBacklog,
diff --git a/frontend/src/context/ProgramScopeContext.jsx b/frontend/src/context/ProgramScopeContext.jsx
index 12d6516..134facd 100644
--- a/frontend/src/context/ProgramScopeContext.jsx
+++ b/frontend/src/context/ProgramScopeContext.jsx
@@ -78,6 +78,13 @@ export function ProgramScopeProvider({ children }) {
setSearchParams({}, { replace: true })
}, [setSearchParams])
+ const refreshInitiativeMeta = useCallback(() => {
+ if (!initiativeId) return
+ getInitiative(initiativeId)
+ .then((data) => setInitiativeTitle(data.title || ''))
+ .catch(() => setInitiativeTitle(''))
+ }, [initiativeId])
+
const applyFromInitiative = useCallback(
(id, { keepProject = false } = {}) => {
if (!id) return
@@ -130,6 +137,7 @@ export function ProgramScopeProvider({ children }) {
applyFromProject,
applyFromAction,
hrefWithScope,
+ refreshInitiativeMeta,
hasScope: Boolean(initiativeId),
}),
[
@@ -143,6 +151,7 @@ export function ProgramScopeProvider({ children }) {
applyFromProject,
applyFromAction,
hrefWithScope,
+ refreshInitiativeMeta,
],
)
diff --git a/frontend/src/pages/initiative/InitiativeInboxPage.jsx b/frontend/src/pages/initiative/InitiativeInboxPage.jsx
index 2431423..3a97c8a 100644
--- a/frontend/src/pages/initiative/InitiativeInboxPage.jsx
+++ b/frontend/src/pages/initiative/InitiativeInboxPage.jsx
@@ -27,8 +27,7 @@ export function InitiativeInboxPage() {
roadmapItems={roadmapItems}
canManage={capabilities.has('kairo.backlog.manage')}
onCreate={handleCreateBacklog}
- onUpdateGate={handleBacklogGate}
- onUpdateStatus={handleBacklogStatus}
+ onUpdate={handleUpdateBacklog}
onConvert={handleConvertBacklog}
onDelete={handleDeleteBacklog}
busy={formBusy}
diff --git a/frontend/src/pages/modes/PlanProfilePage.jsx b/frontend/src/pages/modes/PlanProfilePage.jsx
index 81bea34..3f8a6c4 100644
--- a/frontend/src/pages/modes/PlanProfilePage.jsx
+++ b/frontend/src/pages/modes/PlanProfilePage.jsx
@@ -1,12 +1,20 @@
+import { useState } from 'react'
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { StatusBadge } from '../../components/StatusBadge.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
+import { Modal } from '../../components/Modal.jsx'
+import { InitiativeForm } from '../../components/InitiativeForm.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
+import { useProgramScope } from '../../context/ProgramScopeContext.jsx'
function PlanProfileInner() {
const ops = useInitiativeOperations()
- const { initiative, loading, error } = ops
+ const { refreshInitiativeMeta } = useProgramScope()
+ const { initiative, loading, error, capabilities, formBusy } = ops
+ const [editing, setEditing] = useState(false)
+
+ const canManage = capabilities.has('kairo.initiative.manage')
if (loading) {
return
@@ -16,27 +24,56 @@ function PlanProfileInner() {
return Vorhaben nicht gefunden.
}
+ async function handleSave(payload) {
+ const ok = await ops.handleUpdateInitiative(payload)
+ if (ok) {
+ refreshInitiativeMeta()
+ setEditing(false)
+ }
+ }
+
return (
-
-
- {error && {error}
}
- {initiative.goal ? (
-
-
Ziel
-
{initiative.goal}
-
- ) : (
-
- Noch kein Ziel hinterlegt — vollständiges Profil folgt mit Archetypen (AP1.10).
+ <>
+
+
+ {error && {error}
}
+ {initiative.goal ? (
+
+
Ziel
+
{initiative.goal}
+
+ ) : (
+ Noch kein Ziel hinterlegt.
+ )}
+
+ Archetyp-Felder und dynamische Attribute folgen mit AP1.10 (Entity Field System).
- )}
-
- Bearbeitung per Modal kommt in AP1.10 (Entity Field System).
-
-
+
+
+ setEditing(false)}>
+ setEditing(false)}
+ busy={formBusy}
+ />
+
+ >
)
}
diff --git a/frontend/src/pages/modes/PlanStructurePage.jsx b/frontend/src/pages/modes/PlanStructurePage.jsx
index a7e5f99..70b29ea 100644
--- a/frontend/src/pages/modes/PlanStructurePage.jsx
+++ b/frontend/src/pages/modes/PlanStructurePage.jsx
@@ -25,12 +25,13 @@ function PlanStructureInner() {
<>
{error && {error}
}
diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css
index 95bd03b..5ad9cc4 100644
--- a/frontend/src/styles/components.css
+++ b/frontend/src/styles/components.css
@@ -1353,8 +1353,85 @@
grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
}
-.project-form textarea {
- min-height: 5rem;
- resize: vertical;
+.plan-profile__header-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.modal-root {
+ position: fixed;
+ inset: 0;
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ background: rgba(15, 23, 42, 0.45);
+}
+
+.modal-dialog {
+ width: min(100%, 520px);
+ max-height: min(90vh, 900px);
+ overflow: auto;
+ background: var(--jk-surface);
+ border: 1px solid var(--jk-border);
+ border-radius: 14px;
+ box-shadow: var(--jk-shadow-card);
+ outline: none;
+}
+
+.modal-dialog--lg {
+ width: min(100%, 680px);
+}
+
+.modal-dialog__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 16px;
+ border-bottom: 1px solid var(--jk-border);
+ position: sticky;
+ top: 0;
+ background: var(--jk-surface);
+ z-index: 1;
+}
+
+.modal-dialog__title {
+ margin: 0;
+ font-size: 1.05rem;
+ font-weight: 700;
+}
+
+.modal-dialog__close {
+ min-width: 2rem;
+ padding-inline: 0.5rem;
+ font-size: 1.25rem;
+ line-height: 1;
+}
+
+.modal-dialog__body {
+ padding: 16px;
+}
+
+.list-item-main--clickable {
+ width: 100%;
+ text-align: left;
+ background: none;
+ border: none;
+ padding: 0;
+ cursor: pointer;
+ color: inherit;
+ font: inherit;
+}
+
+.list-item-main--clickable:hover strong {
+ color: var(--jk-primary);
+}
+
+.list-item-main--clickable:disabled {
+ cursor: default;
}
diff --git a/frontend/src/styles/program-chrome.css b/frontend/src/styles/program-chrome.css
index 5c04fa2..f78fafe 100644
--- a/frontend/src/styles/program-chrome.css
+++ b/frontend/src/styles/program-chrome.css
@@ -402,6 +402,13 @@
margin-top: 12px;
}
+.plan-profile__header-actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
.plan-profile__note {
margin-top: 16px;
font-size: 13px;