Kairo-Jinkendo/frontend/src/pages/InitiativeDetailPage.jsx

719 lines
21 KiB
React
Raw Normal View History

import { useCallback, useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import {
getInitiative,
getInitiativeSteeringSnapshot,
listInitiativeActions,
createInitiativeAction,
} from '../api/initiatives.js'
import { updateAction, setActionAssignments } from '../api/actions.js'
import {
listInitiativeBlockers,
createInitiativeBlocker,
updateBlocker,
deleteBlocker,
} from '../api/blockers.js'
import {
listInitiativeBacklog,
createInitiativeBacklogItem,
updateBacklogItem,
deleteBacklogItem,
convertBacklogToAction,
} from '../api/backlog.js'
import {
listInitiativeMilestones,
createInitiativeMilestone,
updateMilestone,
deleteMilestone,
} from '../api/milestones.js'
import {
listInitiativeEvidence,
createInitiativeEvidence,
updateEvidence,
deleteEvidence,
} from '../api/evidence.js'
import {
listInitiativeDecisions,
createInitiativeDecision,
updateDecision,
deleteDecision,
} from '../api/decisions.js'
import {
listInitiativeReviews,
createInitiativeReview,
updateReview,
deleteReview,
} from '../api/reviews.js'
import {
listInitiativeRecurring,
createInitiativeRecurring,
updateRecurring,
deleteRecurring,
} from '../api/recurring.js'
import { ACTION_STATUSES, ACTION_STATUS_LABELS } from '../constants/status.js'
import { StatusBadge } from '../components/StatusBadge.jsx'
import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { ActionForm } from '../components/ActionForm.jsx'
import { BlockersSection } from '../components/BlockersSection.jsx'
import { BacklogSection } from '../components/BacklogSection.jsx'
import { MilestonesSection } from '../components/MilestonesSection.jsx'
import { EvidenceSection } from '../components/EvidenceSection.jsx'
import { DecisionsSection } from '../components/DecisionsSection.jsx'
import { ReviewsSection } from '../components/ReviewsSection.jsx'
import { RecurringSection } from '../components/RecurringSection.jsx'
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
import { useCapabilities } from '../hooks/useCapabilities.js'
import { useActors } from '../hooks/useActors.js'
import { useSession } from '../context/SessionContext.jsx'
export function InitiativeDetailPage() {
const { id } = useParams()
const { context, loading: sessionLoading } = useSession()
const { capabilities } = useCapabilities()
const {
actors,
loading: actorsLoading,
error: actorsError,
usedFallback: actorsUsedFallback,
reload: reloadActors,
} = useActors()
const [initiative, setInitiative] = useState(null)
const [actions, setActions] = useState([])
const [blockers, setBlockers] = useState([])
const [backlogItems, setBacklogItems] = useState([])
const [milestones, setMilestones] = useState([])
const [evidenceItems, setEvidenceItems] = useState([])
const [decisions, setDecisions] = useState([])
const [reviews, setReviews] = useState([])
const [recurringItems, setRecurringItems] = useState([])
const [steeringSnapshot, setSteeringSnapshot] = useState(null)
const [steeringSnapshotLoading, setSteeringSnapshotLoading] = useState(false)
const [steeringSnapshotError, setSteeringSnapshotError] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [showActionForm, setShowActionForm] = useState(false)
const [editingAction, setEditingAction] = useState(null)
const [formBusy, setFormBusy] = useState(false)
const [hideDone, setHideDone] = useState(true)
const load = useCallback(async () => {
if (sessionLoading) return
setLoading(true)
setError(null)
try {
const [initData, actionData] = await Promise.all([
getInitiative(id),
listInitiativeActions(id),
])
setInitiative(initData)
setActions(actionData)
const loads = []
const canReadOm =
capabilities.has('kairo.initiative.read') ||
capabilities.has('kairo.blocker.read')
if (canReadOm) {
loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([])))
loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([])))
loads.push(listInitiativeMilestones(id).then(setMilestones).catch(() => setMilestones([])))
loads.push(listInitiativeEvidence(id).then(setEvidenceItems).catch(() => setEvidenceItems([])))
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([])))
loads.push(
(async () => {
setSteeringSnapshotLoading(true)
setSteeringSnapshotError(null)
try {
setSteeringSnapshot(await getInitiativeSteeringSnapshot(id))
} catch (err) {
setSteeringSnapshot(null)
setSteeringSnapshotError(err.message || 'Snapshot fehlgeschlagen')
} finally {
setSteeringSnapshotLoading(false)
}
})()
)
}
await Promise.all(loads)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [id, capabilities, sessionLoading])
useEffect(() => {
load()
}, [load])
const visibleActions = hideDone
? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded')
: actions
const actionContextById = Object.fromEntries(
(steeringSnapshot?.actions || []).map((a) => [a.id, a])
)
async function handleCreateAction(payload) {
setFormBusy(true)
try {
const assigned =
payload.assigned_actor_ids?.length > 0
? payload.assigned_actor_ids
: context?.actor?.id
? [context.actor.id]
: []
await createInitiativeAction(id, { ...payload, assigned_actor_ids: assigned })
setShowActionForm(false)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleUpdateAction(actionId, payload) {
setFormBusy(true)
try {
await updateAction(actionId, {
title: payload.title,
description: payload.description,
status: payload.status,
priority: payload.priority,
due_at: payload.due_at,
clear_due_at: !payload.due_at,
})
if (capabilities.has('kairo.action.manage')) {
await setActionAssignments(actionId, payload.assigned_actor_ids || [])
}
setEditingAction(null)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleQuickStatus(action, status) {
if (!capabilities.has('kairo.action.manage')) return
try {
await updateAction(action.id, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateBlocker(body) {
setFormBusy(true)
try {
await createInitiativeBlocker(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleCreateBlockerForAction(actionId) {
if (!capabilities.has('kairo.blocker.manage')) return
setFormBusy(true)
try {
await createInitiativeBlocker(id, {
title: 'Blocker',
action_id: actionId,
set_action_blocked: true,
})
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleBlockerStatus(blockerId, status) {
try {
await updateBlocker(blockerId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteBlocker(blockerId) {
try {
await deleteBlocker(blockerId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateBacklog(body) {
setFormBusy(true)
try {
await createInitiativeBacklogItem(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleBacklogStatus(itemId, status) {
try {
await updateBacklogItem(itemId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleConvertBacklog(itemId) {
setFormBusy(true)
try {
await convertBacklogToAction(itemId)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleDeleteBacklog(itemId) {
try {
await deleteBacklogItem(itemId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateMilestone(body) {
setFormBusy(true)
try {
await createInitiativeMilestone(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleMilestoneStatus(milestoneId, status) {
try {
await updateMilestone(milestoneId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteMilestone(milestoneId) {
try {
await deleteMilestone(milestoneId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateEvidence(body) {
setFormBusy(true)
try {
await createInitiativeEvidence(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleEvidenceStatus(evidenceId, status) {
try {
await updateEvidence(evidenceId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteEvidence(evidenceId) {
try {
await deleteEvidence(evidenceId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateDecision(body) {
setFormBusy(true)
try {
await createInitiativeDecision(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleDecisionStatus(decisionId, status) {
try {
await updateDecision(decisionId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteDecision(decisionId) {
try {
await deleteDecision(decisionId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateReview(body) {
setFormBusy(true)
try {
await createInitiativeReview(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleReviewStatus(reviewId, status) {
try {
await updateReview(reviewId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteReview(reviewId) {
try {
await deleteReview(reviewId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateRecurring(body) {
setFormBusy(true)
try {
await createInitiativeRecurring(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleRecurringStatus(recurringId, status) {
try {
await updateRecurring(recurringId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteRecurring(recurringId) {
try {
await deleteRecurring(recurringId)
await load()
} catch (err) {
setError(err.message)
}
}
if (loading) {
return (
<div className="app-page">
<LoadingState message="Lade Vorhaben …" />
</div>
)
}
if (error && !initiative) {
return (
<div className="app-page">
<ErrorState message={error} onRetry={load} />
</div>
)
}
if (!initiative) {
return (
<div className="app-page">
<ErrorState message="Vorhaben nicht gefunden." />
</div>
)
}
return (
<div className="app-page">
<nav className="breadcrumb">
<Link to="/initiatives">Vorhaben</Link>
<span aria-hidden="true"> / </span>
<span>{initiative.title}</span>
</nav>
<header className="page-header detail-header">
<div>
<h1>{initiative.title}</h1>
{initiative.goal && <p className="detail-goal">{initiative.goal}</p>}
<div className="detail-badges">
<StatusBadge kind="initiative" status={initiative.status} />
<PriorityBadge priority={initiative.priority} />
</div>
</div>
</header>
{error && <p className="error">{error}</p>}
{capabilities.has('kairo.initiative.read') && (
<SteeringSnapshotPanel
snapshot={steeringSnapshot}
loading={steeringSnapshotLoading}
error={steeringSnapshotError}
/>
)}
<section className="card">
<div className="section-header">
<h2>Maßnahmen</h2>
<div className="section-actions">
<label className="checkbox-label inline-filter">
<input
type="checkbox"
checked={hideDone}
onChange={(e) => setHideDone(e.target.checked)}
/>
Erledigte ausblenden
</label>
{capabilities.has('kairo.action.manage') && (
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={() => { setShowActionForm((v) => !v); setEditingAction(null) }}
>
{showActionForm ? 'Abbrechen' : 'Maßnahme anlegen'}
</button>
)}
</div>
</div>
{showActionForm && (
<div className="inline-form-block">
<ActionForm
actors={actors}
actorsLoading={actorsLoading}
actorsError={actorsError}
actorsUsedFallback={actorsUsedFallback}
onReloadActors={reloadActors}
onSubmit={handleCreateAction}
onCancel={() => setShowActionForm(false)}
busy={formBusy}
submitLabel="Maßnahme anlegen"
/>
</div>
)}
{visibleActions.length === 0 && (
<EmptyState message="Noch keine Maßnahmen in diesem Vorhaben." />
)}
<ul className="item-list actions-detail-list">
{visibleActions.map((action) => {
const ctx = actionContextById[action.id]
return (
<li key={action.id} className="list-item card-list-item">
{editingAction === action.id ? (
<ActionForm
initial={action}
actors={actors}
actorsLoading={actorsLoading}
actorsError={actorsError}
actorsUsedFallback={actorsUsedFallback}
onReloadActors={reloadActors}
onSubmit={(payload) => handleUpdateAction(action.id, payload)}
onCancel={() => setEditingAction(null)}
busy={formBusy}
submitLabel="Speichern"
/>
) : (
<>
<div className="list-item-main">
<strong>{action.title}</strong>
{action.description && (
<p className="list-item-desc">{action.description}</p>
)}
{action.assigned_actor_ids?.length > 0 && (
<p className="muted list-item-sub">
Zugewiesen: {action.assigned_actor_ids.length} Actor(s)
</p>
)}
{action.due_at && (
<p className="muted list-item-sub">
Fällig: {new Date(action.due_at).toLocaleString('de-DE')}
</p>
)}
{ctx && (ctx.open_blocker_count > 0 || ctx.evidence?.length > 0 || ctx.reviews?.length > 0) && (
<p className="muted list-item-sub action-links">
{ctx.open_blocker_count > 0 && `${ctx.open_blocker_count} Blocker · `}
{ctx.evidence?.length > 0 && `${ctx.evidence.length} Evidence · `}
{ctx.reviews?.length > 0 && `${ctx.reviews.length} Review(s)`}
</p>
)}
</div>
<div className="list-item-meta action-controls">
<StatusBadge status={action.status} />
<PriorityBadge priority={action.priority} />
{capabilities.has('kairo.action.manage') && (
<>
<select
className="inline-select"
value={action.status}
onChange={(e) => handleQuickStatus(action, e.target.value)}
aria-label="Status ändern"
>
{ACTION_STATUSES.map((s) => (
<option key={s} value={s}>
{ACTION_STATUS_LABELS[s]}
</option>
))}
</select>
{capabilities.has('kairo.blocker.manage') && action.status !== 'done' && (
<button
type="button"
className="btn btn-secondary"
onClick={() => handleCreateBlockerForAction(action.id)}
disabled={formBusy}
>
Blocker
</button>
)}
<button
type="button"
className="btn btn-secondary"
onClick={() => {
setEditingAction(action.id)
setShowActionForm(false)
}}
>
Bearbeiten
</button>
</>
)}
</div>
</>
)}
</li>
)
})}
</ul>
</section>
{capabilities.has('kairo.initiative.read') && (
<MilestonesSection
milestones={milestones}
canManage={capabilities.has('kairo.milestone.manage')}
onCreate={handleCreateMilestone}
onUpdateStatus={handleMilestoneStatus}
onDelete={handleDeleteMilestone}
busy={formBusy}
/>
)}
{capabilities.has('kairo.initiative.read') && (
<BacklogSection
items={backlogItems}
canManage={capabilities.has('kairo.backlog.manage')}
onCreate={handleCreateBacklog}
onUpdateStatus={handleBacklogStatus}
onConvert={handleConvertBacklog}
onDelete={handleDeleteBacklog}
busy={formBusy}
/>
)}
{capabilities.has('kairo.initiative.read') && (
<BlockersSection
blockers={blockers}
canManage={capabilities.has('kairo.blocker.manage')}
onCreate={handleCreateBlocker}
onUpdateStatus={handleBlockerStatus}
onDelete={handleDeleteBlocker}
busy={formBusy}
/>
)}
{capabilities.has('kairo.initiative.read') && (
<EvidenceSection
items={evidenceItems}
canManage={capabilities.has('kairo.evidence.manage')}
onCreate={handleCreateEvidence}
onUpdateStatus={handleEvidenceStatus}
onDelete={handleDeleteEvidence}
busy={formBusy}
/>
)}
{capabilities.has('kairo.initiative.read') && (
<DecisionsSection
items={decisions}
canManage={capabilities.has('kairo.decision.manage')}
onCreate={handleCreateDecision}
onUpdateStatus={handleDecisionStatus}
onDelete={handleDeleteDecision}
busy={formBusy}
/>
)}
{capabilities.has('kairo.initiative.read') && (
<ReviewsSection
items={reviews}
canManage={capabilities.has('kairo.review.manage')}
onCreate={handleCreateReview}
onUpdateStatus={handleReviewStatus}
onDelete={handleDeleteReview}
busy={formBusy}
/>
)}
{capabilities.has('kairo.initiative.read') && (
<RecurringSection
items={recurringItems}
canManage={capabilities.has('kairo.recurring.manage')}
onCreate={handleCreateRecurring}
onUpdateStatus={handleRecurringStatus}
onDelete={handleDeleteRecurring}
busy={formBusy}
/>
)}
</div>
)
}