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
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>
180 lines
5.1 KiB
JavaScript
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 }
|