All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m17s
Test Suite / lint-backend (push) Successful in 2s
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 13s
Migration 020 fuer Kantenarten und Parallelgruppe; steering/graph berechnet Fortschritt; UI zeigt Bereit/Blockiert in Liste und Graph. Co-authored-by: Cursor <cursoragent@cursor.com>
222 lines
6.8 KiB
JavaScript
222 lines
6.8 KiB
JavaScript
import { Link } from 'react-router-dom'
|
|
import { gatePath } from '../utils/routes.js'
|
|
import { gateTitleById } from './GateSelect.jsx'
|
|
|
|
const DEPENDENCY_TYPE_LABELS = {
|
|
requires: 'Voraussetzung',
|
|
blocks: 'Blockiert',
|
|
related: 'Bezug',
|
|
parallel_group: 'Parallelgruppe',
|
|
optional_branch: 'Optional',
|
|
}
|
|
|
|
const OUTGOING_PHRASES = {
|
|
requires: 'benötigt',
|
|
blocks: 'blockiert',
|
|
related: 'Bezug zu',
|
|
}
|
|
|
|
const INCOMING_PHRASES = {
|
|
requires: 'Voraussetzung für',
|
|
blocks: 'Wird blockiert von',
|
|
related: 'Bezug von',
|
|
}
|
|
|
|
function groupDependencies(itemId, dependencies) {
|
|
/** @type {{ outgoing: typeof dependencies, incoming: typeof dependencies }} */
|
|
const groups = { outgoing: [], incoming: [] }
|
|
for (const dep of dependencies) {
|
|
if (dep.from_item_id === itemId) groups.outgoing.push(dep)
|
|
else if (dep.to_item_id === itemId) groups.incoming.push(dep)
|
|
}
|
|
return groups
|
|
}
|
|
|
|
function DependencyRow({ dep, itemId, siblingItems, direction, canManage, busy, onDelete }) {
|
|
const otherId = direction === 'outgoing' ? dep.to_item_id : dep.from_item_id
|
|
const otherTitle = gateTitleById(siblingItems, otherId) || otherId
|
|
const phrase =
|
|
direction === 'outgoing'
|
|
? OUTGOING_PHRASES[dep.dependency_type] || dep.dependency_type
|
|
: INCOMING_PHRASES[dep.dependency_type] || dep.dependency_type
|
|
|
|
return (
|
|
<li className="list-item card-list-item gate-dependency-item">
|
|
<div className="list-item-main">
|
|
<p className="gate-dependency-item__phrase">
|
|
{direction === 'outgoing' ? (
|
|
<>
|
|
Dieses Gate <strong>{phrase}</strong>{' '}
|
|
<Link to={gatePath(otherId)}>{otherTitle}</Link>
|
|
</>
|
|
) : (
|
|
<>
|
|
<strong>{phrase}</strong>{' '}
|
|
<Link to={gatePath(otherId)}>{otherTitle}</Link>
|
|
</>
|
|
)}
|
|
</p>
|
|
<p className="muted list-item-sub">
|
|
{DEPENDENCY_TYPE_LABELS[dep.dependency_type] || dep.dependency_type}
|
|
{dep.group_key ? ` · Gruppe ${dep.group_key}` : ''}
|
|
</p>
|
|
</div>
|
|
{canManage && (
|
|
<div className="list-item-meta action-controls">
|
|
<button
|
|
type="button"
|
|
className="btn btn-secondary btn-sm"
|
|
disabled={busy}
|
|
onClick={() => onDelete(dep.id)}
|
|
>
|
|
Entfernen
|
|
</button>
|
|
</div>
|
|
)}
|
|
</li>
|
|
)
|
|
}
|
|
|
|
export function GateDependenciesSection({
|
|
itemId,
|
|
siblingItems = [],
|
|
dependencies = [],
|
|
canManage,
|
|
busy,
|
|
onAdd,
|
|
onDelete,
|
|
}) {
|
|
const candidates = siblingItems.filter((item) => item.id !== itemId)
|
|
const { outgoing, incoming } = groupDependencies(itemId, dependencies)
|
|
const hasAny = outgoing.length > 0 || incoming.length > 0
|
|
|
|
async function handleAddSubmit(event) {
|
|
event.preventDefault()
|
|
const form = event.target
|
|
const toItemId = form.to_item_id.value
|
|
const dependencyType = form.dependency_type.value
|
|
const groupKey = form.group_key?.value?.trim()
|
|
if (!toItemId) return
|
|
const body = {
|
|
to_item_id: toItemId,
|
|
dependency_type: dependencyType,
|
|
}
|
|
if (dependencyType === 'parallel_group') {
|
|
if (!groupKey) return
|
|
body.group_key = groupKey
|
|
}
|
|
await onAdd(body)
|
|
form.reset()
|
|
}
|
|
|
|
return (
|
|
<section className="gate-dependencies-section">
|
|
<div className="section-header">
|
|
<div>
|
|
<h3>Abhängigkeiten</h3>
|
|
<p className="section-lead muted">
|
|
Kanten für den Gate-Graph — ausgehende Abhängigkeiten kannst du hier anlegen und
|
|
entfernen.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{!hasAny && (
|
|
<p className="muted gate-dependencies-section__empty">
|
|
Noch keine Kanten — im Graph erscheinen Gates sonst nach Reihenfolge.
|
|
</p>
|
|
)}
|
|
|
|
{outgoing.length > 0 && (
|
|
<>
|
|
<h4 className="gate-dependencies-section__group-title">Ausgehend</h4>
|
|
<ul className="item-list gate-dependency-list">
|
|
{outgoing.map((dep) => (
|
|
<DependencyRow
|
|
key={dep.id}
|
|
dep={dep}
|
|
itemId={itemId}
|
|
siblingItems={siblingItems}
|
|
direction="outgoing"
|
|
canManage={canManage}
|
|
busy={busy}
|
|
onDelete={onDelete}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
|
|
{incoming.length > 0 && (
|
|
<>
|
|
<h4 className="gate-dependencies-section__group-title">Eingehend</h4>
|
|
<ul className="item-list gate-dependency-list">
|
|
{incoming.map((dep) => (
|
|
<DependencyRow
|
|
key={dep.id}
|
|
dep={dep}
|
|
itemId={itemId}
|
|
siblingItems={siblingItems}
|
|
direction="incoming"
|
|
canManage={canManage}
|
|
busy={busy}
|
|
onDelete={onDelete}
|
|
/>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
|
|
{canManage && candidates.length > 0 && (
|
|
<form className="inline-form-block gate-dependency-form" onSubmit={handleAddSubmit}>
|
|
<h4>Kante hinzufügen</h4>
|
|
<p className="muted form-hint gate-dependency-form__hint">
|
|
Dieses Gate …
|
|
</p>
|
|
<label>
|
|
Beziehung
|
|
<select name="dependency_type" defaultValue="requires">
|
|
<option value="requires">benötigt (Voraussetzung)</option>
|
|
<option value="blocks">blockiert</option>
|
|
<option value="parallel_group">Parallelgruppe</option>
|
|
<option value="optional_branch">optionaler Zweig</option>
|
|
<option value="related">steht in Bezug zu</option>
|
|
</select>
|
|
</label>
|
|
<label className="gate-dependency-form__group-key">
|
|
Parallelgruppe (Schlüssel)
|
|
<input
|
|
name="group_key"
|
|
maxLength={128}
|
|
placeholder="z. B. phase-1"
|
|
/>
|
|
<span className="muted form-hint">Nur bei Kantenart „Parallelgruppe“.</span>
|
|
</label>
|
|
<label>
|
|
Anderes Gate
|
|
<select name="to_item_id" required defaultValue="">
|
|
<option value="" disabled>
|
|
Gate wählen …
|
|
</option>
|
|
{candidates.map((item) => (
|
|
<option key={item.id} value={item.id}>
|
|
{item.title}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button type="submit" className="btn btn-primary" disabled={busy}>
|
|
Kante anlegen
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
{canManage && candidates.length === 0 && (
|
|
<p className="muted gate-dependencies-section__empty">
|
|
Mindestens ein weiteres Gate im Vorhaben nötig, um Kanten zu definieren.
|
|
</p>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|