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
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:
parent
e50c04c96f
commit
a99d4a8974
21
backend/migrations/015_backlog_sort_order.sql
Normal file
21
backend/migrations/015_backlog_sort_order.sql
Normal 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);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 & 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}>
|
||||
|
|
|
|||
|
|
@ -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 & 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}
|
||||
|
|
|
|||
32
frontend/src/components/ReorderControls.jsx
Normal file
32
frontend/src/components/ReorderControls.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
22
frontend/src/hooks/useMinWidth.js
Normal file
22
frontend/src/hooks/useMinWidth.js
Normal 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
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ function PlanStructureInner() {
|
|||
onCreate={ops.handleCreateProject}
|
||||
onUpdate={ops.handleUpdateProject}
|
||||
onDelete={ops.handleDeleteProject}
|
||||
onReorder={ops.handleReorderProjects}
|
||||
busy={ops.formBusy}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
71
frontend/src/utils/reorder.js
Normal file
71
frontend/src/utils/reorder.js
Normal 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)
|
||||
}
|
||||
48
frontend/src/utils/reorder.test.js
Normal file
48
frontend/src/utils/reorder.test.js
Normal 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([])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user