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

Ersetzt Inline-Formulare durch Dialoge; Scope-Breadcrumb aktualisiert sich nach Profil-Speichern.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-09 11:26:48 +02:00
parent bbadf22d39
commit e50c04c96f
14 changed files with 529 additions and 135 deletions

View File

@ -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

View File

@ -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"

View 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>
)
}

View File

@ -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>
)
}

View File

@ -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}>

View 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>
)
}

View File

@ -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 &amp; 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>
)
}

View File

@ -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,

View File

@ -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,
],
)

View File

@ -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}

View File

@ -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>
</>
)
}

View File

@ -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}
/>

View File

@ -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;
}

View File

@ -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;