AP1.12c: Reorder für Projekte und Backlog per sort_order.
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Failing after 1m46s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped

Migration 015 für Backlog sort_order; DnD auf Desktop, Pfeile auf Mobile; PATCH-Batches für Geschwister.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-09 11:35:08 +02:00
parent e50c04c96f
commit a99d4a8974
15 changed files with 709 additions and 130 deletions

View File

@ -0,0 +1,21 @@
-- AP1.12c: Backlog sort_order für Reorder im Plan-Modus
ALTER TABLE backlog_items
ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
WITH ranked AS (
SELECT
id,
(ROW_NUMBER() OVER (
PARTITION BY tenant_id, initiative_id
ORDER BY created_at ASC, id ASC
) - 1) * 10 AS next_order
FROM backlog_items
)
UPDATE backlog_items bi
SET sort_order = ranked.next_order
FROM ranked
WHERE bi.id = ranked.id;
CREATE INDEX IF NOT EXISTS idx_backlog_items_sort
ON backlog_items (tenant_id, initiative_id, sort_order);

View File

@ -19,6 +19,7 @@ class BacklogCreateRequest(BaseModel):
status: Literal["new", "triaged", "accepted", "rejected"] = "new"
priority: Literal["low", "normal", "high"] = "normal"
roadmap_item_id: Optional[str] = None
sort_order: Optional[int] = None
class BacklogUpdateRequest(BaseModel):
@ -28,6 +29,7 @@ class BacklogUpdateRequest(BaseModel):
priority: Optional[Literal["low", "normal", "high"]] = None
roadmap_item_id: Optional[str] = None
clear_roadmap_item: bool = False
sort_order: Optional[int] = None
class BacklogConvertRequest(BaseModel):
@ -64,6 +66,7 @@ def update_backlog_item(
priority=body.priority,
roadmap_item_id=body.roadmap_item_id,
clear_roadmap_item=body.clear_roadmap_item,
sort_order=body.sort_order,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -18,7 +18,7 @@ BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "convert
_BACKLOG_COLUMNS = """
id, tenant_id, initiative_id, title, description, status,
priority, roadmap_item_id, converted_action_id, created_at, updated_at
priority, roadmap_item_id, converted_action_id, sort_order, created_at, updated_at
"""
@ -53,6 +53,7 @@ def create_backlog_item(
status: BacklogStatus = "new",
priority: str = "normal",
roadmap_item_id: Optional[str] = None,
sort_order: Optional[int] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
@ -72,16 +73,34 @@ def create_backlog_item(
initiative_id=initiative_id,
roadmap_item_id=roadmap_item_id,
)
cur.execute(
"""
SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order
FROM backlog_items
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
next_order = int(cur.fetchone()["next_order"])
cur.execute(
f"""
INSERT INTO backlog_items (
tenant_id, initiative_id, title, description, status, priority,
roadmap_item_id
roadmap_item_id, sort_order
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_BACKLOG_COLUMNS}
""",
(tenant_id, initiative_id, title, description, status, priority, roadmap_item_id),
(
tenant_id,
initiative_id,
title,
description,
status,
priority,
roadmap_item_id,
sort_order if sort_order is not None else next_order,
),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
@ -109,7 +128,7 @@ def list_backlog_for_initiative(*, tenant_id: str, initiative_id: str) -> list[d
SELECT {_BACKLOG_COLUMNS}
FROM backlog_items
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC, title
ORDER BY sort_order ASC, created_at ASC, title
""",
(tenant_id, initiative_id),
)
@ -147,6 +166,7 @@ def update_backlog_item(
priority: Optional[str] = None,
roadmap_item_id: Optional[str] = None,
clear_roadmap_item: bool = False,
sort_order: Optional[int] = None,
) -> Optional[dict[str, Any]]:
existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
if not existing:
@ -191,6 +211,9 @@ def update_backlog_item(
conn.close()
updates.append("roadmap_item_id = %s")
params.append(roadmap_item_id)
if sort_order is not None:
updates.append("sort_order = %s")
params.append(sort_order)
if not updates:
return existing

View File

@ -247,6 +247,29 @@ def test_backlog_crud(client):
assert deleted.status_code == 204
def test_backlog_sort_order(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
first = _create_backlog(client, token, initiative_id, title="First").json()
second = _create_backlog(client, token, initiative_id, title="Second").json()
assert first["sort_order"] < second["sort_order"]
patched = client.patch(
f"/api/backlog/{second['id']}",
json={"sort_order": 0},
headers=_auth(token),
)
assert patched.status_code == 200
assert patched.json()["sort_order"] == 0
listed = client.get(
f"/api/initiatives/{initiative_id}/backlog",
headers=_auth(token),
).json()
assert [row["title"] for row in listed[:2]] == ["Second", "First"]
def test_milestone_crud(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.16.6-ap1.12b"
DB_SCHEMA_VERSION = "014"
APP_VERSION = "0.16.7-ap1.12c"
DB_SCHEMA_VERSION = "015"
APP_NAME = "jinkendo-kairo"

View File

@ -1,10 +1,13 @@
import { useState } from 'react'
import { useMemo, useState } from 'react'
import { StatusBadge } from './StatusBadge.jsx'
import { PriorityBadge } from './PriorityBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
import { Modal } from './Modal.jsx'
import { BacklogItemForm } from './BacklogItemForm.jsx'
import { ReorderControls } from './ReorderControls.jsx'
import { gateTitleById } from './GateSelect.jsx'
import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js'
import { useMinWidth } from '../hooks/useMinWidth.js'
export function BacklogSection({
items,
@ -12,11 +15,21 @@ export function BacklogSection({
canManage,
onCreate,
onUpdate,
onReorder,
onConvert,
onDelete,
busy,
}) {
const [modalMode, setModalMode] = useState(null)
const [dragItemId, setDragItemId] = useState('')
const [dropTargetId, setDropTargetId] = useState('')
const isDesktop = useMinWidth(1024)
const canReorder = canManage && typeof onReorder === 'function'
const sortedItems = useMemo(
() => sortByOrder(items.filter((item) => item.status !== 'converted')),
[items],
)
function closeModal() {
setModalMode(null)
@ -46,13 +59,58 @@ export function BacklogSection({
if (ok !== false) closeModal()
}
async function applyPatches(patches) {
if (!patches.length) return
await onReorder(patches)
}
async function handleMove(itemId, direction) {
const patches = computeMovePatches(sortedItems, itemId, direction)
await applyPatches(patches)
}
function handleDragStart(event, itemId) {
if (!canReorder || !isDesktop) return
setDragItemId(itemId)
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', itemId)
}
function handleDragEnd() {
setDragItemId('')
setDropTargetId('')
}
function handleDragOver(event, itemId) {
if (!canReorder || !isDesktop || !dragItemId) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
setDropTargetId(itemId)
}
async function handleDrop(event, targetId) {
event.preventDefault()
if (!canReorder || !isDesktop || !dragItemId) {
handleDragEnd()
return
}
const patches = computeDropPatches(sortedItems, dragItemId, targetId)
handleDragEnd()
await applyPatches(patches)
}
const modalTitle =
modalMode?.kind === 'create' ? 'Backlog-Item anlegen' : 'Backlog-Item bearbeiten'
return (
<section className="card">
<section className="card backlog-section">
<div className="section-header">
<h2>Backlog</h2>
<div>
<h2>Backlog</h2>
<p className="section-lead muted">
Reihenfolge per Drag &amp; Drop (Desktop) oder / (Mobile).
</p>
</div>
{canManage && (
<button
type="button"
@ -66,60 +124,105 @@ export function BacklogSection({
{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">
<button
type="button"
className="list-item-main list-item-main--clickable"
onClick={() => canManage && setModalMode({ kind: 'edit', item })}
disabled={!canManage}
<ul className="item-list backlog-reorder-list">
{sortedItems.map((item, index) => {
const isDragging = dragItemId === item.id
const isDropTarget = dropTargetId === item.id && dragItemId && dragItemId !== item.id
const reorderable = canReorder && item.status !== 'converted'
return (
<li
key={item.id}
className={
'list-item card-list-item backlog-reorder-item' +
(isDragging ? ' backlog-reorder-item--dragging' : '') +
(isDropTarget ? ' backlog-reorder-item--drop-target' : '') +
(reorderable && isDesktop ? ' backlog-reorder-item--draggable' : '')
}
draggable={reorderable && isDesktop}
onDragStart={(event) => handleDragStart(event, item.id)}
onDragEnd={handleDragEnd}
onDragOver={(event) => handleDragOver(event, item.id)}
onDrop={(event) => handleDrop(event, item.id)}
>
<strong>{item.title}</strong>
{item.description && (
<p className="list-item-desc">{item.description}</p>
{reorderable && isDesktop && (
<span className="backlog-reorder-item__drag-hint" aria-hidden="true">
</span>
)}
{item.roadmap_item_id && (
<p className="list-item-sub muted">
Gate: {gateTitleById(roadmapItems, item.roadmap_item_id) || item.roadmap_item_id}
</p>
)}
</button>
<div className="list-item-meta action-controls">
<StatusBadge kind="backlog" status={item.status} />
<PriorityBadge priority={item.priority} />
{canManage && item.status !== 'converted' && (
<>
{item.status === 'accepted' && (
<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>
)}
{item.roadmap_item_id && (
<p className="list-item-sub muted">
Gate:{' '}
{gateTitleById(roadmapItems, item.roadmap_item_id) || item.roadmap_item_id}
</p>
)}
</button>
<div className="list-item-meta action-controls">
{reorderable && !isDesktop && (
<ReorderControls
itemId={item.id}
canMoveUp={index > 0}
canMoveDown={index < sortedItems.length - 1}
onMove={(direction) => handleMove(item.id, direction)}
busy={busy}
/>
)}
<StatusBadge kind="backlog" status={item.status} />
<PriorityBadge priority={item.priority} />
{canManage && item.status !== 'converted' && (
<>
{item.status === 'accepted' && (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => onConvert(item.id)}
disabled={busy}
>
In Maßnahme umwandeln
</button>
)}
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => onConvert(item.id)}
className="btn btn-secondary btn-sm"
onClick={() => setModalMode({ kind: 'edit', item })}
disabled={busy}
>
In Maßnahme umwandeln
Bearbeiten
</button>
)}
<button
type="button"
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
</button>
</>
)}
</div>
</li>
))}
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => onDelete(item.id)}
>
Löschen
</button>
</>
)}
</div>
</li>
)
})}
{items
.filter((item) => item.status === 'converted')
.map((item) => (
<li key={item.id} className="list-item card-list-item backlog-reorder-item--converted">
<div className="list-item-main">
<strong>{item.title}</strong>
<p className="list-item-sub muted">Konvertiert nicht mehr sortierbar</p>
</div>
<StatusBadge kind="backlog" status={item.status} />
</li>
))}
</ul>
<Modal open={Boolean(modalMode)} title={modalTitle} onClose={closeModal}>

View File

@ -4,10 +4,13 @@ import { EmptyState } from './EmptyState.jsx'
import { StatusBadge } from './StatusBadge.jsx'
import { Modal } from './Modal.jsx'
import { ProjectForm } from './ProjectForm.jsx'
import { ReorderControls } from './ReorderControls.jsx'
import {
buildProjectsByParent,
containerKindLabel,
} from '../utils/projectTree.js'
import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js'
import { useMinWidth } from '../hooks/useMinWidth.js'
import { gateTitleById } from './GateSelect.jsx'
import { projectPath } from '../utils/routes.js'
@ -18,6 +21,15 @@ function ProjectTreeNodes({
selectedProjectId,
onSelectProject,
canManage,
canReorder,
isDesktop,
dragProjectId,
dropTargetId,
onDragStart,
onDragEnd,
onDragOver,
onDrop,
onMove,
onEdit,
onDelete,
busy,
@ -29,80 +41,120 @@ function ProjectTreeNodes({
return (
<ul className="project-tree-children">
{nodes.map((project) => (
<li key={project.id} className="project-tree-branch">
<div
className={`list-item card-list-item project-tree-item${selectedProjectId === project.id ? ' project-tree-item--selected' : ''}`}
>
<div className="list-item-main project-tree-item-main">
<Link
to={projectPath(project.id)}
className="project-tree-title-link"
>
<strong>{project.title}</strong>
</Link>
{project.container_kind && (
<span className="project-kind-badge muted">
{containerKindLabel(project.container_kind)}
</span>
)}
{project.roadmap_item_id && (
<span className="project-kind-badge muted">
Gate: {gateTitleById(roadmapItems, project.roadmap_item_id)}
{nodes.map((project, index) => {
const isDragging = dragProjectId === project.id
const isDropTarget = dropTargetId === project.id && dragProjectId && dragProjectId !== project.id
return (
<li key={project.id} className="project-tree-branch">
<div
className={
'list-item card-list-item project-tree-item' +
(selectedProjectId === project.id ? ' project-tree-item--selected' : '') +
(isDragging ? ' project-tree-item--dragging' : '') +
(isDropTarget ? ' project-tree-item--drop-target' : '') +
(canReorder && isDesktop ? ' project-tree-item--draggable' : '')
}
draggable={canReorder && isDesktop}
onDragStart={(event) => onDragStart(event, project.id, parentId || '')}
onDragEnd={onDragEnd}
onDragOver={(event) => onDragOver(event, project.id)}
onDrop={(event) => onDrop(event, project.id, parentId || '')}
>
{canReorder && isDesktop && (
<span className="project-tree-item__drag-hint" aria-hidden="true">
</span>
)}
<div className="list-item-main project-tree-item-main">
<Link
to={projectPath(project.id)}
className="project-tree-title-link"
draggable={false}
>
<strong>{project.title}</strong>
</Link>
{project.container_kind && (
<span className="project-kind-badge muted">
{containerKindLabel(project.container_kind)}
</span>
)}
{project.roadmap_item_id && (
<span className="project-kind-badge muted">
Gate: {gateTitleById(roadmapItems, project.roadmap_item_id)}
</span>
)}
</div>
<div className="list-item-meta action-controls project-tree-item-actions">
{canReorder && !isDesktop && (
<ReorderControls
itemId={project.id}
canMoveUp={index > 0}
canMoveDown={index < nodes.length - 1}
onMove={(direction) => onMove(project.id, parentId || '', direction)}
busy={busy}
/>
)}
<StatusBadge kind="project" status={project.status} />
<button
type="button"
className={`btn btn-secondary btn-sm${selectedProjectId === project.id ? ' btn-primary' : ''}`}
title="Arbeitspakete filtern"
onClick={() =>
onSelectProject(selectedProjectId === project.id ? '' : project.id)
}
>
{selectedProjectId === project.id ? 'Filter aktiv' : 'Filtern'}
</button>
{canManage && (
<>
<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>
<div className="list-item-meta action-controls project-tree-item-actions">
<StatusBadge kind="project" status={project.status} />
<button
type="button"
className={`btn btn-secondary btn-sm${selectedProjectId === project.id ? ' btn-primary' : ''}`}
title="Arbeitspakete filtern"
onClick={() =>
onSelectProject(selectedProjectId === project.id ? '' : project.id)
}
>
{selectedProjectId === project.id ? 'Filter aktiv' : 'Filtern'}
</button>
{canManage && (
<>
<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}
parentId={project.id}
selectedProjectId={selectedProjectId}
onSelectProject={onSelectProject}
canManage={canManage}
onEdit={onEdit}
onDelete={onDelete}
busy={busy}
/>
</li>
))}
<ProjectTreeNodes
projects={projects}
roadmapItems={roadmapItems}
parentId={project.id}
selectedProjectId={selectedProjectId}
onSelectProject={onSelectProject}
canManage={canManage}
canReorder={canReorder}
isDesktop={isDesktop}
dragProjectId={dragProjectId}
dropTargetId={dropTargetId}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDrop={onDrop}
onMove={onMove}
onEdit={onEdit}
onDelete={onDelete}
busy={busy}
/>
</li>
)
})}
</ul>
)
}
@ -116,9 +168,21 @@ export function ProjectsSection({
onCreate,
onUpdate,
onDelete,
onReorder,
busy,
}) {
const [modalMode, setModalMode] = useState(null)
const [dragProjectId, setDragProjectId] = useState('')
const [dragParentId, setDragParentId] = useState('')
const [dropTargetId, setDropTargetId] = useState('')
const isDesktop = useMinWidth(1024)
const canReorder = canManage && typeof onReorder === 'function'
const byParent = useMemo(() => buildProjectsByParent(projects), [projects])
function getSiblings(parentKey) {
return sortByOrder(byParent.get(parentKey || '') || [])
}
function closeModal() {
setModalMode(null)
@ -135,6 +199,48 @@ export function ProjectsSection({
if (ok) closeModal()
}
async function applyPatches(patches) {
if (!patches.length) return
await onReorder(patches)
}
async function handleMove(projectId, parentKey, direction) {
const patches = computeMovePatches(getSiblings(parentKey), projectId, direction)
await applyPatches(patches)
}
function handleDragStart(event, projectId, parentKey) {
if (!canReorder || !isDesktop) return
setDragProjectId(projectId)
setDragParentId(parentKey)
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', projectId)
}
function handleDragEnd() {
setDragProjectId('')
setDragParentId('')
setDropTargetId('')
}
function handleDragOver(event, projectId) {
if (!canReorder || !isDesktop || !dragProjectId) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
setDropTargetId(projectId)
}
async function handleDrop(event, targetId, parentKey) {
event.preventDefault()
if (!canReorder || !isDesktop || !dragProjectId || dragParentId !== parentKey) {
handleDragEnd()
return
}
const patches = computeDropPatches(getSiblings(parentKey), dragProjectId, targetId)
handleDragEnd()
await applyPatches(patches)
}
const modalTitle =
modalMode?.kind === 'create' ? 'Neues Projekt' : 'Projekt bearbeiten'
@ -144,8 +250,8 @@ export function ProjectsSection({
<div>
<h2>Struktur</h2>
<p className="section-lead muted">
Projekte, Streams und Phasen Bearbeitung im Modal, Detailseite über den Titel.
Filter schränkt Arbeitspakete ein.
Projekte per Drag &amp; Drop (Desktop) oder / (Mobile) sortieren Bearbeitung im
Modal.
</p>
</div>
{canManage && (
@ -183,6 +289,15 @@ export function ProjectsSection({
selectedProjectId={selectedProjectId}
onSelectProject={onSelectProject}
canManage={canManage}
canReorder={canReorder}
isDesktop={isDesktop}
dragProjectId={dragProjectId}
dropTargetId={dropTargetId}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDrop={handleDrop}
onMove={handleMove}
onEdit={(project) => setModalMode({ kind: 'edit', project })}
onDelete={onDelete}
busy={busy}

View File

@ -0,0 +1,32 @@
/**
* / Reorder für Touch / schmale Viewports (AP1.12c).
*
* @param {{ itemId: string, canMoveUp: boolean, canMoveDown: boolean, onMove: (direction: 'up'|'down') => void, busy?: boolean }} props
*/
export function ReorderControls({ itemId, canMoveUp, canMoveDown, onMove, busy = false }) {
return (
<div className="reorder-controls" aria-label="Reihenfolge">
<button
type="button"
className="reorder-controls__btn"
aria-label="Nach oben"
disabled={!canMoveUp || busy}
onClick={() => onMove('up')}
>
</button>
<button
type="button"
className="reorder-controls__btn"
aria-label="Nach unten"
disabled={!canMoveDown || busy}
onClick={() => onMove('down')}
>
</button>
<span className="sr-only" id={`reorder-${itemId}`}>
Reihenfolge ändern
</span>
</div>
)
}

View File

@ -510,6 +510,42 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
}
}
async function handleReorderProjects(patches) {
if (!patches?.length) return true
setFormBusy(true)
setError(null)
try {
await Promise.all(
patches.map((patch) => updateProject(patch.id, { sort_order: patch.sort_order })),
)
setProjects(await listInitiativeProjects(id))
return true
} catch (err) {
setError(err.message)
return false
} finally {
setFormBusy(false)
}
}
async function handleReorderBacklog(patches) {
if (!patches?.length) return true
setFormBusy(true)
setError(null)
try {
await Promise.all(
patches.map((patch) => updateBacklogItem(patch.id, { sort_order: patch.sort_order })),
)
await load()
return true
} catch (err) {
setError(err.message)
return false
} finally {
setFormBusy(false)
}
}
const milestones = useMemo(
() => roadmapItems.filter((item) => item.item_type === 'milestone'),
[roadmapItems]
@ -701,6 +737,8 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ
handleCreateProject,
handleUpdateProject,
handleDeleteProject,
handleReorderProjects,
handleReorderBacklog,
handleCreateEvidence,
handleEvidenceStatus,
handleDeleteEvidence,

View File

@ -0,0 +1,22 @@
import { useEffect, useState } from 'react'
/**
* @param {number} minWidthPx
*/
export function useMinWidth(minWidthPx) {
const query = `(min-width: ${minWidthPx}px)`
const [matches, setMatches] = useState(() => {
if (typeof window === 'undefined') return true
return window.matchMedia(query).matches
})
useEffect(() => {
const mq = window.matchMedia(query)
const onChange = () => setMatches(mq.matches)
onChange()
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [query])
return matches
}

View File

@ -28,6 +28,7 @@ export function InitiativeInboxPage() {
canManage={capabilities.has('kairo.backlog.manage')}
onCreate={handleCreateBacklog}
onUpdate={handleUpdateBacklog}
onReorder={handleReorderBacklog}
onConvert={handleConvertBacklog}
onDelete={handleDeleteBacklog}
busy={formBusy}

View File

@ -33,6 +33,7 @@ function PlanStructureInner() {
onCreate={ops.handleCreateProject}
onUpdate={ops.handleUpdateProject}
onDelete={ops.handleDeleteProject}
onReorder={ops.handleReorderProjects}
busy={ops.formBusy}
/>
</>

View File

@ -5,6 +5,18 @@
font-size: 0.92rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.error {
color: var(--jk-danger);
font-size: 0.92rem;
@ -1435,3 +1447,69 @@
cursor: default;
}
.reorder-controls {
display: inline-flex;
flex-direction: column;
gap: 2px;
}
@media (min-width: 1024px) {
.reorder-controls {
display: none;
}
}
.reorder-controls__btn {
min-width: 2rem;
padding: 2px 6px;
border-radius: 6px;
border: 1px solid var(--jk-border);
background: var(--jk-surface-raised);
color: var(--jk-text);
font-size: 12px;
line-height: 1.2;
cursor: pointer;
}
.reorder-controls__btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.project-tree-item--draggable,
.backlog-reorder-item--draggable {
cursor: grab;
}
.project-tree-item--dragging,
.backlog-reorder-item--dragging {
opacity: 0.55;
cursor: grabbing;
}
.project-tree-item--drop-target,
.backlog-reorder-item--drop-target {
outline: 2px dashed var(--jk-primary);
outline-offset: 2px;
}
.project-tree-item__drag-hint,
.backlog-reorder-item__drag-hint {
flex: 0 0 auto;
margin-right: 8px;
color: var(--jk-text-muted);
font-size: 12px;
user-select: none;
}
.project-tree-item,
.backlog-reorder-item {
display: flex;
align-items: flex-start;
gap: 8px;
}
.backlog-reorder-item--converted {
opacity: 0.75;
}

View File

@ -0,0 +1,71 @@
/**
* Reorder-Helfer (AP1.12c) sort_order-Patches für Geschwister-Listen.
*/
/**
* @template {{ id: string, sort_order?: number }} T
* @param {T[]} items
*/
export function sortByOrder(items) {
return [...items].sort(
(a, b) =>
(a.sort_order ?? 0) - (b.sort_order ?? 0) ||
String(a.id).localeCompare(String(b.id)),
)
}
/**
* @template {{ id: string, sort_order?: number }} T
* @param {T[]} items bereits sortierte Geschwister
* @param {number} fromIndex
* @param {number} toIndex
* @returns {{ id: string, sort_order: number }[]}
*/
export function computeReorderPatches(items, fromIndex, toIndex) {
if (fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= items.length) {
return []
}
const next = [...items]
const [moved] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, moved)
/** @type {{ id: string, sort_order: number }[]} */
const patches = []
next.forEach((item, index) => {
const order = index * 10
if ((item.sort_order ?? 0) !== order) {
patches.push({ id: item.id, sort_order: order })
}
})
return patches
}
/**
* @template {{ id: string, sort_order?: number }} T
* @param {T[]} items
* @param {string} itemId
* @param {'up'|'down'} direction
*/
export function computeMovePatches(items, itemId, direction) {
const sorted = sortByOrder(items)
const fromIndex = sorted.findIndex((item) => item.id === itemId)
if (fromIndex < 0) return []
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1
if (toIndex < 0 || toIndex >= sorted.length) return []
return computeReorderPatches(sorted, fromIndex, toIndex)
}
/**
* @template {{ id: string, sort_order?: number }} T
* @param {T[]} items
* @param {string} dragId
* @param {string} targetId
*/
export function computeDropPatches(items, dragId, targetId) {
if (!dragId || !targetId || dragId === targetId) return []
const sorted = sortByOrder(items)
const fromIndex = sorted.findIndex((item) => item.id === dragId)
const toIndex = sorted.findIndex((item) => item.id === targetId)
if (fromIndex < 0 || toIndex < 0) return []
return computeReorderPatches(sorted, fromIndex, toIndex)
}

View File

@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import {
computeDropPatches,
computeMovePatches,
computeReorderPatches,
sortByOrder,
} from './reorder.js'
const items = [
{ id: 'a', sort_order: 0 },
{ id: 'b', sort_order: 10 },
{ id: 'c', sort_order: 20 },
]
describe('reorder', () => {
it('sorts by sort_order then id', () => {
const unsorted = [
{ id: 'c', sort_order: 20 },
{ id: 'a', sort_order: 0 },
{ id: 'b', sort_order: 10 },
]
expect(sortByOrder(unsorted).map((i) => i.id)).toEqual(['a', 'b', 'c'])
})
it('computes patches when moving down', () => {
const patches = computeMovePatches(items, 'a', 'down')
expect(patches).toEqual(
expect.arrayContaining([
{ id: 'a', sort_order: 10 },
{ id: 'b', sort_order: 0 },
]),
)
expect(patches).toHaveLength(2)
})
it('computes patches for drag drop', () => {
const patches = computeDropPatches(items, 'c', 'a')
expect(patches).toEqual([
{ id: 'c', sort_order: 0 },
{ id: 'a', sort_order: 10 },
{ id: 'b', sort_order: 20 },
])
})
it('returns empty when indices unchanged', () => {
expect(computeReorderPatches(items, 1, 1)).toEqual([])
})
})