Kairo-Jinkendo/frontend/src/components/QuickJump.jsx
Lars e42edd86fa
Some checks failed
Deploy Development / deploy (push) Successful in 47s
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
AP1.9a: Arbeitsmodi-Shell mit Scope, Objekt-Routen und Legacy-Redirects.
Ersetzt die Vorhaben-Tab-Navigation durch Cockpit/Ausführen/Planen/Kontrolle/Team, Deep Links für APs/Projekte/Gates und Programm-Scope im Header.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 12:55:37 +02:00

180 lines
5.1 KiB
JavaScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { listInitiatives } from '../api/initiatives.js'
import { listWorkspaceOpenActions } from '../api/workspace.js'
import { useProgramScope } from '../context/ProgramScopeContext.jsx'
import { loadRecentObjects } from '../utils/recentObjects.js'
import {
actionPath,
gatePath,
initiativeCatalogPath,
projectPath,
scopedPath,
} from '../utils/routes.js'
const TYPE_LABELS = {
action: 'Arbeitspaket',
project: 'Projekt',
gate: 'Gate',
initiative: 'Vorhaben',
}
function matchQuery(text, query) {
return (text || '').toLowerCase().includes(query.toLowerCase())
}
export function QuickJump() {
const navigate = useNavigate()
const { applyFromInitiative } = useProgramScope()
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [initiatives, setInitiatives] = useState([])
const [actions, setActions] = useState([])
const [recent, setRecent] = useState([])
const rootRef = useRef(null)
useEffect(() => {
let cancelled = false
Promise.all([
listInitiatives().catch(() => []),
listWorkspaceOpenActions().catch(() => []),
]).then(([initData, actionData]) => {
if (cancelled) return
setInitiatives(initData)
setActions(actionData)
})
return () => {
cancelled = true
}
}, [])
useEffect(() => {
setRecent(loadRecentObjects())
}, [open])
useEffect(() => {
function onDocClick(e) {
if (rootRef.current && !rootRef.current.contains(e.target)) {
setOpen(false)
}
}
document.addEventListener('mousedown', onDocClick)
return () => document.removeEventListener('mousedown', onDocClick)
}, [])
const results = useMemo(() => {
const q = query.trim()
if (!q) return []
const hits = []
for (const item of initiatives) {
if (matchQuery(item.title, q)) {
hits.push({
key: `initiative-${item.id}`,
label: item.title,
meta: 'Vorhaben',
href: scopedPath('/control/status', { initiativeId: item.id }),
onSelect: () => applyFromInitiative(item.id),
})
}
}
for (const action of actions) {
if (matchQuery(action.title, q)) {
hits.push({
key: `action-${action.id}`,
label: action.title,
meta: 'Arbeitspaket',
href: actionPath(action.id),
})
}
}
return hits.slice(0, 8)
}, [query, initiatives, actions, applyFromInitiative])
const go = useCallback(
(href, onSelect) => {
if (onSelect) onSelect()
navigate(href)
setOpen(false)
setQuery('')
},
[navigate],
)
return (
<div className="quick-jump" ref={rootRef}>
<button
type="button"
className="quick-jump__trigger"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-haspopup="listbox"
>
Springen zu
</button>
{open && (
<div className="quick-jump__panel">
<input
type="search"
className="quick-jump__input"
placeholder="Vorhaben, Arbeitspaket …"
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
<ul className="quick-jump__list" role="listbox">
{query.trim() === '' &&
recent.map((item) => (
<li key={`recent-${item.type}-${item.id}`}>
<button
type="button"
className="quick-jump__item"
onClick={() => go(item.href)}
>
<span className="quick-jump__item-label">{item.title}</span>
<span className="quick-jump__item-meta">
{TYPE_LABELS[item.type] || item.type} · zuletzt
</span>
</button>
</li>
))}
{query.trim() !== '' &&
results.map((item) => (
<li key={item.key}>
<button
type="button"
className="quick-jump__item"
onClick={() => go(item.href, item.onSelect)}
>
<span className="quick-jump__item-label">{item.label}</span>
<span className="quick-jump__item-meta">{item.meta}</span>
</button>
</li>
))}
{query.trim() !== '' && results.length === 0 && (
<li className="quick-jump__empty">Keine Treffer</li>
)}
{query.trim() === '' && recent.length === 0 && (
<li className="quick-jump__empty">Tippe zum Suchen oder öffne ein Objekt.</li>
)}
</ul>
<div className="quick-jump__footer">
<button
type="button"
className="link-inline"
onClick={() => go(initiativeCatalogPath())}
>
Vorhaben-Katalog
</button>
</div>
</div>
)}
</div>
)
}
export { actionPath, projectPath, gatePath }