feat(P3): IHF Phase 3 complete — Governance and Decision Linkage

Implements the full governance layer:
- Schema: requirements, decision_records, policy_references,
  implementation_change_references; requirement_candidates gets
  requirement_id back-reference
- RequirementsController (index/show; promotion-only create)
- DecisionRecordsController (CRUD + policy/impl ref management)
- GovernanceDashboardAction on HubsController (AutoRefresh)
- PromoteToRequirementAction + LinkToDecisionAction on candidates
- Outcome immutability enforced at controller level (fill excludes outcome)
- Full six-outcome vocabulary with Tailwind color roles
- Integration tests for all Phase 3 paths
- FrontController: registers Phase 2 missing controllers + all Phase 3
- SCOPE.md + docs/phase3-summary.md updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 10:38:50 +00:00
parent 840b0e5c7b
commit 7f9a8dd441
23 changed files with 2039 additions and 19 deletions

View File

@@ -0,0 +1,167 @@
module Web.Controller.DecisionRecords where
import Web.Types
import Web.View.DecisionRecords.Index
import Web.View.DecisionRecords.Show
import Web.View.DecisionRecords.New
import Web.View.DecisionRecords.Edit
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
validOutcomes :: [Text]
validOutcomes = ["accepted", "rejected", "deferred", "split", "merged", "reframed"]
validPolicyScopes :: [Text]
validPolicyScopes = ["internal", "external", "regulatory", "contractual", "architectural"]
validSystems :: [Text]
validSystems = ["github", "linear", "jira", "other"]
instance Controller DecisionRecordsController where
beforeAction = ensureIsUser
action DecisionRecordsAction = do
mOutcomeFilter <- paramOrNothing @Text "outcome"
records <- case mOutcomeFilter of
Nothing -> query @DecisionRecord |> orderByDesc #decidedAt |> fetch
Just o -> query @DecisionRecord
|> filterWhere (#outcome, o)
|> orderByDesc #decidedAt
|> fetch
requirements <- query @Requirement |> fetch
users <- query @User |> fetch
render IndexView { records, requirements, users, mOutcomeFilter }
action ShowDecisionRecordAction { decisionRecordId } = do
record <- fetch decisionRecordId
policyRefs <- query @PolicyReference
|> filterWhere (#decisionId, decisionRecordId)
|> orderByAsc #createdAt
|> fetch
implRefs <- query @ImplementationChangeReference
|> filterWhere (#decisionId, decisionRecordId)
|> orderByAsc #linkedAt
|> fetch
mRequirement <- case record.requirementId of
Nothing -> pure Nothing
Just rid -> fetchOneOrNothing rid
mCandidate <- case record.candidateId of
Nothing -> pure Nothing
Just cid -> fetchOneOrNothing cid
users <- query @User |> fetch
render ShowView
{ record
, policyRefs
, implRefs
, mRequirement
, mCandidate
, users
}
action NewDecisionRecordAction = do
requirements <- query @Requirement |> fetch
candidates <- query @RequirementCandidate |> fetch
users <- query @User |> fetch
let record = newRecord @DecisionRecord
render NewView { record, requirements, candidates, users }
action CreateDecisionRecordAction = do
requirements <- query @Requirement |> fetch
candidates <- query @RequirementCandidate |> fetch
users <- query @User |> fetch
mUser <- currentUserOrNothing
let decidedBy = fmap (.id) mUser
let record = newRecord @DecisionRecord
record
|> fill @'["title", "rationale", "outcome", "requirementId", "candidateId", "notes"]
|> set #decidedBy (fmap (Id . unId) decidedBy)
|> validateField #title nonEmpty
|> validateField #rationale nonEmpty
|> validateField #outcome (`elem` validOutcomes)
|> ifValid \case
Left record -> render NewView { record, requirements, candidates, users }
Right record -> do
created <- createRecord record
setSuccessMessage "Decision record created"
redirectTo ShowDecisionRecordAction { decisionRecordId = created.id }
action EditDecisionRecordAction { decisionRecordId } = do
record <- fetch decisionRecordId
requirements <- query @Requirement |> fetch
candidates <- query @RequirementCandidate |> fetch
users <- query @User |> fetch
render EditView { record, requirements, candidates, users }
action UpdateDecisionRecordAction { decisionRecordId } = do
record <- fetch decisionRecordId
requirements <- query @Requirement |> fetch
candidates <- query @RequirementCandidate |> fetch
users <- query @User |> fetch
-- Outcome is immutable: only update non-outcome fields
record
|> fill @'["title", "rationale", "requirementId", "candidateId", "notes"]
|> validateField #title nonEmpty
|> validateField #rationale nonEmpty
|> ifValid \case
Left record -> render EditView { record, requirements, candidates, users }
Right record -> do
updateRecord record
setSuccessMessage "Decision record updated"
redirectTo ShowDecisionRecordAction { decisionRecordId }
action AddPolicyReferenceAction { decisionRecordId } = do
mUser <- currentUserOrNothing
let createdBy = fmap (.id) mUser
policyScope <- param @Text "policyScope"
constraintNote <- paramOrNothing @Text "constraintNote"
unless (policyScope `elem` validPolicyScopes) do
setErrorMessage ("Invalid policy scope: " <> policyScope)
respondWith 422 do
redirectTo ShowDecisionRecordAction { decisionRecordId }
newRecord @PolicyReference
|> set #decisionId decisionRecordId
|> set #policyScope policyScope
|> set #constraintNote constraintNote
|> set #createdBy (fmap (Id . unId) createdBy)
|> createRecord
setSuccessMessage "Policy reference added"
redirectTo ShowDecisionRecordAction { decisionRecordId }
action DeletePolicyReferenceAction { policyReferenceId } = do
ref <- fetch policyReferenceId
let decisionRecordId = ref.decisionId
deleteRecord ref
setSuccessMessage "Policy reference removed"
redirectTo ShowDecisionRecordAction { decisionRecordId }
action AddImplementationRefAction { decisionRecordId } = do
mUser <- currentUserOrNothing
let linkedBy = fmap (.id) mUser
workItemRef <- param @Text "workItemRef"
system <- param @Text "system"
unless (system `elem` validSystems) do
setErrorMessage ("Invalid system: " <> system)
respondWith 422 do
redirectTo ShowDecisionRecordAction { decisionRecordId }
when (workItemRef == "") do
setErrorMessage "Work item reference cannot be empty"
respondWith 422 do
redirectTo ShowDecisionRecordAction { decisionRecordId }
newRecord @ImplementationChangeReference
|> set #decisionId decisionRecordId
|> set #workItemRef workItemRef
|> set #system system
|> set #linkedBy (fmap (Id . unId) linkedBy)
|> createRecord
setSuccessMessage "Implementation reference added"
redirectTo ShowDecisionRecordAction { decisionRecordId }
action DeleteImplementationRefAction { implementationChangeReferenceId } = do
ref <- fetch implementationChangeReferenceId
let decisionRecordId = ref.decisionId
deleteRecord ref
setSuccessMessage "Implementation reference removed"
redirectTo ShowDecisionRecordAction { decisionRecordId }

View File

@@ -6,6 +6,7 @@ import Web.View.Hubs.Show
import Web.View.Hubs.New
import Web.View.Hubs.Edit
import Web.View.Hubs.TriageDashboard
import Web.View.Hubs.GovernanceDashboard
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
@@ -110,3 +111,48 @@ instance Controller HubsController where
, recentEscalations
, allAnnotations
}
action GovernanceDashboardAction { hubId } = autoRefresh do
hub <- fetch hubId
widgets <- query @Widget
|> filterWhere (#hubId, hubId)
|> fetch
let widgetIds = map (.id) widgets
-- All requirements whose source candidate is in this hub's widgets
allCandidates <- query @RequirementCandidate
|> filterWhereIn (#sourceWidgetId, widgetIds)
|> fetch
let acceptedCandidateIds = map (.id) (filter (\c -> c.status == "accepted") allCandidates)
allRequirements <- query @Requirement
|> filterWhereIn (#sourceCandidateId, acceptedCandidateIds)
|> fetch
-- Recent decisions (last 20) — scoped to this hub's requirements
let requirementIds = map (.id) allRequirements
recentDecisions <- query @DecisionRecord
|> filterWhereIn (#requirementId, map Just requirementIds)
|> orderByDesc #decidedAt
|> limit 20
|> fetch
-- All hub decisions (for outcome counts)
allDecisions <- query @DecisionRecord
|> filterWhereIn (#requirementId, map Just requirementIds)
|> fetch
-- All annotations for traceability coverage
allAnnotations <- query @Annotation
|> filterWhereIn (#widgetId, widgetIds)
|> fetch
render GovernanceDashboardView
{ hub
, widgets
, allCandidates
, allRequirements
, recentDecisions
, allDecisions
, allAnnotations
}

View File

@@ -178,3 +178,58 @@ instance Controller RequirementCandidatesController where
, widgets
, mStatusFilter = Just "my_queue"
}
action PromoteToRequirementAction { requirementCandidateId } = do
candidate <- fetch requirementCandidateId
-- Guard: only accepted candidates may be promoted
when (candidate.status /= "accepted") do
setErrorMessage "Only accepted candidates can be promoted to a requirement"
respondWith 422 do
redirectTo ShowRequirementCandidateAction { requirementCandidateId }
-- Idempotent: if already promoted, redirect to existing requirement
case candidate.requirementId of
Just rid -> redirectTo ShowRequirementAction { requirementId = rid }
Nothing -> do
mUser <- currentUserOrNothing
let createdBy = fmap (.id) mUser
req <- newRecord @Requirement
|> set #title candidate.title
|> set #description candidate.description
|> set #sourceCandidateId requirementCandidateId
|> set #status "active"
|> set #createdBy (fmap (Id . unId) createdBy)
|> createRecord
candidate
|> set #requirementId (Just req.id)
|> updateRecord
setSuccessMessage "Promoted to requirement"
redirectTo ShowRequirementAction { requirementId = req.id }
action LinkToDecisionAction { requirementCandidateId } = do
candidate <- fetch requirementCandidateId
-- Guard: only accepted candidates
when (candidate.status /= "accepted") do
setErrorMessage "Only accepted candidates can be linked to a decision"
respondWith 422 do
redirectTo ShowRequirementCandidateAction { requirementCandidateId }
-- Idempotent: check if a decision already links to this candidate
existing <- query @DecisionRecord
|> filterWhere (#candidateId, Just requirementCandidateId)
|> fetchOneOrNothing
case existing of
Just dr -> redirectTo ShowDecisionRecordAction { decisionRecordId = dr.id }
Nothing -> do
mUser <- currentUserOrNothing
let decidedBy = fmap (.id) mUser
-- Use promoted requirement id if available
let mReqId = candidate.requirementId
dr <- newRecord @DecisionRecord
|> set #title candidate.title
|> set #rationale candidate.description
|> set #outcome "accepted"
|> set #candidateId (Just requirementCandidateId)
|> set #requirementId mReqId
|> set #decidedBy (fmap (Id . unId) decidedBy)
|> createRecord
setSuccessMessage "Decision record created"
redirectTo ShowDecisionRecordAction { decisionRecordId = dr.id }

View File

@@ -0,0 +1,25 @@
module Web.Controller.Requirements where
import Web.Types
import Web.View.Requirements.Index
import Web.View.Requirements.Show
import Generated.Types
import IHP.Prelude
import IHP.ControllerPrelude
instance Controller RequirementsController where
beforeAction = ensureIsUser
action RequirementsAction = do
requirements <- query @Requirement |> orderByDesc #createdAt |> fetch
candidates <- query @RequirementCandidate |> fetch
render IndexView { requirements, candidates }
action ShowRequirementAction { requirementId } = do
requirement <- fetch requirementId
candidate <- fetch requirement.sourceCandidateId
widget <- fetch candidate.sourceWidgetId
mDecision <- query @DecisionRecord
|> filterWhere (#requirementId, Just requirementId)
|> fetchOneOrNothing
render ShowView { requirement, candidate, widget, mDecision }

View File

@@ -11,6 +11,10 @@ import Web.Controller.Hubs ()
import Web.Controller.Widgets ()
import Web.Controller.InteractionEvents ()
import Web.Controller.Annotations ()
import Web.Controller.AnnotationThreads ()
import Web.Controller.RequirementCandidates ()
import Web.Controller.Requirements ()
import Web.Controller.DecisionRecords ()
import Web.Controller.Sessions ()
instance FrontController WebApplication where
@@ -20,6 +24,10 @@ instance FrontController WebApplication where
, parseRoute @WidgetsController
, parseRoute @InteractionEventsController
, parseRoute @AnnotationsController
, parseRoute @AnnotationThreadsController
, parseRoute @RequirementCandidatesController
, parseRoute @RequirementsController
, parseRoute @DecisionRecordsController
]
instance InitControllerContext WebApplication where
@@ -45,6 +53,9 @@ defaultLayout inner = [hsx|
<a href={HubsAction} class="font-semibold text-indigo-600">inter-hub</a>
<a href={HubsAction} class="text-sm text-gray-600 hover:text-gray-900">Hubs</a>
<a href={WidgetsAction} class="text-sm text-gray-600 hover:text-gray-900">Widgets</a>
<a href={RequirementCandidatesAction} class="text-sm text-gray-600 hover:text-gray-900">Candidates</a>
<a href={RequirementsAction} class="text-sm text-gray-600 hover:text-gray-900">Requirements</a>
<a href={DecisionRecordsAction} class="text-sm text-gray-600 hover:text-gray-900">Decisions</a>
<div class="ml-auto">
<a href={DeleteSessionAction} class="text-sm text-gray-500 hover:text-gray-700">Sign out</a>
</div>

View File

@@ -22,5 +22,11 @@ instance AutoRoute AnnotationThreadsController
-- Requirement Candidates
instance AutoRoute RequirementCandidatesController
-- Requirements (Phase 3)
instance AutoRoute RequirementsController
-- Decision Records (Phase 3)
instance AutoRoute DecisionRecordsController
-- Sessions
instance AutoRoute SessionsController

View File

@@ -18,12 +18,13 @@ data WebApplication = WebApplication deriving (Eq, Show)
data HubsController
= HubsAction
| NewHubAction
| ShowHubAction { hubId :: !(Id Hub) }
| ShowHubAction { hubId :: !(Id Hub) }
| CreateHubAction
| EditHubAction { hubId :: !(Id Hub) }
| UpdateHubAction { hubId :: !(Id Hub) }
| DeleteHubAction { hubId :: !(Id Hub) }
| TriageDashboardAction { hubId :: !(Id Hub) }
| EditHubAction { hubId :: !(Id Hub) }
| UpdateHubAction { hubId :: !(Id Hub) }
| DeleteHubAction { hubId :: !(Id Hub) }
| TriageDashboardAction { hubId :: !(Id Hub) }
| GovernanceDashboardAction { hubId :: !(Id Hub) }
deriving (Eq, Show, Data)
data WidgetsController
@@ -65,6 +66,26 @@ data RequirementCandidatesController
| UpdateTriageStatusAction { requirementCandidateId :: !(Id RequirementCandidate) }
| AssignReviewerAction { requirementCandidateId :: !(Id RequirementCandidate) }
| MyQueueAction
| PromoteToRequirementAction { requirementCandidateId :: !(Id RequirementCandidate) }
| LinkToDecisionAction { requirementCandidateId :: !(Id RequirementCandidate) }
deriving (Eq, Show, Data)
data RequirementsController
= RequirementsAction
| ShowRequirementAction { requirementId :: !(Id Requirement) }
deriving (Eq, Show, Data)
data DecisionRecordsController
= DecisionRecordsAction
| ShowDecisionRecordAction { decisionRecordId :: !(Id DecisionRecord) }
| NewDecisionRecordAction
| CreateDecisionRecordAction
| EditDecisionRecordAction { decisionRecordId :: !(Id DecisionRecord) }
| UpdateDecisionRecordAction { decisionRecordId :: !(Id DecisionRecord) }
| AddPolicyReferenceAction { decisionRecordId :: !(Id DecisionRecord) }
| DeletePolicyReferenceAction { policyReferenceId :: !(Id PolicyReference) }
| AddImplementationRefAction { decisionRecordId :: !(Id DecisionRecord) }
| DeleteImplementationRefAction { implementationChangeReferenceId :: !(Id ImplementationChangeReference) }
deriving (Eq, Show, Data)
data SessionsController

View File

@@ -0,0 +1,34 @@
module Web.View.DecisionRecords.Edit where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
import Web.View.DecisionRecords.New (renderForm)
data EditView = EditView
{ record :: !DecisionRecord
, requirements :: ![Requirement]
, candidates :: ![RequirementCandidate]
, users :: ![User]
}
instance View EditView where
html EditView { .. } = [hsx|
<div class="mb-6 flex items-center gap-2 text-sm text-gray-500">
<a href={DecisionRecordsAction} class="hover:text-gray-700">Decisions</a>
<span>/</span>
<a href={ShowDecisionRecordAction { decisionRecordId = record.id }}
class="hover:text-gray-700">{record.title}</a>
<span>/</span>
<span>Edit</span>
</div>
<div class="max-w-2xl">
<h1 class="text-2xl font-semibold mb-2">Edit Decision Record</h1>
<p class="text-sm text-amber-600 mb-6">
Note: outcome is immutable and cannot be changed here.
</p>
{renderForm record requirements candidates users (UpdateDecisionRecordAction { decisionRecordId = record.id })}
</div>
|]

View File

@@ -0,0 +1,108 @@
module Web.View.DecisionRecords.Index where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data IndexView = IndexView
{ records :: ![DecisionRecord]
, requirements :: ![Requirement]
, users :: ![User]
, mOutcomeFilter :: !(Maybe Text)
}
allOutcomes :: [Text]
allOutcomes = ["accepted", "rejected", "deferred", "split", "merged", "reframed"]
instance View IndexView where
html IndexView { .. } = [hsx|
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Decision Records</h1>
<a href={NewDecisionRecordAction}
class="bg-indigo-600 text-white text-sm px-4 py-2 rounded hover:bg-indigo-700">
New Decision
</a>
</div>
<!-- Outcome filter tabs -->
<div class="flex gap-2 mb-5 text-sm flex-wrap">
<a href={DecisionRecordsAction}
class={filterTabClass Nothing mOutcomeFilter}>All</a>
{forEach allOutcomes (\o -> [hsx|
<a href={decisionFilterUrl o}
class={filterTabClass (Just o) mOutcomeFilter}>{o}</a>
|])}
</div>
{if null records
then [hsx|<p class="text-sm text-gray-400">No decision records found.</p>|]
else renderTable records requirements users}
|]
decisionFilterUrl :: Text -> Text
decisionFilterUrl o = "/DecisionRecords?outcome=" <> o
renderTable :: [DecisionRecord] -> [Requirement] -> [User] -> Html
renderTable records reqs users = [hsx|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-600">Title</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Outcome</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Requirement</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Decided By</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Decided At</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach records (renderRow reqs users)}
</tbody>
</table>
</div>
|]
renderRow :: [Requirement] -> [User] -> DecisionRecord -> Html
renderRow reqs users dr = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<a href={ShowDecisionRecordAction { decisionRecordId = dr.id }}
class="text-indigo-600 hover:text-indigo-800 font-medium">{dr.title}</a>
</td>
<td class="px-4 py-3">
<span class={outcomeClass dr.outcome <> " text-xs px-2 py-0.5 rounded font-medium"}>
{dr.outcome}
</span>
</td>
<td class="px-4 py-3 text-gray-600">
{linkedReqTitle reqs dr.requirementId}
</td>
<td class="px-4 py-3 text-gray-600">
{userName users dr.decidedBy}
</td>
<td class="px-4 py-3 text-gray-400 text-xs">{show dr.decidedAt}</td>
</tr>
|]
linkedReqTitle :: [Requirement] -> Maybe (Id Requirement) -> Text
linkedReqTitle _ Nothing = ""
linkedReqTitle reqs (Just rid) = maybe "(unknown)" (.title) (find (\r -> r.id == rid) reqs)
userName :: [User] -> Maybe (Id User) -> Text
userName _ Nothing = ""
userName users (Just uid) = maybe "(unknown)" (.name) (find (\u -> u.id == uid) users)
outcomeClass :: Text -> Text
outcomeClass "accepted" = "bg-green-100 text-green-800"
outcomeClass "rejected" = "bg-red-100 text-red-800"
outcomeClass "deferred" = "bg-gray-100 text-gray-600"
outcomeClass "split" = "bg-purple-100 text-purple-800"
outcomeClass "merged" = "bg-indigo-100 text-indigo-800"
outcomeClass "reframed" = "bg-orange-100 text-orange-800"
outcomeClass _ = "bg-gray-100 text-gray-600"
filterTabClass :: Maybe Text -> Maybe Text -> Text
filterTabClass a b
| a == b = "px-3 py-1.5 rounded bg-indigo-100 text-indigo-700 font-medium"
| otherwise = "px-3 py-1.5 rounded text-gray-600 hover:bg-gray-100"

View File

@@ -0,0 +1,97 @@
module Web.View.DecisionRecords.New where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data NewView = NewView
{ record :: !DecisionRecord
, requirements :: ![Requirement]
, candidates :: ![RequirementCandidate]
, users :: ![User]
}
instance View NewView where
html NewView { .. } = [hsx|
<div class="mb-6 flex items-center gap-2 text-sm text-gray-500">
<a href={DecisionRecordsAction} class="hover:text-gray-700">Decisions</a>
<span>/</span>
<span>New</span>
</div>
<div class="max-w-2xl">
<h1 class="text-2xl font-semibold mb-6">New Decision Record</h1>
{renderForm record requirements candidates users CreateDecisionRecordAction}
</div>
|]
renderForm :: DecisionRecord -> [Requirement] -> [RequirementCandidate] -> [User] -> action -> Html
renderForm record requirements candidates users submitAction = [hsx|
<form method="POST" action={submitAction} class="bg-white rounded-lg border border-gray-200 px-6 py-5 space-y-4">
{hiddenField "authenticity_token"}
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<input type="text" name="title" value={record.title}
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
required />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Outcome</label>
<select name="outcome"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value="accepted">accepted</option>
<option value="rejected">rejected</option>
<option value="deferred">deferred</option>
<option value="split">split</option>
<option value="merged">merged</option>
<option value="reframed">reframed</option>
</select>
<p class="text-xs text-gray-400 mt-1">Outcome cannot be changed after creation.</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Rationale</label>
<textarea name="rationale" rows="4"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
required>{record.rationale}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Linked Requirement (optional)</label>
<select name="requirementId"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value=""> None </option>
{forEach requirements (\r -> [hsx|<option value={show r.id}>{r.title}</option>|])}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Linked Candidate (optional)</label>
<select name="candidateId"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500">
<option value=""> None </option>
{forEach candidates (\c -> [hsx|<option value={show c.id}>{c.title}</option>|])}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Notes (optional)</label>
<textarea name="notes" rows="2"
class="w-full border border-gray-300 rounded px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
placeholder="For split/merged: list related candidate IDs or context"
>{maybe "" id record.notes}</textarea>
</div>
<div class="flex gap-3 pt-2">
<button type="submit"
class="bg-indigo-600 text-white text-sm px-4 py-2 rounded hover:bg-indigo-700">
Create Decision
</button>
<a href={DecisionRecordsAction}
class="text-sm text-gray-500 px-4 py-2 rounded hover:bg-gray-100">Cancel</a>
</div>
</form>
|]

View File

@@ -0,0 +1,206 @@
module Web.View.DecisionRecords.Show where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data ShowView = ShowView
{ record :: !DecisionRecord
, policyRefs :: ![PolicyReference]
, implRefs :: ![ImplementationChangeReference]
, mRequirement :: !(Maybe Requirement)
, mCandidate :: !(Maybe RequirementCandidate)
, users :: ![User]
}
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="mb-6 flex items-center gap-2 text-sm text-gray-500">
<a href={DecisionRecordsAction} class="hover:text-gray-700">Decisions</a>
<span>/</span>
<span>{record.title}</span>
</div>
<div class="max-w-3xl space-y-6">
<!-- Header card -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-5">
<div class="flex items-start justify-between mb-3">
<h1 class="text-2xl font-semibold">{record.title}</h1>
<div class="flex gap-2 ml-4">
<span class={outcomeClass record.outcome <> " text-xs px-2 py-0.5 rounded font-medium"}>
{record.outcome}
</span>
<a href={EditDecisionRecordAction { decisionRecordId = record.id }}
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
Edit
</a>
</div>
</div>
<div class="text-xs text-gray-400 mb-3">
Decided by: {userName users record.decidedBy} · {show record.decidedAt}
</div>
<div class="mb-3">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Rationale</p>
<p class="text-sm text-gray-700 leading-relaxed">{record.rationale}</p>
</div>
{maybe mempty renderNotes record.notes}
</div>
<!-- Linked requirement -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-2">Linked Requirement</h2>
{case mRequirement of
Nothing -> [hsx|<p class="text-sm text-gray-400">No requirement linked.</p>|]
Just req -> [hsx|
<a href={ShowRequirementAction { requirementId = req.id }}
class="text-sm text-indigo-600 hover:text-indigo-800">{req.title}</a>
|]}
</div>
<!-- Source candidate -->
{maybe mempty renderCandidateSection mCandidate}
<!-- Policy references -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Policy References</h2>
{forEach policyRefs renderPolicyRef}
<form method="POST" action={AddPolicyReferenceAction { decisionRecordId = record.id }}
class="mt-3 flex items-end gap-2">
{hiddenField "authenticity_token"}
<div>
<label class="text-xs text-gray-500 block mb-1">Scope</label>
<select name="policyScope"
class="text-sm border border-gray-300 rounded px-2 py-1.5">
<option value="internal">internal</option>
<option value="external">external</option>
<option value="regulatory">regulatory</option>
<option value="contractual">contractual</option>
<option value="architectural">architectural</option>
</select>
</div>
<div class="flex-1">
<label class="text-xs text-gray-500 block mb-1">Constraint note (optional)</label>
<input type="text" name="constraintNote"
class="w-full text-sm border border-gray-300 rounded px-2 py-1.5"
placeholder="e.g. GDPR Art. 17 right-to-erasure" />
</div>
<button type="submit"
class="text-sm bg-gray-100 border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-200">
Add
</button>
</form>
</div>
<!-- Implementation references -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Implementation References</h2>
{forEach implRefs renderImplRef}
<form method="POST" action={AddImplementationRefAction { decisionRecordId = record.id }}
class="mt-3 flex items-end gap-2">
{hiddenField "authenticity_token"}
<div>
<label class="text-xs text-gray-500 block mb-1">System</label>
<select name="system"
class="text-sm border border-gray-300 rounded px-2 py-1.5">
<option value="github">github</option>
<option value="linear">linear</option>
<option value="jira">jira</option>
<option value="other">other</option>
</select>
</div>
<div class="flex-1">
<label class="text-xs text-gray-500 block mb-1">Work item ref</label>
<input type="text" name="workItemRef"
class="w-full text-sm border border-gray-300 rounded px-2 py-1.5"
placeholder="e.g. #1234, PROJ-456" />
</div>
<button type="submit"
class="text-sm bg-gray-100 border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-200">
Add
</button>
</form>
</div>
</div>
|]
renderNotes :: Text -> Html
renderNotes notes = [hsx|
<div class="mt-2">
<p class="text-xs font-semibold text-gray-500 uppercase tracking-wide mb-1">Notes</p>
<p class="text-sm text-gray-600 italic">{notes}</p>
</div>
|]
renderCandidateSection :: RequirementCandidate -> Html
renderCandidateSection c = [hsx|
<div class="bg-white rounded-lg border border-gray-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-2">Source Candidate</h2>
<a href={ShowRequirementCandidateAction { requirementCandidateId = c.id }}
class="text-sm text-indigo-600 hover:text-indigo-800">{c.title}</a>
</div>
|]
renderPolicyRef :: PolicyReference -> Html
renderPolicyRef ref = [hsx|
<div class="flex items-start justify-between py-2 border-b border-gray-100 last:border-0">
<div class="flex items-center gap-2 text-sm">
<span class={policyScopeClass ref.policyScope <> " text-xs px-2 py-0.5 rounded font-medium"}>
{ref.policyScope}
</span>
{maybe mempty (\n -> [hsx|<span class="text-gray-600">{n}</span>|]) ref.constraintNote}
<span class="text-xs text-gray-400">{show ref.createdAt}</span>
</div>
<form method="POST"
action={DeletePolicyReferenceAction { policyReferenceId = ref.id }}>
{hiddenField "authenticity_token"}
<button type="submit"
class="text-xs text-red-500 hover:text-red-700 ml-2">Remove</button>
</form>
</div>
|]
renderImplRef :: ImplementationChangeReference -> Html
renderImplRef ref = [hsx|
<div class="flex items-start justify-between py-2 border-b border-gray-100 last:border-0">
<div class="flex items-center gap-2 text-sm">
<span class={systemBadgeClass ref.system <> " text-xs px-2 py-0.5 rounded font-medium"}>
{ref.system}
</span>
<span class="font-mono text-gray-700">{ref.workItemRef}</span>
<span class="text-xs text-gray-400">{show ref.linkedAt}</span>
</div>
<form method="POST"
action={DeleteImplementationRefAction { implementationChangeReferenceId = ref.id }}>
{hiddenField "authenticity_token"}
<button type="submit"
class="text-xs text-red-500 hover:text-red-700 ml-2">Remove</button>
</form>
</div>
|]
outcomeClass :: Text -> Text
outcomeClass "accepted" = "bg-green-100 text-green-800"
outcomeClass "rejected" = "bg-red-100 text-red-800"
outcomeClass "deferred" = "bg-gray-100 text-gray-600"
outcomeClass "split" = "bg-purple-100 text-purple-800"
outcomeClass "merged" = "bg-indigo-100 text-indigo-800"
outcomeClass "reframed" = "bg-orange-100 text-orange-800"
outcomeClass _ = "bg-gray-100 text-gray-600"
policyScopeClass :: Text -> Text
policyScopeClass "regulatory" = "bg-red-50 text-red-700 border border-red-200"
policyScopeClass "contractual" = "bg-orange-50 text-orange-700 border border-orange-200"
policyScopeClass "external" = "bg-yellow-50 text-yellow-700 border border-yellow-200"
policyScopeClass "architectural"= "bg-blue-50 text-blue-700 border border-blue-200"
policyScopeClass _ = "bg-gray-50 text-gray-600 border border-gray-200"
systemBadgeClass :: Text -> Text
systemBadgeClass "github" = "bg-gray-800 text-white"
systemBadgeClass "linear" = "bg-violet-100 text-violet-800"
systemBadgeClass "jira" = "bg-blue-100 text-blue-800"
systemBadgeClass _ = "bg-gray-100 text-gray-600"
userName :: [User] -> Maybe (Id User) -> Text
userName _ Nothing = ""
userName users (Just uid) = maybe "(unknown)" (.name) (find (\u -> u.id == uid) users)

View File

@@ -0,0 +1,206 @@
module Web.View.Hubs.GovernanceDashboard where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data GovernanceDashboardView = GovernanceDashboardView
{ hub :: !Hub
, widgets :: ![Widget]
, allCandidates :: ![RequirementCandidate]
, allRequirements :: ![Requirement]
, recentDecisions :: ![DecisionRecord]
, allDecisions :: ![DecisionRecord]
, allAnnotations :: ![Annotation]
}
instance View GovernanceDashboardView where
html GovernanceDashboardView { .. } = [hsx|
<div class="mb-6 flex items-center justify-between">
<div>
<div class="flex items-center gap-2 text-sm text-gray-500 mb-1">
<a href={HubsAction} class="hover:text-gray-700">Hubs</a>
<span>/</span>
<a href={ShowHubAction { hubId = hub.id }} class="hover:text-gray-700">{hub.name}</a>
<span>/</span>
<span>Governance</span>
</div>
<h1 class="text-2xl font-semibold">Governance Dashboard {hub.name}</h1>
</div>
<div class="flex gap-2">
<a href={TriageDashboardAction { hubId = hub.id }}
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
Triage Dashboard
</a>
<a href={ShowHubAction { hubId = hub.id }}
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
Hub Overview
</a>
</div>
</div>
<!-- KPI row: decision outcomes -->
<div class="grid grid-cols-3 gap-4 mb-6 sm:grid-cols-6">
{forEach outcomeList (\o -> renderKpiCard o (countOutcome allDecisions o))}
</div>
<!-- Open requirements awaiting decision -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-5 mb-6">
<h2 class="text-sm font-semibold text-gray-700 mb-3">
Open Requirements Awaiting Decision
<span class="ml-2 text-xs font-normal text-gray-400">
({show (length awaitingDecision)} pending)
</span>
</h2>
{if null awaitingDecision
then [hsx|<p class="text-sm text-gray-400">All requirements have linked decisions.</p>|]
else forEach awaitingDecision renderAwaitingReq}
</div>
<!-- Recent decisions -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-5 mb-6">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Recent Decisions</h2>
{if null recentDecisions
then [hsx|<p class="text-sm text-gray-400">No decisions recorded yet.</p>|]
else [hsx|
<table class="w-full text-sm">
<thead class="border-b border-gray-100">
<tr>
<th class="text-left py-2 text-xs font-medium text-gray-500">Title</th>
<th class="text-left py-2 text-xs font-medium text-gray-500">Outcome</th>
<th class="text-left py-2 text-xs font-medium text-gray-500">Source Widget</th>
<th class="text-left py-2 text-xs font-medium text-gray-500">Decided At</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{forEach recentDecisions (renderDecisionRow allRequirements allCandidates widgets)}
</tbody>
</table>
|]}
</div>
<!-- Traceability coverage per widget -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-5">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Traceability Coverage</h2>
<table class="w-full text-sm">
<thead class="border-b border-gray-100">
<tr>
<th class="text-left py-2 text-xs font-medium text-gray-500">Widget</th>
<th class="text-center py-2 text-xs font-medium text-gray-500">Annotation</th>
<th class="text-center py-2 text-xs font-medium text-gray-500">Candidate</th>
<th class="text-center py-2 text-xs font-medium text-gray-500">Decision</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
{forEach widgets (renderCoverageRow allAnnotations allCandidates allRequirements allDecisions)}
</tbody>
</table>
</div>
|]
where
awaitingDecision = filter (isAwaitingDecision allDecisions) allRequirements
outcomeList :: [Text]
outcomeList = ["accepted", "rejected", "deferred", "split", "merged", "reframed"]
countOutcome :: [DecisionRecord] -> Text -> Int
countOutcome decisions o = length (filter (\d -> d.outcome == o) decisions)
renderKpiCard :: Text -> Int -> Html
renderKpiCard outcome count = [hsx|
<div class={kpiCardClass outcome <> " rounded-lg px-4 py-3 text-center"}>
<div class="text-2xl font-bold">{show count}</div>
<div class="text-xs mt-0.5 opacity-75">{outcome}</div>
</div>
|]
kpiCardClass :: Text -> Text
kpiCardClass "accepted" = "bg-green-50 text-green-800"
kpiCardClass "rejected" = "bg-red-50 text-red-800"
kpiCardClass "deferred" = "bg-gray-50 text-gray-700"
kpiCardClass "split" = "bg-purple-50 text-purple-800"
kpiCardClass "merged" = "bg-indigo-50 text-indigo-800"
kpiCardClass "reframed" = "bg-orange-50 text-orange-800"
kpiCardClass _ = "bg-gray-50 text-gray-700"
isAwaitingDecision :: [DecisionRecord] -> Requirement -> Bool
isAwaitingDecision decisions req =
not (any (\d -> d.requirementId == Just req.id) decisions)
renderAwaitingReq :: Requirement -> Html
renderAwaitingReq req = [hsx|
<div class="flex items-center justify-between py-2 border-b border-gray-50 last:border-0">
<a href={ShowRequirementAction { requirementId = req.id }}
class="text-sm text-indigo-600 hover:text-indigo-800">{req.title}</a>
<span class="text-xs text-gray-400">{show req.createdAt}</span>
</div>
|]
renderDecisionRow :: [Requirement] -> [RequirementCandidate] -> [Widget] -> DecisionRecord -> Html
renderDecisionRow reqs candidates widgets dr = [hsx|
<tr>
<td class="py-2 pr-4">
<a href={ShowDecisionRecordAction { decisionRecordId = dr.id }}
class="text-indigo-600 hover:text-indigo-800">{dr.title}</a>
</td>
<td class="py-2 pr-4">
<span class={outcomeClass dr.outcome <> " text-xs px-2 py-0.5 rounded font-medium"}>
{dr.outcome}
</span>
</td>
<td class="py-2 pr-4 text-gray-600 text-xs">
{originWidget dr reqs candidates widgets}
</td>
<td class="py-2 text-gray-400 text-xs">{show dr.decidedAt}</td>
</tr>
|]
-- Trace decision → requirement → candidate → widget name
originWidget :: DecisionRecord -> [Requirement] -> [RequirementCandidate] -> [Widget] -> Text
originWidget dr reqs candidates widgets =
case dr.requirementId >>= \rid -> find (\r -> r.id == rid) reqs of
Just req ->
case find (\c -> c.id == req.sourceCandidateId) candidates of
Just c ->
case find (\w -> w.id == c.sourceWidgetId) widgets of
Just w -> w.name
Nothing -> ""
Nothing -> ""
Nothing ->
case dr.candidateId >>= \cid -> find (\c -> c.id == cid) candidates of
Just c ->
case find (\w -> w.id == c.sourceWidgetId) widgets of
Just w -> w.name
Nothing -> ""
Nothing -> ""
renderCoverageRow :: [Annotation] -> [RequirementCandidate] -> [Requirement] -> [DecisionRecord] -> Widget -> Html
renderCoverageRow annotations candidates requirements decisions w = [hsx|
<tr>
<td class="py-2 pr-4 text-sm text-gray-700">{w.name}</td>
<td class="py-2 text-center">{coverageMark hasAnnotation}</td>
<td class="py-2 text-center">{coverageMark hasCandidate}</td>
<td class="py-2 text-center">{coverageMark hasDecision}</td>
</tr>
|]
where
hasAnnotation = any (\a -> a.widgetId == w.id) annotations
widgetCandidates = filter (\c -> c.sourceWidgetId == w.id) candidates
hasCandidate = not (null widgetCandidates)
candidateIds = map (.id) widgetCandidates
widgetReqIds = map (.id) (filter (\r -> r.sourceCandidateId `elem` candidateIds) requirements)
hasDecision = any (\d -> d.requirementId `elem` map Just widgetReqIds) decisions
coverageMark :: Bool -> Html
coverageMark True = [hsx|<span class="text-green-600 font-bold"></span>|]
coverageMark False = [hsx|<span class="text-gray-300"></span>|]
outcomeClass :: Text -> Text
outcomeClass "accepted" = "bg-green-100 text-green-800"
outcomeClass "rejected" = "bg-red-100 text-red-800"
outcomeClass "deferred" = "bg-gray-100 text-gray-600"
outcomeClass "split" = "bg-purple-100 text-purple-800"
outcomeClass "merged" = "bg-indigo-100 text-indigo-800"
outcomeClass "reframed" = "bg-orange-100 text-orange-800"
outcomeClass _ = "bg-gray-100 text-gray-600"

View File

@@ -33,6 +33,10 @@ instance View ShowView where
class="text-sm border border-indigo-300 text-indigo-700 px-3 py-1.5 rounded hover:bg-indigo-50">
Triage Dashboard
</a>
<a href={GovernanceDashboardAction { hubId = hub.id }}
class="text-sm border border-purple-300 text-purple-700 px-3 py-1.5 rounded hover:bg-purple-50">
Governance Dashboard
</a>
<a href={EditHubAction { hubId = hub.id }}
class="text-sm border border-gray-300 px-3 py-1.5 rounded hover:bg-gray-50">
Edit

View File

@@ -67,6 +67,9 @@ instance View ShowView where
{renderReviewerSection candidate mAssignment users}
</div>
<!-- Phase 3: Promote to Requirement / Link to Decision -->
{renderGovernanceActions candidate}
<!-- Triage history -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Triage History</h2>
@@ -167,6 +170,51 @@ renderTriageRow ts = [hsx|
</li>
|]
renderGovernanceActions :: RequirementCandidate -> Html
renderGovernanceActions candidate
| candidate.status == "accepted" = [hsx|
<div class="bg-white rounded-lg border border-indigo-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-3">Governance</h2>
<div class="flex flex-wrap gap-3">
{renderPromoteButton candidate}
{renderLinkDecisionButton candidate}
</div>
</div>
|]
| otherwise = mempty
renderPromoteButton :: RequirementCandidate -> Html
renderPromoteButton candidate =
case candidate.requirementId of
Just rid -> [hsx|
<a href={ShowRequirementAction { requirementId = rid }}
class="text-sm text-green-700 bg-green-50 border border-green-200 px-3 py-1.5 rounded hover:bg-green-100">
Requirement
</a>
|]
Nothing -> [hsx|
<form method="POST"
action={PromoteToRequirementAction { requirementCandidateId = candidate.id }}>
{hiddenField "authenticity_token"}
<button type="submit"
class="text-sm bg-indigo-600 text-white px-3 py-1.5 rounded hover:bg-indigo-700">
Promote to Requirement
</button>
</form>
|]
renderLinkDecisionButton :: RequirementCandidate -> Html
renderLinkDecisionButton candidate = [hsx|
<form method="POST"
action={LinkToDecisionAction { requirementCandidateId = candidate.id }}>
{hiddenField "authenticity_token"}
<button type="submit"
class="text-sm bg-gray-700 text-white px-3 py-1.5 rounded hover:bg-gray-800">
Create Decision Record
</button>
</form>
|]
statusClass :: Text -> Text
statusClass "open" = "bg-blue-100 text-blue-700"
statusClass "in_review" = "bg-yellow-100 text-yellow-800"

View File

@@ -0,0 +1,69 @@
module Web.View.Requirements.Index where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data IndexView = IndexView
{ requirements :: ![Requirement]
, candidates :: ![RequirementCandidate]
}
instance View IndexView where
html IndexView { .. } = [hsx|
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-semibold">Requirements</h1>
</div>
{if null requirements
then [hsx|<p class="text-sm text-gray-400">No requirements yet. Promote an accepted candidate to create one.</p>|]
else renderTable requirements candidates}
|]
renderTable :: [Requirement] -> [RequirementCandidate] -> Html
renderTable reqs candidates = [hsx|
<div class="bg-white rounded-lg border border-gray-200 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="text-left px-4 py-3 font-medium text-gray-600">Title</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Status</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Source Candidate</th>
<th class="text-left px-4 py-3 font-medium text-gray-600">Created</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
{forEach reqs (renderRow candidates)}
</tbody>
</table>
</div>
|]
renderRow :: [RequirementCandidate] -> Requirement -> Html
renderRow candidates req = [hsx|
<tr class="hover:bg-gray-50">
<td class="px-4 py-3">
<a href={ShowRequirementAction { requirementId = req.id }}
class="text-indigo-600 hover:text-indigo-800 font-medium">{req.title}</a>
</td>
<td class="px-4 py-3">
<span class={reqStatusClass req.status <> " text-xs px-2 py-0.5 rounded font-medium"}>
{req.status}
</span>
</td>
<td class="px-4 py-3 text-gray-600">
{candidateTitle candidates req.sourceCandidateId}
</td>
<td class="px-4 py-3 text-gray-400 text-xs">{show req.createdAt}</td>
</tr>
|]
candidateTitle :: [RequirementCandidate] -> Id RequirementCandidate -> Text
candidateTitle cs cid =
maybe "(unknown)" (.title) (find (\c -> c.id == cid) cs)
reqStatusClass :: Text -> Text
reqStatusClass "active" = "bg-green-100 text-green-800"
reqStatusClass "superseded" = "bg-yellow-100 text-yellow-800"
reqStatusClass "withdrawn" = "bg-gray-100 text-gray-500"
reqStatusClass _ = "bg-gray-100 text-gray-600"

View File

@@ -0,0 +1,72 @@
module Web.View.Requirements.Show where
import Web.Types
import Generated.Types
import IHP.Prelude
import IHP.ViewPrelude
data ShowView = ShowView
{ requirement :: !Requirement
, candidate :: !RequirementCandidate
, widget :: !Widget
, mDecision :: !(Maybe DecisionRecord)
}
instance View ShowView where
html ShowView { .. } = [hsx|
<div class="mb-6 flex items-center gap-2 text-sm text-gray-500">
<a href={RequirementsAction} class="hover:text-gray-700">Requirements</a>
<span>/</span>
<span>{requirement.title}</span>
</div>
<div class="max-w-3xl space-y-6">
<!-- Header card -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-5">
<div class="flex items-start justify-between mb-3">
<h1 class="text-2xl font-semibold">{requirement.title}</h1>
<span class={reqStatusClass requirement.status <> " text-xs px-2 py-0.5 rounded font-medium ml-4"}>
{requirement.status}
</span>
</div>
<p class="text-sm text-gray-700 leading-relaxed">{requirement.description}</p>
</div>
<!-- Source candidate -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-2">Source Candidate</h2>
<a href={ShowRequirementCandidateAction { requirementCandidateId = candidate.id }}
class="text-sm text-indigo-600 hover:text-indigo-800">{candidate.title}</a>
<p class="text-xs text-gray-400 mt-1">Widget: {widget.name}</p>
</div>
<!-- Linked decision -->
<div class="bg-white rounded-lg border border-gray-200 px-6 py-4">
<h2 class="text-sm font-semibold text-gray-700 mb-2">Linked Decision</h2>
{case mDecision of
Nothing -> [hsx|<p class="text-sm text-gray-400">No decision linked yet.</p>|]
Just dr -> [hsx|
<a href={ShowDecisionRecordAction { decisionRecordId = dr.id }}
class="text-sm text-indigo-600 hover:text-indigo-800">{dr.title}</a>
<span class={outcomeClass dr.outcome <> " text-xs px-2 py-0.5 rounded font-medium ml-2"}>
{dr.outcome}
</span>
|]}
</div>
</div>
|]
reqStatusClass :: Text -> Text
reqStatusClass "active" = "bg-green-100 text-green-800"
reqStatusClass "superseded" = "bg-yellow-100 text-yellow-800"
reqStatusClass "withdrawn" = "bg-gray-100 text-gray-500"
reqStatusClass _ = "bg-gray-100 text-gray-600"
outcomeClass :: Text -> Text
outcomeClass "accepted" = "bg-green-100 text-green-800"
outcomeClass "rejected" = "bg-red-100 text-red-800"
outcomeClass "deferred" = "bg-gray-100 text-gray-600"
outcomeClass "split" = "bg-purple-100 text-purple-800"
outcomeClass "merged" = "bg-indigo-100 text-indigo-800"
outcomeClass "reframed" = "bg-orange-100 text-orange-800"
outcomeClass _ = "bg-gray-100 text-gray-600"