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) { if (!definitions?.length) {
return null return null
} }
return ( return (
<fieldset className="field-renderer"> <fieldset className="field-renderer">
<legend className="field-renderer__legend">Zusatzfelder</legend> <legend className="field-renderer__legend">Zusatzfelder (Archetyp)</legend>
{definitions.map((def) => ( {definitions.map((def) => (
<DynamicField <DynamicField
key={def.field_key} key={def.field_key}
@ -13,13 +13,14 @@ export function FieldRenderer({ definitions, values, onChange, disabled = false
value={values?.[def.field_key]} value={values?.[def.field_key]}
onChange={(next) => onChange(def.field_key, next)} onChange={(next) => onChange(def.field_key, next)}
disabled={disabled} disabled={disabled}
actors={actors}
/> />
))} ))}
</fieldset> </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 { field_key, field_type, label, required, validation_json: validation } = definition
const inputId = `field-${field_key}` 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 = const inputType =
field_type === 'number' ? 'number' : field_type === 'date' ? 'date' : field_type === 'url' ? 'url' : 'text' 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 { INITIATIVE_ARCHETYPE_LABELS } from '../constants/initiativeArchetypes.js'
import { listEntityArchetypes } from '../api/entityArchetypes.js' import { listEntityArchetypes } from '../api/entityArchetypes.js'
import { import {
getInitiativeFieldDefinitions,
getInitiativeFields, getInitiativeFields,
listArchetypeFieldDefinitions, listArchetypeFieldDefinitions,
} from '../api/entityFields.js' } from '../api/entityFields.js'
@ -13,6 +12,7 @@ import { FieldRenderer } from './FieldRenderer.jsx'
export function InitiativeForm({ export function InitiativeForm({
initial = {}, initial = {},
initiativeId = null, initiativeId = null,
actors = [],
onSubmit, onSubmit,
onCancel, onCancel,
busy = false, busy = false,
@ -46,22 +46,24 @@ export function InitiativeForm({
}, []) }, [])
useEffect(() => { useEffect(() => {
if (!archetypeKey) return undefined
let cancelled = false let cancelled = false
setFieldsLoading(true) setFieldsLoading(true)
async function loadFields() { async function loadFields() {
try { try {
let definitions = [] const definitions = await listArchetypeFieldDefinitions('initiative', archetypeKey)
let values = {} let values = {}
if (initiativeId) { if (initiativeId) {
definitions = await getInitiativeFieldDefinitions(initiativeId)
values = await getInitiativeFields(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) { if (!cancelled) {
setFieldDefinitions(definitions) setFieldDefinitions(definitions)
setDynamicValues(values) setDynamicValues(filteredValues)
} }
} catch { } catch {
if (!cancelled) { if (!cancelled) {
@ -90,6 +92,7 @@ export function InitiativeForm({
target_state_summary: form.target_state_summary.value, target_state_summary: form.target_state_summary.value,
status: form.status.value, status: form.status.value,
priority: form.priority.value, priority: form.priority.value,
owner_actor_id: form.owner_actor_id?.value || undefined,
dynamicFields: dynamicValues, dynamicFields: dynamicValues,
}) })
} }
@ -102,7 +105,9 @@ export function InitiativeForm({
const selectedArchetype = (archetypes || []).find((item) => item.key === archetypeKey) const selectedArchetype = (archetypes || []).find((item) => item.key === archetypeKey)
return ( 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> <label>
Titel Titel
<input name="title" defaultValue={initial.title || ''} required maxLength={255} /> <input name="title" defaultValue={initial.title || ''} required maxLength={255} />
@ -125,6 +130,23 @@ export function InitiativeForm({
<span className="muted form-hint">{selectedArchetype.description}</span> <span className="muted form-hint">{selectedArchetype.description}</span>
)} )}
</label> </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> <label>
Kurzbeschreibung (Ziel) Kurzbeschreibung (Ziel)
<textarea name="goal" rows={2} defaultValue={initial.goal || ''} /> <textarea name="goal" rows={2} defaultValue={initial.goal || ''} />
@ -141,12 +163,18 @@ export function InitiativeForm({
defaultValue={initial.target_state_summary || ''} defaultValue={initial.target_state_summary || ''}
/> />
</label> </label>
</fieldset>
<FieldRenderer <FieldRenderer
definitions={fieldDefinitions} definitions={fieldDefinitions}
values={dynamicValues} values={dynamicValues}
onChange={handleDynamicChange} onChange={handleDynamicChange}
disabled={busy || fieldsLoading} disabled={busy || fieldsLoading}
actors={actors}
/> />
<fieldset className="initiative-form__section">
<legend>Steuerung</legend>
<div className="form-row form-row--2"> <div className="form-row form-row--2">
<label> <label>
Status Status
@ -169,6 +197,8 @@ export function InitiativeForm({
</select> </select>
</label> </label>
</div> </div>
</fieldset>
<div className="form-actions"> <div className="form-actions">
<button type="submit" className="btn btn-primary" disabled={busy || !formReady}> <button type="submit" className="btn btn-primary" disabled={busy || !formReady}>
{busy ? 'Speichern …' : submitLabel} {busy ? 'Speichern …' : submitLabel}

View File

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

View File

@ -1595,9 +1595,37 @@
background: var(--jk-surface-subtle, #f8fafc); 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; 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 { .method-profile-select {