AP2.2b: Kritischer Pfad in Kontrolle für A2 Linear.
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m57s
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 20s
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m57s
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 20s
CriticalPathPanel aus Execution Graph, Next-Action-Begruendung und Integrationstest fuer sequential_dependency. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f6aba94864
commit
00567bb097
78
backend/tests/test_ap22b_linear_critical_path.py
Normal file
78
backend/tests/test_ap22b_linear_critical_path.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""AP2.2b — A2 linear project: critical path in execution graph + Next Action."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from tests.factories import provision_user_in_tenant
|
||||
from tests.test_initiatives_actions import _auth, _create_initiative, _login
|
||||
|
||||
|
||||
def test_linear_critical_path_and_next_action(client):
|
||||
user = provision_user_in_tenant(tenant_role="admin")
|
||||
token = _login(client, user)
|
||||
|
||||
created = _create_initiative(
|
||||
client,
|
||||
token,
|
||||
title="Neue Küche",
|
||||
archetype_key="initiative.linear_project",
|
||||
)
|
||||
initiative_id = created.json()["id"]
|
||||
|
||||
a = client.post(
|
||||
f"/api/initiatives/{initiative_id}/actions",
|
||||
json={"title": "Rohbau", "status": "open", "sort_order": 0},
|
||||
headers=_auth(token),
|
||||
)
|
||||
b = client.post(
|
||||
f"/api/initiatives/{initiative_id}/actions",
|
||||
json={"title": "Elektrik", "status": "open", "sort_order": 1},
|
||||
headers=_auth(token),
|
||||
)
|
||||
c = client.post(
|
||||
f"/api/initiatives/{initiative_id}/actions",
|
||||
json={"title": "Endabnahme", "status": "open", "sort_order": 2},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert a.status_code == 201 and b.status_code == 201 and c.status_code == 201
|
||||
a_id, b_id, c_id = a.json()["id"], b.json()["id"], c.json()["id"]
|
||||
|
||||
dep1 = client.post(
|
||||
f"/api/initiatives/{initiative_id}/execution/dependencies",
|
||||
json={
|
||||
"predecessor_action_id": a_id,
|
||||
"successor_action_id": b_id,
|
||||
"dependency_kind": "requires",
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
dep2 = client.post(
|
||||
f"/api/initiatives/{initiative_id}/execution/dependencies",
|
||||
json={
|
||||
"predecessor_action_id": b_id,
|
||||
"successor_action_id": c_id,
|
||||
"dependency_kind": "requires",
|
||||
},
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert dep1.status_code == 201 and dep2.status_code == 201
|
||||
|
||||
graph = client.get(
|
||||
f"/api/initiatives/{initiative_id}/execution/graph-state",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert graph.status_code == 200
|
||||
body = graph.json()
|
||||
assert body["critical_path"] == [a_id, b_id, c_id]
|
||||
assert a_id in body["ready_actions"]
|
||||
assert b_id in body["blocked_actions"]
|
||||
|
||||
snap = client.get(
|
||||
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||
headers=_auth(token),
|
||||
)
|
||||
assert snap.status_code == 200
|
||||
next_actions = snap.json().get("next_actions") or []
|
||||
assert next_actions
|
||||
top = next_actions[0]
|
||||
assert top.get("action_id") == a_id
|
||||
assert top.get("reason_code") in ("execution_ready", "execution_critical_path")
|
||||
134
frontend/src/components/CriticalPathPanel.jsx
Normal file
134
frontend/src/components/CriticalPathPanel.jsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getInitiativeExecutionGraphState } from '../api/executionPlan.js'
|
||||
import { LoadingState } from './LoadingState.jsx'
|
||||
import { ErrorState } from './ErrorState.jsx'
|
||||
import { EmptyState } from './EmptyState.jsx'
|
||||
import { StatusBadge } from './StatusBadge.jsx'
|
||||
import { ExecutionFlowBadge } from './ExecutionFlowBadge.jsx'
|
||||
import {
|
||||
buildCriticalPathSteps,
|
||||
findNextReadyOnCriticalPath,
|
||||
summarizeCriticalPath,
|
||||
} from '../utils/executionGraph.js'
|
||||
import { actionPath, scopedPath } from '../utils/routes.js'
|
||||
|
||||
export function CriticalPathPanel({
|
||||
initiativeId,
|
||||
actions = [],
|
||||
embedded = true,
|
||||
}) {
|
||||
const [graphState, setGraphState] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!initiativeId) {
|
||||
setGraphState(null)
|
||||
setLoading(false)
|
||||
return undefined
|
||||
}
|
||||
let cancelled = false
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
getInitiativeExecutionGraphState(initiativeId)
|
||||
.then((data) => {
|
||||
if (!cancelled) setGraphState(data)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setError(err.message)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [initiativeId])
|
||||
|
||||
const steps = buildCriticalPathSteps(graphState, actions)
|
||||
const summary = summarizeCriticalPath(graphState, actions)
|
||||
const nextReady = findNextReadyOnCriticalPath(graphState, actions)
|
||||
|
||||
const body = (
|
||||
<>
|
||||
{loading && <LoadingState message="Lade kritischer Pfad …" />}
|
||||
{!loading && error && (
|
||||
<ErrorState message={error} onRetry={() => window.location.reload()} />
|
||||
)}
|
||||
{!loading && !error && steps.length === 0 && (
|
||||
<EmptyState message="Noch kein kritischer Pfad — Arbeitspakete mit Abhängigkeiten unter Plan → Zielzustände / Durchführungsplan anlegen." />
|
||||
)}
|
||||
{!loading && !error && steps.length > 0 && (
|
||||
<>
|
||||
<p className="critical-path-summary muted">{summary.message}</p>
|
||||
{nextReady && (
|
||||
<div className="critical-path-next card-list-item">
|
||||
<span className="badge status-badge status-active">Nächster Schritt</span>
|
||||
<strong>{nextReady.action.title}</strong>
|
||||
<p className="muted">
|
||||
Kritischer Pfad — ausführungsbereit, keine blockierenden Vorgänger.
|
||||
</p>
|
||||
<Link
|
||||
to={actionPath(nextReady.actionId)}
|
||||
className="btn btn-primary btn-sm"
|
||||
>
|
||||
Arbeitspaket öffnen
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<ol className="critical-path-steps item-list">
|
||||
{steps.map((step) => (
|
||||
<li
|
||||
key={step.actionId}
|
||||
className={
|
||||
'list-item card-list-item critical-path-step' +
|
||||
(step.isNextReady ? ' critical-path-step--next' : '') +
|
||||
(step.status === 'done' ? ' critical-path-step--done' : '')
|
||||
}
|
||||
>
|
||||
<span className="critical-path-step__index muted">{step.index}</span>
|
||||
<div className="list-item-main">
|
||||
<Link to={actionPath(step.actionId)} className="link-inline">
|
||||
<strong>{step.title}</strong>
|
||||
</Link>
|
||||
<p className="list-item-desc muted">{step.reason}</p>
|
||||
</div>
|
||||
<div className="list-item-meta">
|
||||
<StatusBadge status={step.status} />
|
||||
<ExecutionFlowBadge meta={step.meta} actionStatus={step.status} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<p className="muted critical-path-hint">
|
||||
Graph unter{' '}
|
||||
<Link to={scopedPath('/plan/gates', { initiativeId })} className="link-inline">
|
||||
Plan → Zielzustände
|
||||
</Link>
|
||||
{' '}pflegen.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<section className="card critical-path-panel">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Kritischer Pfad</h2>
|
||||
<p className="section-lead muted">
|
||||
Längster Abhängigkeitspfad aus dem Execution Graph — Steuerungsantwort für
|
||||
lineare Vorhaben (A2).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{body}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
|
@ -60,6 +60,13 @@ export const SIGNAL_LABELS = {
|
|||
work_in_progress: 'Arbeit in Umsetzung',
|
||||
}
|
||||
|
||||
export const NEXT_ACTION_REASON_LABELS = {
|
||||
execution_ready: 'Ausführungsbereit',
|
||||
execution_critical_path: 'Kritischer Pfad',
|
||||
execution_waiting: 'Wartet (Reihenfolge)',
|
||||
planning_debt: 'Durchführungsplan fehlt',
|
||||
}
|
||||
|
||||
export const NEXT_ACTION_KIND_LABELS = {
|
||||
action: 'Arbeitspaket',
|
||||
resolve_blocker: 'Blocker klären',
|
||||
|
|
|
|||
|
|
@ -2,11 +2,22 @@ import { Link } from 'react-router-dom'
|
|||
import { scopedPath } from '../../utils/routes.js'
|
||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||
import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx'
|
||||
import { CriticalPathPanel } from '../../components/CriticalPathPanel.jsx'
|
||||
import { NextActionWidget } from '../../widgets/NextActionWidget.jsx'
|
||||
|
||||
function usesCriticalPathControl(initiative, steeringSnapshot) {
|
||||
const archetypeKey = initiative?.archetype_key
|
||||
const methodKey = steeringSnapshot?.method_key
|
||||
return (
|
||||
archetypeKey === 'initiative.linear_project' || methodKey === 'sequential_dependency'
|
||||
)
|
||||
}
|
||||
|
||||
export function InitiativeOverviewPage() {
|
||||
const {
|
||||
initiativeId,
|
||||
initiative,
|
||||
actions,
|
||||
error,
|
||||
steeringSnapshot,
|
||||
steeringSnapshotLoading,
|
||||
|
|
@ -18,6 +29,7 @@ export function InitiativeOverviewPage() {
|
|||
} = useInitiativeOperations()
|
||||
|
||||
const counts = steeringSnapshot?.counts || {}
|
||||
const showCriticalPath = usesCriticalPathControl(initiative, steeringSnapshot)
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -35,6 +47,10 @@ export function InitiativeOverviewPage() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{showCriticalPath && capabilities.has('kairo.action.read') && (
|
||||
<CriticalPathPanel initiativeId={initiativeId} actions={actions} />
|
||||
)}
|
||||
|
||||
{capabilities.has('kairo.workspace.read') && (
|
||||
<NextActionWidget
|
||||
scope="initiative"
|
||||
|
|
@ -42,6 +58,16 @@ export function InitiativeOverviewPage() {
|
|||
items={steeringSnapshot?.next_actions}
|
||||
loading={steeringSnapshotLoading}
|
||||
embedded
|
||||
title={
|
||||
showCriticalPath
|
||||
? 'Nächster Schritt am kritischen Pfad'
|
||||
: undefined
|
||||
}
|
||||
subtitle={
|
||||
showCriticalPath
|
||||
? 'Empfehlung aus sequential_dependency — bereite Arbeitspakete zuerst.'
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -704,3 +704,56 @@
|
|||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* AP2.2b — kritischer Pfad (Kontrolle / A2) */
|
||||
.critical-path-panel .critical-path-summary {
|
||||
margin: 0 0 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.critical-path-next {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
border-left: 3px solid var(--jk-primary);
|
||||
background: var(--jk-surface-muted, rgba(0, 0, 0, 0.03));
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.critical-path-steps {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.critical-path-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.critical-path-step--next {
|
||||
border-color: var(--jk-primary);
|
||||
box-shadow: inset 3px 0 0 var(--jk-primary);
|
||||
}
|
||||
|
||||
.critical-path-step--done {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.critical-path-step__index {
|
||||
min-width: 1.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.critical-path-hint {
|
||||
margin: 12px 0 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.next-action-reason {
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,3 +44,126 @@ export function dependenciesForAction(dependencies, actionId) {
|
|||
successors: list.filter((dep) => dep.predecessor_action_id === actionId),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Array<{ id: string }>} actions
|
||||
*/
|
||||
export function actionByIdMap(actions) {
|
||||
return Object.fromEntries((actions || []).map((a) => [a.id, a]))
|
||||
}
|
||||
|
||||
/**
|
||||
* First ready action on critical_path (AP2.2b).
|
||||
* @param {Record<string, unknown>|null|undefined} graphState
|
||||
* @param {Array<{ id: string, title?: string, status?: string }>} actions
|
||||
*/
|
||||
export function findNextReadyOnCriticalPath(graphState, actions) {
|
||||
const path = graphState?.critical_path
|
||||
if (!Array.isArray(path) || path.length === 0) return null
|
||||
|
||||
const byId = actionByIdMap(actions)
|
||||
for (const rawId of path) {
|
||||
const actionId = String(rawId)
|
||||
const meta = getActionExecutionMeta(graphState, actionId)
|
||||
if (!meta?.ready) continue
|
||||
const action = byId[actionId]
|
||||
if (!action || action.status === 'done' || action.status === 'discarded') continue
|
||||
return { actionId, action, meta }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>|null|undefined} graphState
|
||||
* @param {string} actionId
|
||||
* @param {Array<{ id: string, title?: string }>} actions
|
||||
*/
|
||||
export function buildCriticalPathStepReason(graphState, actionId, actions) {
|
||||
const meta = getActionExecutionMeta(graphState, actionId)
|
||||
if (!meta) return 'Keine Graph-Daten'
|
||||
|
||||
if (meta.ready) {
|
||||
return 'Bereit — keine offenen Vorgänger auf dem kritischen Pfad.'
|
||||
}
|
||||
if (meta.blocked && meta.blocked_by?.length) {
|
||||
const waitingOn = formatBlockedByTitles(actions, meta.blocked_by)
|
||||
return waitingOn
|
||||
? `Wartet auf: ${waitingOn}`
|
||||
: 'Wartet auf Vorgänger-Arbeitspakete.'
|
||||
}
|
||||
return 'Noch nicht ausführungsbereit.'
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>|null|undefined} graphState
|
||||
* @param {Array<{ id: string, title?: string, status?: string }>} actions
|
||||
*/
|
||||
export function buildCriticalPathSteps(graphState, actions) {
|
||||
const path = graphState?.critical_path
|
||||
if (!Array.isArray(path) || path.length === 0) return []
|
||||
|
||||
const byId = actionByIdMap(actions)
|
||||
const nextReady = findNextReadyOnCriticalPath(graphState, actions)
|
||||
|
||||
return path.map((rawId, index) => {
|
||||
const actionId = String(rawId)
|
||||
const action = byId[actionId]
|
||||
const meta = getActionExecutionMeta(graphState, actionId)
|
||||
return {
|
||||
index: index + 1,
|
||||
actionId,
|
||||
title: action?.title || actionId.slice(0, 8),
|
||||
status: action?.status || meta?.status || 'open',
|
||||
meta,
|
||||
isNextReady: nextReady?.actionId === actionId,
|
||||
reason: buildCriticalPathStepReason(graphState, actionId, actions),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>|null|undefined} graphState
|
||||
* @param {Array<{ id: string, title?: string, status?: string }>} actions
|
||||
*/
|
||||
export function summarizeCriticalPath(graphState, actions) {
|
||||
const steps = buildCriticalPathSteps(graphState, actions)
|
||||
if (steps.length === 0) {
|
||||
return {
|
||||
hasPath: false,
|
||||
message: 'Noch kein kritischer Pfad — lege Arbeitspakete mit Abhängigkeiten an.',
|
||||
}
|
||||
}
|
||||
|
||||
const nextReady = findNextReadyOnCriticalPath(graphState, actions)
|
||||
if (nextReady) {
|
||||
return {
|
||||
hasPath: true,
|
||||
message: `Nächster Schritt am kritischen Pfad: „${nextReady.action.title || 'Arbeitspaket'}".`,
|
||||
nextActionId: nextReady.actionId,
|
||||
}
|
||||
}
|
||||
|
||||
const openOnPath = steps.filter(
|
||||
(step) => step.status !== 'done' && step.status !== 'discarded',
|
||||
)
|
||||
if (openOnPath.length === 0) {
|
||||
return {
|
||||
hasPath: true,
|
||||
message: 'Kritischer Pfad abgeschlossen — alle Schritte erledigt.',
|
||||
}
|
||||
}
|
||||
|
||||
const waiting = openOnPath.find((step) => step.meta?.blocked)
|
||||
if (waiting) {
|
||||
return {
|
||||
hasPath: true,
|
||||
message: `Kritischer Pfad wartet bei „${waiting.title}" — ${waiting.reason}`,
|
||||
blockedActionId: waiting.actionId,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasPath: true,
|
||||
message: 'Kritischer Pfad vorhanden — kein bereiter Schritt.',
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import {
|
|||
formatBlockedByTitles,
|
||||
getActionExecutionMeta,
|
||||
sortActionsForExecutionPlan,
|
||||
findNextReadyOnCriticalPath,
|
||||
summarizeCriticalPath,
|
||||
buildCriticalPathSteps,
|
||||
} from './executionGraph.js'
|
||||
|
||||
describe('executionGraph utils', () => {
|
||||
|
|
@ -42,4 +45,40 @@ describe('executionGraph utils', () => {
|
|||
expect(dependenciesForAction(deps, 's1').predecessors).toHaveLength(1)
|
||||
expect(dependenciesForAction(deps, 'p1').successors).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('finds next ready step on critical path', () => {
|
||||
const graphState = {
|
||||
critical_path: ['a', 'b', 'c'],
|
||||
items: {
|
||||
a: { ready: false, blocked: true, blocked_by: ['x'] },
|
||||
b: { ready: true, blocked: false, blocked_by: [] },
|
||||
c: { ready: false, blocked: true, blocked_by: ['b'] },
|
||||
},
|
||||
}
|
||||
const actions = [
|
||||
{ id: 'a', title: 'Schritt A', status: 'open' },
|
||||
{ id: 'b', title: 'Schritt B', status: 'ready' },
|
||||
{ id: 'c', title: 'Schritt C', status: 'open' },
|
||||
]
|
||||
const next = findNextReadyOnCriticalPath(graphState, actions)
|
||||
expect(next?.actionId).toBe('b')
|
||||
expect(summarizeCriticalPath(graphState, actions).message).toContain('Schritt B')
|
||||
})
|
||||
|
||||
it('builds critical path steps with reasons', () => {
|
||||
const graphState = {
|
||||
critical_path: ['first', 'second'],
|
||||
items: {
|
||||
first: { ready: true, blocked: false, blocked_by: [] },
|
||||
second: { ready: false, blocked: true, blocked_by: ['first'] },
|
||||
},
|
||||
}
|
||||
const actions = [
|
||||
{ id: 'first', title: 'Erstes AP', status: 'done' },
|
||||
{ id: 'second', title: 'Zweites AP', status: 'open' },
|
||||
]
|
||||
const steps = buildCriticalPathSteps(graphState, actions)
|
||||
expect(steps).toHaveLength(2)
|
||||
expect(steps[1].reason).toContain('Erstes AP')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { getNextActionCandidates } from '../api/attention.js'
|
||||
import { NEXT_ACTION_KIND_LABELS } from '../constants/operating.js'
|
||||
import { NEXT_ACTION_KIND_LABELS, NEXT_ACTION_REASON_LABELS } from '../constants/operating.js'
|
||||
import { EmptyState } from '../components/EmptyState.jsx'
|
||||
import { ErrorState } from '../components/ErrorState.jsx'
|
||||
import { LoadingState } from '../components/LoadingState.jsx'
|
||||
|
|
@ -52,6 +52,14 @@ function NextActionList({ items, initiativeId, showInitiativeLink = true }) {
|
|||
<span className="badge status-badge status-active next-action-kind">
|
||||
{NEXT_ACTION_KIND_LABELS[item.kind] || item.kind}
|
||||
</span>
|
||||
{item.reason_code && NEXT_ACTION_REASON_LABELS[item.reason_code] && (
|
||||
<span
|
||||
className="badge status-badge status-progress next-action-reason"
|
||||
title={item.reason_code}
|
||||
>
|
||||
{NEXT_ACTION_REASON_LABELS[item.reason_code]}
|
||||
</span>
|
||||
)}
|
||||
<strong>{item.title}</strong>
|
||||
{item.summary && <p className="list-item-desc muted">{item.summary}</p>}
|
||||
{item.recommended_action && (
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user