AP1.10c: Profil-Modal mit Archetyp, EFS und Owner.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m13s
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 18s
Test Suite / playwright-smoke (push) Successful in 12s

InitiativeForm laedt Felder nach gewaehltem Archetyp; Plan-Profil zeigt Standardfelder und Zusatzfelder; actor_ref und Owner-Auswahl.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-11 07:48:00 +02:00
parent 43d30944aa
commit dbe6f4f684
4 changed files with 207 additions and 95 deletions

View File

@ -1,11 +1,11 @@
export function FieldRenderer({ definitions, values, onChange, disabled = false }) {
export function FieldRenderer({ definitions, values, onChange, disabled = false, actors = [] }) {
if (!definitions?.length) {
return null
}
return (
<fieldset className="field-renderer">
<legend className="field-renderer__legend">Zusatzfelder</legend>
<legend className="field-renderer__legend">Zusatzfelder (Archetyp)</legend>
{definitions.map((def) => (
<DynamicField
key={def.field_key}
@ -13,13 +13,14 @@ export function FieldRenderer({ definitions, values, onChange, disabled = false
value={values?.[def.field_key]}
onChange={(next) => onChange(def.field_key, next)}
disabled={disabled}
actors={actors}
/>
))}
</fieldset>
)
}
function DynamicField({ definition, value, onChange, disabled }) {
function DynamicField({ definition, value, onChange, disabled, actors = [] }) {
const { field_key, field_type, label, required, validation_json: validation } = definition
const inputId = `field-${field_key}`
@ -83,6 +84,30 @@ function DynamicField({ definition, value, onChange, disabled }) {
)
}
if (field_type === 'actor_ref') {
return (
<label htmlFor={inputId}>
{label}
{required && ' *'}
<select
id={inputId}
name={field_key}
value={value ?? ''}
onChange={(e) => onChange(e.target.value || null)}
required={required}
disabled={disabled}
>
<option value=""></option>
{actors.map((actor) => (
<option key={actor.id} value={actor.id}>
{actor.name}
</option>
))}
</select>
</label>
)
}
const inputType =
field_type === 'number' ? 'number' : field_type === 'date' ? 'date' : field_type === 'url' ? 'url' : 'text'

View File

@ -4,7 +4,6 @@ import { INITIATIVE_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.j
import { INITIATIVE_ARCHETYPE_LABELS } from '../constants/initiativeArchetypes.js'
import { listEntityArchetypes } from '../api/entityArchetypes.js'
import {
getInitiativeFieldDefinitions,
getInitiativeFields,
listArchetypeFieldDefinitions,
} from '../api/entityFields.js'
@ -13,6 +12,7 @@ import { FieldRenderer } from './FieldRenderer.jsx'
export function InitiativeForm({
initial = {},
initiativeId = null,
actors = [],
onSubmit,
onCancel,
busy = false,
@ -46,22 +46,24 @@ export function InitiativeForm({
}, [])
useEffect(() => {
if (!archetypeKey) return undefined
let cancelled = false
setFieldsLoading(true)
async function loadFields() {
try {
let definitions = []
const definitions = await listArchetypeFieldDefinitions('initiative', archetypeKey)
let values = {}
if (initiativeId) {
definitions = await getInitiativeFieldDefinitions(initiativeId)
values = await getInitiativeFields(initiativeId)
} else if (archetypeKey) {
definitions = await listArchetypeFieldDefinitions('initiative', archetypeKey)
}
const validKeys = new Set(definitions.map((def) => def.field_key))
const filteredValues = Object.fromEntries(
Object.entries(values || {}).filter(([key]) => validKeys.has(key)),
)
if (!cancelled) {
setFieldDefinitions(definitions)
setDynamicValues(values)
setDynamicValues(filteredValues)
}
} catch {
if (!cancelled) {
@ -90,6 +92,7 @@ export function InitiativeForm({
target_state_summary: form.target_state_summary.value,
status: form.status.value,
priority: form.priority.value,
owner_actor_id: form.owner_actor_id?.value || undefined,
dynamicFields: dynamicValues,
})
}
@ -102,7 +105,9 @@ export function InitiativeForm({
const selectedArchetype = (archetypes || []).find((item) => item.key === archetypeKey)
return (
<form className="form workspace-form" onSubmit={handleSubmit}>
<form className="form workspace-form initiative-form" onSubmit={handleSubmit}>
<fieldset className="initiative-form__section">
<legend>Identität</legend>
<label>
Titel
<input name="title" defaultValue={initial.title || ''} required maxLength={255} />
@ -125,6 +130,23 @@ export function InitiativeForm({
<span className="muted form-hint">{selectedArchetype.description}</span>
)}
</label>
{actors.length > 0 && (
<label>
Verantwortlich (Owner)
<select name="owner_actor_id" defaultValue={initial.owner_actor_id || ''}>
<option value=""></option>
{actors.map((actor) => (
<option key={actor.id} value={actor.id}>
{actor.name}
</option>
))}
</select>
</label>
)}
</fieldset>
<fieldset className="initiative-form__section">
<legend>Programm</legend>
<label>
Kurzbeschreibung (Ziel)
<textarea name="goal" rows={2} defaultValue={initial.goal || ''} />
@ -141,12 +163,18 @@ export function InitiativeForm({
defaultValue={initial.target_state_summary || ''}
/>
</label>
</fieldset>
<FieldRenderer
definitions={fieldDefinitions}
values={dynamicValues}
onChange={handleDynamicChange}
disabled={busy || fieldsLoading}
actors={actors}
/>
<fieldset className="initiative-form__section">
<legend>Steuerung</legend>
<div className="form-row form-row--2">
<label>
Status
@ -169,6 +197,8 @@ export function InitiativeForm({
</select>
</label>
</div>
</fieldset>
<div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy || !formReady}>
{busy ? 'Speichern …' : submitLabel}

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { ArchetypeBadge } from '../../components/ArchetypeBadge.jsx'
import { StatusBadge } from '../../components/StatusBadge.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { Modal } from '../../components/Modal.jsx'
@ -17,13 +18,15 @@ import {
getInitiativeFields,
} from '../../api/entityFields.js'
function DynamicFieldsDisplay({ initiativeId }) {
function DynamicFieldsDisplay({ initiativeId, refreshKey = 0 }) {
const [definitions, setDefinitions] = useState([])
const [values, setValues] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
setLoading(true)
try {
const [defs, fieldValues] = await Promise.all([
getInitiativeFieldDefinitions(initiativeId),
@ -38,15 +41,21 @@ function DynamicFieldsDisplay({ initiativeId }) {
setDefinitions([])
setValues({})
}
} finally {
if (!cancelled) setLoading(false)
}
}
load()
return () => {
cancelled = true
}
}, [initiativeId])
}, [initiativeId, refreshKey])
if (!definitions.length || values === null) {
if (loading || values === null) {
return null
}
if (!definitions.length) {
return null
}
@ -57,15 +66,25 @@ function DynamicFieldsDisplay({ initiativeId }) {
})
if (!visible.length) {
return null
return (
<div className="plan-profile__field">
<h3 className="plan-profile__label">Zusatzfelder (Archetyp)</h3>
<p className="muted">Noch keine Werte für die Felder dieses Archetyps.</p>
</div>
)
}
return visible.map((def) => (
return (
<div className="plan-profile__section">
<h3 className="plan-profile__section-title">Zusatzfelder (Archetyp)</h3>
{visible.map((def) => (
<div key={def.field_key} className="plan-profile__field">
<h3 className="plan-profile__label">{def.label}</h3>
<h4 className="plan-profile__label">{def.label}</h4>
<p>{formatFieldValue(def, values[def.field_key])}</p>
</div>
))
))}
</div>
)
}
function formatFieldValue(definition, value) {
@ -91,6 +110,7 @@ function PlanProfileInner() {
projects,
} = ops
const [editing, setEditing] = useState(false)
const [fieldsRefreshKey, setFieldsRefreshKey] = useState(0)
const canManage = capabilities.has('kairo.initiative.manage')
if (loading) {
@ -110,6 +130,7 @@ function PlanProfileInner() {
const ok = await ops.handleUpdateInitiative(payload)
if (ok) {
refreshInitiativeMeta()
setFieldsRefreshKey((key) => key + 1)
setEditing(false)
}
}
@ -118,7 +139,13 @@ function PlanProfileInner() {
<>
<section className="card plan-profile">
<header className="plan-profile__header">
<div>
<h2 className="card-title">{initiative.title}</h2>
<ArchetypeBadge
archetypeKey={initiative.archetype_key}
archetypes={initiativeArchetypes}
/>
</div>
<div className="plan-profile__header-actions">
<StatusBadge kind="initiative" status={initiative.status} />
{canManage && (
@ -165,27 +192,29 @@ function PlanProfileInner() {
<p>{initiative.goal}</p>
</div>
) : (
<p className="muted">Noch keine Kurzbeschreibung hinterlegt.</p>
<p className="muted plan-profile__empty-hint">Noch keine Kurzbeschreibung hinterlegt.</p>
)}
{initiative.vision && (
<div className="plan-profile__field">
<h3 className="plan-profile__label">Vision</h3>
<p>{initiative.vision}</p>
<p>{initiative.vision?.trim() ? initiative.vision : '—'}</p>
</div>
)}
{initiative.target_state_summary && (
<div className="plan-profile__field">
<h3 className="plan-profile__label">Zielzustand</h3>
<p>{initiative.target_state_summary}</p>
<h3 className="plan-profile__label">Zielzustand (Kurzfassung)</h3>
<p>
{initiative.target_state_summary?.trim() ? initiative.target_state_summary : '—'}
</p>
</div>
)}
<DynamicFieldsDisplay initiativeId={initiative.id} key={editing ? 'closed' : 'open'} />
<DynamicFieldsDisplay
initiativeId={initiative.id}
refreshKey={fieldsRefreshKey}
/>
</section>
<Modal open={editing} title="Vorhaben bearbeiten" onClose={() => setEditing(false)} size="lg">
<InitiativeForm
initiativeId={initiative.id}
initial={initiative}
actors={actors}
onSubmit={handleSave}
onCancel={() => setEditing(false)}
busy={formBusy}

View File

@ -1595,9 +1595,37 @@
background: var(--jk-surface-subtle, #f8fafc);
}
.plan-profile__steering-edit {
.initiative-form__section {
border: none;
margin: 0 0 1.25rem;
padding: 0;
}
.initiative-form__section legend {
font-weight: 600;
margin-bottom: 0.75rem;
padding: 0;
}
.plan-profile__section-title {
margin: 1.25rem 0 0.75rem;
font-size: 1rem;
}
.plan-profile__empty-hint {
margin: 0 0 1rem;
max-width: 28rem;
}
.field-renderer {
border: none;
margin: 0 0 1.25rem;
padding: 0;
}
.field-renderer__legend {
font-weight: 600;
margin-bottom: 0.75rem;
padding: 0;
}
.method-profile-select {