AP1.12b: Modal-Bearbeitung für Profil, Projekte und Backlog im Plan-Modus.
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Successful in 1m47s
Test Suite / lint-backend (push) Successful in 3s
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 53s
Test Suite / pytest-backend (push) Successful in 1m47s
Test Suite / lint-backend (push) Successful in 3s
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
Ersetzt Inline-Formulare durch Dialoge; Scope-Breadcrumb aktualisiert sich nach Profil-Speichern. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
bbadf22d39
commit
e50c04c96f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
102
frontend/src/components/BacklogItemForm.jsx
Normal file
102
frontend/src/components/BacklogItemForm.jsx
Normal file
|
|
@ -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 (
|
||||
<form className="form workspace-form backlog-item-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Titel
|
||||
<input
|
||||
name="title"
|
||||
defaultValue={initial.title || ''}
|
||||
required
|
||||
maxLength={255}
|
||||
disabled={statusLocked}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Beschreibung
|
||||
<textarea
|
||||
name="description"
|
||||
rows={3}
|
||||
defaultValue={initial.description || ''}
|
||||
disabled={statusLocked}
|
||||
/>
|
||||
</label>
|
||||
<div className="form-row form-row--2">
|
||||
{allowStatus && (
|
||||
<label>
|
||||
Status
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={initial.status || 'new'}
|
||||
disabled={statusLocked}
|
||||
>
|
||||
{BACKLOG_STATUSES.filter((s) => s !== 'converted').map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{BACKLOG_STATUS_LABELS[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
Priorität
|
||||
<select name="priority" defaultValue={initial.priority || 'normal'} disabled={statusLocked}>
|
||||
{PRIORITIES.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{PRIORITY_LABELS[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{roadmapItems.length > 0 && (
|
||||
<GateSelect
|
||||
roadmapItems={roadmapItems}
|
||||
defaultValue={initial.roadmap_item_id || ''}
|
||||
disabled={statusLocked}
|
||||
/>
|
||||
)}
|
||||
<div className="form-actions">
|
||||
<button type="submit" className="btn btn-primary" disabled={busy || statusLocked}>
|
||||
{busy ? 'Speichern …' : submitLabel}
|
||||
</button>
|
||||
{onCancel && (
|
||||
<button type="button" className="btn btn-secondary" onClick={onCancel} disabled={busy}>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<section className="card">
|
||||
<div className="section-header">
|
||||
|
|
@ -44,49 +57,24 @@ export function BacklogSection({
|
|||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-block-mobile"
|
||||
onClick={() => setShowForm((v) => !v)}
|
||||
onClick={() => setModalMode({ kind: 'create' })}
|
||||
>
|
||||
{showForm ? 'Abbrechen' : 'Backlog-Item'}
|
||||
Backlog-Item
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForm && canManage && (
|
||||
<form className="inline-form-block" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Titel
|
||||
<input
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
maxLength={255}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
{roadmapItems.length > 0 && (
|
||||
<label>
|
||||
Zielzustand (Gate)
|
||||
<select value={gateId} onChange={(e) => setGateId(e.target.value)}>
|
||||
<option value="">— keins —</option>
|
||||
{roadmapItems.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<button type="submit" className="btn btn-primary" disabled={busy}>
|
||||
Anlegen
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{items.length === 0 && <EmptyState message="Backlog ist leer." />}
|
||||
|
||||
<ul className="item-list">
|
||||
{items.map((item) => (
|
||||
<li key={item.id} className="list-item card-list-item">
|
||||
<div className="list-item-main">
|
||||
<button
|
||||
type="button"
|
||||
className="list-item-main list-item-main--clickable"
|
||||
onClick={() => canManage && setModalMode({ kind: 'edit', item })}
|
||||
disabled={!canManage}
|
||||
>
|
||||
<strong>{item.title}</strong>
|
||||
{item.description && (
|
||||
<p className="list-item-desc">{item.description}</p>
|
||||
|
|
@ -96,43 +84,16 @@ export function BacklogSection({
|
|||
Gate: {gateTitleById(roadmapItems, item.roadmap_item_id) || item.roadmap_item_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="list-item-meta action-controls">
|
||||
<StatusBadge kind="backlog" status={item.status} />
|
||||
<PriorityBadge priority={item.priority} />
|
||||
{canManage && item.status !== 'converted' && roadmapItems.length > 0 && (
|
||||
<select
|
||||
className="inline-select"
|
||||
value={item.roadmap_item_id || ''}
|
||||
onChange={(e) => onUpdateGate(item.id, e.target.value)}
|
||||
aria-label="Gate-Zuordnung"
|
||||
>
|
||||
<option value="">Gate …</option>
|
||||
{roadmapItems.map((gate) => (
|
||||
<option key={gate.id} value={gate.id}>
|
||||
{gate.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{canManage && item.status !== 'converted' && (
|
||||
<>
|
||||
<select
|
||||
className="inline-select"
|
||||
value={item.status}
|
||||
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
|
||||
aria-label="Backlog-Status"
|
||||
>
|
||||
{BACKLOG_STATUSES.filter((s) => s !== 'converted').map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{BACKLOG_STATUS_LABELS[s]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{item.status === 'accepted' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => onConvert(item.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
|
|
@ -141,7 +102,15 @@ export function BacklogSection({
|
|||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setModalMode({ kind: 'edit', item })}
|
||||
disabled={busy}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => onDelete(item.id)}
|
||||
>
|
||||
Löschen
|
||||
|
|
@ -152,6 +121,27 @@ export function BacklogSection({
|
|||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Modal open={Boolean(modalMode)} title={modalTitle} onClose={closeModal}>
|
||||
{modalMode?.kind === 'create' && (
|
||||
<BacklogItemForm
|
||||
roadmapItems={roadmapItems}
|
||||
onSubmit={handleCreateSubmit}
|
||||
onCancel={closeModal}
|
||||
busy={busy}
|
||||
submitLabel="Anlegen"
|
||||
/>
|
||||
)}
|
||||
{modalMode?.kind === 'edit' && modalMode.item && (
|
||||
<BacklogItemForm
|
||||
initial={modalMode.item}
|
||||
roadmapItems={roadmapItems}
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={closeModal}
|
||||
busy={busy}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ export function GateSelect({
|
|||
defaultValue = '',
|
||||
label = 'Zielzustand (Gate)',
|
||||
allowEmpty = true,
|
||||
disabled = false,
|
||||
}) {
|
||||
if (roadmapItems.length === 0) return null
|
||||
|
||||
return (
|
||||
<label>
|
||||
{label}
|
||||
<select name={name} defaultValue={defaultValue || ''}>
|
||||
<select name={name} defaultValue={defaultValue || ''} disabled={disabled}>
|
||||
{allowEmpty && <option value="">— keins —</option>}
|
||||
{roadmapItems.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
|
|
|
|||
68
frontend/src/components/Modal.jsx
Normal file
68
frontend/src/components/Modal.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="modal-root" role="presentation" onMouseDown={onClose}>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className={`modal-dialog modal-dialog--${size}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="modal-dialog__header">
|
||||
<h2 id={titleId} className="modal-dialog__title">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-dialog__close btn btn-secondary btn-sm"
|
||||
onClick={onClose}
|
||||
aria-label="Schließen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="modal-dialog__body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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'}
|
||||
</button>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Projekt „${project.title}“ wirklich löschen?`)) {
|
||||
onDelete(project.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => onEdit(project)}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Projekt „${project.title}“ wirklich löschen?`)) {
|
||||
onDelete(project.id)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ProjectTreeNodes
|
||||
projects={projects}
|
||||
roadmapItems={roadmapItems}
|
||||
initiativeId={initiativeId}
|
||||
parentId={project.id}
|
||||
selectedProjectId={selectedProjectId}
|
||||
onSelectProject={onSelectProject}
|
||||
canManage={canManage}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
busy={busy}
|
||||
/>
|
||||
|
|
@ -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 (
|
||||
<section className="card projects-section">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Struktur</h2>
|
||||
<p className="section-lead muted">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Link
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-block-mobile"
|
||||
to={`${projectPath('new')}?initiative=${initiativeId}`}
|
||||
onClick={() => setModalMode({ kind: 'create' })}
|
||||
>
|
||||
Neues Projekt
|
||||
</Link>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
@ -145,16 +179,41 @@ export function ProjectsSection({
|
|||
<ProjectTreeNodes
|
||||
projects={projects}
|
||||
roadmapItems={roadmapItems}
|
||||
initiativeId={initiativeId}
|
||||
parentId=""
|
||||
selectedProjectId={selectedProjectId}
|
||||
onSelectProject={onSelectProject}
|
||||
canManage={canManage}
|
||||
onEdit={(project) => setModalMode({ kind: 'edit', project })}
|
||||
onDelete={onDelete}
|
||||
busy={busy}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal open={Boolean(modalMode)} title={modalTitle} onClose={closeModal} size="lg">
|
||||
{modalMode?.kind === 'create' && (
|
||||
<ProjectForm
|
||||
initial={{ status: 'active' }}
|
||||
projects={projects}
|
||||
roadmapItems={roadmapItems}
|
||||
onSubmit={handleCreateSubmit}
|
||||
onCancel={closeModal}
|
||||
busy={busy}
|
||||
submitLabel="Anlegen"
|
||||
/>
|
||||
)}
|
||||
{modalMode?.kind === 'edit' && modalMode.project && (
|
||||
<ProjectForm
|
||||
initial={modalMode.project}
|
||||
projects={projects}
|
||||
roadmapItems={roadmapItems}
|
||||
excludeProjectId={modalMode.project.id}
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={closeModal}
|
||||
busy={busy}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 <LoadingState message="Lade Profil …" />
|
||||
|
|
@ -16,27 +24,56 @@ function PlanProfileInner() {
|
|||
return <p className="muted">Vorhaben nicht gefunden.</p>
|
||||
}
|
||||
|
||||
async function handleSave(payload) {
|
||||
const ok = await ops.handleUpdateInitiative(payload)
|
||||
if (ok) {
|
||||
refreshInitiativeMeta()
|
||||
setEditing(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="card plan-profile">
|
||||
<header className="plan-profile__header">
|
||||
<h2 className="card-title">{initiative.title}</h2>
|
||||
<StatusBadge kind="initiative" status={initiative.status} />
|
||||
</header>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{initiative.goal ? (
|
||||
<div className="plan-profile__field">
|
||||
<h3 className="plan-profile__label">Ziel</h3>
|
||||
<p>{initiative.goal}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">
|
||||
Noch kein Ziel hinterlegt — vollständiges Profil folgt mit Archetypen (AP1.10).
|
||||
<>
|
||||
<section className="card plan-profile">
|
||||
<header className="plan-profile__header">
|
||||
<h2 className="card-title">{initiative.title}</h2>
|
||||
<div className="plan-profile__header-actions">
|
||||
<StatusBadge kind="initiative" status={initiative.status} />
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-sm"
|
||||
onClick={() => setEditing(true)}
|
||||
disabled={formBusy}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{error && <p className="error">{error}</p>}
|
||||
{initiative.goal ? (
|
||||
<div className="plan-profile__field">
|
||||
<h3 className="plan-profile__label">Ziel</h3>
|
||||
<p>{initiative.goal}</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">Noch kein Ziel hinterlegt.</p>
|
||||
)}
|
||||
<p className="muted plan-profile__note">
|
||||
Archetyp-Felder und dynamische Attribute folgen mit AP1.10 (Entity Field System).
|
||||
</p>
|
||||
)}
|
||||
<p className="muted plan-profile__note">
|
||||
Bearbeitung per Modal kommt in AP1.10 (Entity Field System).
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<Modal open={editing} title="Vorhaben bearbeiten" onClose={() => setEditing(false)}>
|
||||
<InitiativeForm
|
||||
initial={initiative}
|
||||
onSubmit={handleSave}
|
||||
onCancel={() => setEditing(false)}
|
||||
busy={formBusy}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,12 +25,13 @@ function PlanStructureInner() {
|
|||
<>
|
||||
{error && <p className="error">{error}</p>}
|
||||
<ProjectsSection
|
||||
initiativeId={initiativeId}
|
||||
projects={projects}
|
||||
roadmapItems={roadmapItems}
|
||||
selectedProjectId={selectedProjectId}
|
||||
onSelectProject={setSelectedProjectId}
|
||||
canManage={capabilities.has('kairo.project.manage')}
|
||||
onCreate={ops.handleCreateProject}
|
||||
onUpdate={ops.handleUpdateProject}
|
||||
onDelete={ops.handleDeleteProject}
|
||||
busy={ops.formBusy}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user