feat(planning): explain and trace planning decisions
This commit is contained in:
1 parent
609b3dece9
commit
d7a13b0b1e
24 files changed
+6073
-342
No files matched your search
@@ -1,4 +1,6 @@
|
||||
<div class="calendar-year">
|
||||
<p class="sr-only" aria-live="polite" aria-atomic="true">{{ liveMessage() }}</p>
|
||||
|
||||
@if (isLoading()) {
|
||||
<div class="loading-overlay">
|
||||
<div class="loading-spinner">
|
||||
@@ -50,6 +52,73 @@
|
||||
<app-calendar-settings></app-calendar-settings>
|
||||
</header>
|
||||
|
||||
@if (planningOutcome(); as outcome) {
|
||||
@if (outcome.kind === 'InvalidInput') {
|
||||
<ul class="reason-list" role="alert">
|
||||
@for (reason of outcome.reasons; track reason.code + reason.message) {
|
||||
<li>
|
||||
<strong>{{ formatIdentifier(reason.code) }}:</strong> {{ reason.message }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
|
||||
@if (planningExplanation(); as outcomeExplanation) {
|
||||
@if (outcomeExplanation.kind === 'ConflictExplanation') {
|
||||
<section
|
||||
class="outcome-explanation conflict-explanation"
|
||||
aria-labelledby="conflict-title"
|
||||
>
|
||||
<h3 id="conflict-title">{{ tr().planning.conflictDetails }}</h3>
|
||||
<p>
|
||||
<strong>{{ tr().planning.conflictingRules }}:</strong>
|
||||
</p>
|
||||
<ul>
|
||||
@for (rule of outcomeExplanation.conflictingRules; track rule.id) {
|
||||
<li>
|
||||
{{ rule.label }} <code>{{ rule.id }}</code>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<p>
|
||||
<strong>{{ tr().planning.bottleneck }}:</strong>
|
||||
{{ outcomeExplanation.bottleneck.message }}
|
||||
</p>
|
||||
</section>
|
||||
} @else if (outcomeExplanation.kind === 'IncompleteExplanation') {
|
||||
<section
|
||||
class="outcome-explanation incomplete-explanation"
|
||||
aria-labelledby="incomplete-title"
|
||||
>
|
||||
<h3 id="incomplete-title">{{ tr().planning.incompleteDetails }}</h3>
|
||||
<p>{{ outcomeExplanation.reason.message }}</p>
|
||||
<dl class="compact-facts">
|
||||
<div>
|
||||
<dt>{{ tr().planning.completedStages }}</dt>
|
||||
<dd>
|
||||
{{
|
||||
outcomeExplanation.completedStages.length
|
||||
? outcomeExplanation.completedStages.join(', ')
|
||||
: tr().planning.none
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.uncompletedStages }}</dt>
|
||||
<dd>
|
||||
{{
|
||||
outcomeExplanation.uncompletedStages.length
|
||||
? outcomeExplanation.uncompletedStages.join(', ')
|
||||
: tr().planning.none
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
}
|
||||
}
|
||||
|
||||
@if (displayedPlan(); as plan) {
|
||||
<dl class="plan-summary">
|
||||
<div>
|
||||
@@ -67,6 +136,329 @@
|
||||
</dl>
|
||||
}
|
||||
|
||||
@if (selectedExplanation(); as selected) {
|
||||
<section class="placement-explanation" aria-labelledby="placement-explanation-title">
|
||||
<header class="explanation-heading">
|
||||
<div>
|
||||
<p class="eyebrow">{{ tr().planning.whyPlacement }}</p>
|
||||
<h3 id="placement-explanation-title">
|
||||
{{ selected.placement.date }} · {{ balanceLabel(selected) }}
|
||||
</h3>
|
||||
</div>
|
||||
<nav
|
||||
class="placement-stepper"
|
||||
[attr.aria-label]="tr().planning.placementNavigation"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-step="first"
|
||||
[disabled]="selectedPlacementIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.first"
|
||||
(click)="stepPlacement('first')"
|
||||
>
|
||||
<span aria-hidden="true">⏮</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-step="previous"
|
||||
[disabled]="selectedPlacementIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.previous"
|
||||
(click)="stepPlacement('previous')"
|
||||
>
|
||||
<span aria-hidden="true">◀</span>
|
||||
</button>
|
||||
<output>
|
||||
{{ tr().planning.placement }} {{ selectedPlacementIndex() + 1 }}
|
||||
{{ tr().planning.of }} {{ planExplanation()!.placements.length }}
|
||||
</output>
|
||||
<button
|
||||
type="button"
|
||||
data-step="next"
|
||||
[disabled]="
|
||||
selectedPlacementIndex() === planExplanation()!.placements.length - 1
|
||||
"
|
||||
[attr.aria-label]="tr().planning.next"
|
||||
(click)="stepPlacement('next')"
|
||||
>
|
||||
<span aria-hidden="true">▶</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-step="last"
|
||||
[disabled]="
|
||||
selectedPlacementIndex() === planExplanation()!.placements.length - 1
|
||||
"
|
||||
[attr.aria-label]="tr().planning.last"
|
||||
(click)="stepPlacement('last')"
|
||||
>
|
||||
<span aria-hidden="true">⏭</span>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
@if (planExplanation()!.incomplete; as interrupted) {
|
||||
<div class="incomplete-incumbent" role="status">
|
||||
<strong>{{ tr().planning.incumbentExplanation }}</strong>
|
||||
<p>{{ interrupted.reason.message }}</p>
|
||||
<p>
|
||||
{{ tr().planning.completedStages }}:
|
||||
{{
|
||||
interrupted.completedStages.length
|
||||
? interrupted.completedStages.join(', ')
|
||||
: tr().planning.none
|
||||
}}
|
||||
</p>
|
||||
<p>
|
||||
{{ tr().planning.uncompletedStages }}:
|
||||
{{
|
||||
interrupted.uncompletedStages.length
|
||||
? interrupted.uncompletedStages.join(', ')
|
||||
: tr().planning.none
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
<dl class="explanation-facts compact-facts">
|
||||
<div>
|
||||
<dt>{{ tr().planning.balance }}</dt>
|
||||
<dd>
|
||||
{{ balanceLabel(selected) }} <code>{{ selected.placement.balanceId }}</code>
|
||||
@if (selected.balance) {
|
||||
<br />{{ planningDate(selected.balance.availableFrom) }} –
|
||||
{{ planningDate(selected.balance.expiryDate) }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.rules }}</dt>
|
||||
<dd>
|
||||
@if (selected.ruleIds.length) {
|
||||
@for (ruleId of selected.ruleIds; track ruleId; let last = $last) {
|
||||
{{ ruleLabel(ruleId) }} <code>{{ ruleId }}</code
|
||||
>{{ last ? '' : ', ' }}
|
||||
}
|
||||
} @else {
|
||||
{{ tr().planning.noRules }}
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.supporting }}</dt>
|
||||
<dd>{{ selected.supporting ? tr().planning.yes : tr().planning.no }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.remaining }}</dt>
|
||||
<dd>
|
||||
{{ selected.remainingQuantity }}
|
||||
@if (selected.remainingBalance) {
|
||||
<br /><small>
|
||||
{{ formatIdentifier(selected.remainingBalance.classification) }} ·
|
||||
{{ selected.remainingBalance.reason.message }}
|
||||
</small>
|
||||
}
|
||||
</dd>
|
||||
</div>
|
||||
@if (selected.timeOffPeriod; as period) {
|
||||
<div>
|
||||
<dt>{{ tr().planning.timeOffPeriod }}</dt>
|
||||
<dd>{{ period.start }} – {{ period.end }} ({{ period.duration }})</dd>
|
||||
</div>
|
||||
}
|
||||
@if (selected.firstDecisiveObjective; as decisive) {
|
||||
<div>
|
||||
<dt>{{ tr().planning.firstDecisive }}</dt>
|
||||
<dd>{{ formatIdentifier(decisive) }}</dd>
|
||||
</div>
|
||||
}
|
||||
</dl>
|
||||
|
||||
<div class="explanation-columns">
|
||||
<section>
|
||||
<h4>{{ tr().planning.contributions }}</h4>
|
||||
<ul>
|
||||
@for (
|
||||
contribution of selected.contributions;
|
||||
track contribution.kind + contribution.code
|
||||
) {
|
||||
<li>
|
||||
<strong>{{ contribution.label }}</strong>
|
||||
@if (contribution.value !== undefined) {
|
||||
<span> · {{ contribution.value }}</span>
|
||||
}
|
||||
@if (contribution.dates?.length) {
|
||||
<span> · {{ contribution.dates!.join(', ') }}</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<h4>{{ tr().planning.objectiveVector }}</h4>
|
||||
<ol class="objective-vector">
|
||||
@for (
|
||||
objective of selected.objectiveVector.objectives;
|
||||
track objective.id
|
||||
) {
|
||||
<li>
|
||||
<span>{{ objectiveLabel(objective) }}</span>
|
||||
<strong>{{ objective.value }}</strong>
|
||||
<small>{{ formatIdentifier(objective.direction) }}</small>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</section>
|
||||
<section>
|
||||
<h4>{{ tr().planning.finalMetrics }}</h4>
|
||||
<dl class="metric-list">
|
||||
<div>
|
||||
<dt>{{ tr().planning.unplacedAtRisk }}</dt>
|
||||
<dd>{{ selected.finalMetrics.unplacedAtRisk }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.holidayWeekValue }}</dt>
|
||||
<dd>{{ selected.finalMetrics.holidayWeekValue }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.daysGained }}</dt>
|
||||
<dd>{{ selected.finalMetrics.nonWorkingDayGain }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.longestWorkSequence }}</dt>
|
||||
<dd>{{ selected.finalMetrics.longestWorkSequence }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.shortSequenceDeficit }}</dt>
|
||||
<dd>{{ selected.finalMetrics.shortSequenceDeficit }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.supportingPlacements }}</dt>
|
||||
<dd>{{ selected.finalMetrics.supportingPlacements }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ tr().planning.preferenceSatisfaction }}</dt>
|
||||
<dd>
|
||||
{{
|
||||
selected.finalMetrics.preferenceSatisfaction.join(', ') ||
|
||||
tr().planning.none
|
||||
}}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<details class="comparison-details">
|
||||
<summary>{{ tr().planning.removedRestoredComparison }}</summary>
|
||||
<p>{{ tr().planning.comparisonHelp }}</p>
|
||||
<ul>
|
||||
@for (delta of selected.comparison.delta; track delta.id) {
|
||||
<li>
|
||||
<strong>{{ formatIdentifier(delta.id) }}:</strong> {{ delta.value }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<section class="counterfactual-panel" aria-labelledby="counterfactual-title">
|
||||
<h4 id="counterfactual-title">{{ tr().planning.counterfactual }}</h4>
|
||||
<p>{{ tr().planning.hypothetical }}</p>
|
||||
@if (counterfactual(); as analysis) {
|
||||
<p>
|
||||
<strong
|
||||
>{{ analysis.placement.date }} ·
|
||||
{{ analysis.placement.balanceId }}</strong
|
||||
>
|
||||
</p>
|
||||
}
|
||||
@if (counterfactualLoading()) {
|
||||
<p role="status">{{ tr().planning.counterfactualLoading }}</p>
|
||||
} @else if (counterfactualError()) {
|
||||
<p class="inline-error" role="alert">
|
||||
{{ tr().planning.counterfactualError }} {{ counterfactualError() }}
|
||||
</p>
|
||||
} @else if (selectedCounterfactualResult(); as result) {
|
||||
<div class="counterfactual-result">
|
||||
<h5>{{ result.date }}</h5>
|
||||
<p class="status-badge" [class.ineligible]="result.status === 'ineligible'">
|
||||
{{
|
||||
result.status === 'eligible'
|
||||
? tr().planning.eligible
|
||||
: tr().planning.ineligible
|
||||
}}
|
||||
</p>
|
||||
@if (result.historicalStatus) {
|
||||
<p>
|
||||
<strong>{{ tr().planning.historical }}:</strong>
|
||||
{{ traceStatusLabel(result.historicalStatus) }}
|
||||
</p>
|
||||
}
|
||||
@if (result.reason) {
|
||||
<p>
|
||||
<strong>{{ tr().planning.reason }}:</strong>
|
||||
{{ result.reason.message }}
|
||||
</p>
|
||||
}
|
||||
@if (result.comparison; as comparison) {
|
||||
<p>
|
||||
<strong>{{ tr().planning.firstDecisive }}:</strong>
|
||||
{{
|
||||
comparison.firstDecisiveObjective
|
||||
? formatIdentifier(comparison.firstDecisiveObjective)
|
||||
: tr().planning.none
|
||||
}}
|
||||
</p>
|
||||
<ul>
|
||||
@for (delta of comparison.delta; track delta.id) {
|
||||
<li>
|
||||
<strong>{{ formatIdentifier(delta.id) }}:</strong>
|
||||
{{ delta.value }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<details>
|
||||
<summary>{{ tr().planning.fullComparison }}</summary>
|
||||
<div class="comparison-vectors">
|
||||
<div>
|
||||
<strong>{{ tr().planning.before }}</strong>
|
||||
<ol>
|
||||
@for (
|
||||
objective of comparison.before.objectives;
|
||||
track objective.id
|
||||
) {
|
||||
<li>
|
||||
{{ objectiveLabel(objective) }}:
|
||||
{{ objective.value }}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{ tr().planning.after }}</strong>
|
||||
<ol>
|
||||
@for (
|
||||
objective of comparison.after.objectives;
|
||||
track objective.id
|
||||
) {
|
||||
<li>
|
||||
{{ objectiveLabel(objective) }}:
|
||||
{{ objective.value }}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</section>
|
||||
} @else if (explanationError()) {
|
||||
<p class="inline-error" role="alert">
|
||||
{{ tr().planning.explanationError }} {{ explanationError()!.message }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<div class="calendar-legend" role="group" [attr.aria-label]="tr().planning.legend">
|
||||
<strong>{{ tr().planning.legend }}</strong>
|
||||
<ul>
|
||||
@@ -90,20 +482,406 @@
|
||||
<span class="legend-swatch today" aria-hidden="true"></span
|
||||
>{{ tr().dayTypes.today }}
|
||||
</li>
|
||||
<li>
|
||||
<span class="legend-symbol" aria-hidden="true">◎</span
|
||||
>{{ tr().planning.currentPlacement }}
|
||||
</li>
|
||||
<li>
|
||||
<span class="legend-symbol" aria-hidden="true">✓</span>{{ tr().planning.eligible }}
|
||||
</li>
|
||||
<li>
|
||||
<span class="legend-symbol" aria-hidden="true">×</span
|
||||
>{{ tr().planning.ineligible }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="month-grid">
|
||||
@for (month of months; track $index) {
|
||||
@for (month of months(); track month.year + '-' + month.monthIndex) {
|
||||
<mat-card class="month-card">
|
||||
<mat-card-content>
|
||||
<app-calendar-month
|
||||
[monthIndex]="month"
|
||||
[year]="getYear(month)"
|
||||
[selectedDates]="selectedDates"
|
||||
[monthIndex]="month.monthIndex"
|
||||
[year]="month.year"
|
||||
[selectedDates]="calendarSelectedDates()"
|
||||
[horizonStart]="planningHorizon()?.start"
|
||||
[horizonEnd]="planningHorizon()?.end"
|
||||
[currentDate]="currentPlacementDate()"
|
||||
[dateStatuses]="dateStatuses()"
|
||||
(dateSelected)="selectCalendarDate($event)"
|
||||
></app-calendar-month>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (planningOutcome() && planningOutcome()?.kind !== 'InvalidInput') {
|
||||
<details class="trace-explorer">
|
||||
<summary>{{ tr().planning.traceTitle }}</summary>
|
||||
@if (traceLoading()) {
|
||||
<p role="status">{{ tr().planning.traceLoading }}</p>
|
||||
} @else if (planningTrace(); as trace) {
|
||||
@if (trace.error) {
|
||||
<p class="inline-error" role="alert">{{ trace.error.message }}</p>
|
||||
}
|
||||
<div class="trace-layout">
|
||||
<nav class="trace-phases" [attr.aria-label]="tr().planning.phases">
|
||||
<div class="trace-phase-stepper">
|
||||
<button
|
||||
type="button"
|
||||
data-phase-step="first"
|
||||
[disabled]="tracePhaseIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.firstPhase"
|
||||
(click)="stepTracePhase('first')"
|
||||
>
|
||||
<span aria-hidden="true">⏮</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-phase-step="previous"
|
||||
[disabled]="tracePhaseIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.previousPhase"
|
||||
(click)="stepTracePhase('previous')"
|
||||
>
|
||||
<span aria-hidden="true">◀</span>
|
||||
</button>
|
||||
<output
|
||||
>{{ tracePhaseIndex() + 1 }} {{ tr().planning.of }}
|
||||
{{ trace.phases.length }}</output
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-phase-step="next"
|
||||
[disabled]="tracePhaseIndex() === trace.phases.length - 1"
|
||||
[attr.aria-label]="tr().planning.nextPhase"
|
||||
(click)="stepTracePhase('next')"
|
||||
>
|
||||
<span aria-hidden="true">▶</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-phase-step="last"
|
||||
[disabled]="tracePhaseIndex() === trace.phases.length - 1"
|
||||
[attr.aria-label]="tr().planning.lastPhase"
|
||||
(click)="stepTracePhase('last')"
|
||||
>
|
||||
<span aria-hidden="true">⏭</span>
|
||||
</button>
|
||||
</div>
|
||||
@for (phase of trace.phases; track phase.id; let index = $index) {
|
||||
<button
|
||||
type="button"
|
||||
[class.active]="index === tracePhaseIndex()"
|
||||
[attr.aria-pressed]="index === tracePhaseIndex()"
|
||||
(click)="selectTracePhase(index)"
|
||||
>
|
||||
<span>{{ formatIdentifier(phase.name) }}</span>
|
||||
<small>{{ tracePhaseStatusLabel(phase.status) }}</small>
|
||||
</button>
|
||||
}
|
||||
</nav>
|
||||
|
||||
@if (selectedTracePhase(); as phase) {
|
||||
<section class="trace-decisions" aria-labelledby="trace-phase-title">
|
||||
<h3 id="trace-phase-title">{{ formatIdentifier(phase.name) }}</h3>
|
||||
@if (phase.decisions.length) {
|
||||
<nav [attr.aria-label]="tr().planning.decisions">
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="traceDecisionIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.firstDecision"
|
||||
(click)="stepTraceDecision('first')"
|
||||
>
|
||||
<span aria-hidden="true">⏮</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="traceDecisionIndex() === 0"
|
||||
[attr.aria-label]="tr().planning.previousDecision"
|
||||
(click)="stepTraceDecision('previous')"
|
||||
>
|
||||
<span aria-hidden="true">◀</span>
|
||||
</button>
|
||||
<output
|
||||
>{{ tr().planning.decision }} {{ traceDecisionIndex() + 1 }}
|
||||
{{ tr().planning.of }} {{ phase.decisions.length }}</output
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="
|
||||
traceDecisionIndex() === phase.decisions.length - 1
|
||||
"
|
||||
[attr.aria-label]="tr().planning.nextDecision"
|
||||
(click)="stepTraceDecision('next')"
|
||||
>
|
||||
<span aria-hidden="true">▶</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="
|
||||
traceDecisionIndex() === phase.decisions.length - 1
|
||||
"
|
||||
[attr.aria-label]="tr().planning.lastDecision"
|
||||
(click)="stepTraceDecision('last')"
|
||||
>
|
||||
<span aria-hidden="true">⏭</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
@if (selectedTraceDecision(); as decision) {
|
||||
<div
|
||||
class="trace-toolbar"
|
||||
role="group"
|
||||
[attr.aria-label]="tr().planning.replay"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-pressed]="
|
||||
traceReplay() && traceMoment() === 'before'
|
||||
"
|
||||
(click)="showTraceMoment('before')"
|
||||
>
|
||||
{{ tr().planning.before }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-pressed]="
|
||||
traceReplay() && traceMoment() === 'after'
|
||||
"
|
||||
(click)="showTraceMoment('after')"
|
||||
>
|
||||
{{ tr().planning.after }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
[attr.aria-pressed]="!traceReplay()"
|
||||
(click)="showFinalPlan()"
|
||||
>
|
||||
{{ tr().planning.finalPlan }}
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
<strong>{{ tr().planning.dates }}:</strong>
|
||||
{{
|
||||
decision.dates.length
|
||||
? decision.dates.join(', ')
|
||||
: tr().planning.none
|
||||
}}
|
||||
</p>
|
||||
@if (decision.reason) {
|
||||
<p>
|
||||
<strong>{{ tr().planning.reason }}:</strong>
|
||||
{{ decision.reason.message }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<h4>{{ tr().planning.candidates }}</h4>
|
||||
@if (decision.candidates.length > 50) {
|
||||
<nav
|
||||
class="candidate-pagination"
|
||||
[attr.aria-label]="tr().planning.candidates"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="traceCandidatePage() === 0"
|
||||
[attr.aria-label]="tr().planning.previousCandidates"
|
||||
(click)="stepTraceCandidatePage(-1)"
|
||||
>
|
||||
◀
|
||||
</button>
|
||||
<output
|
||||
>{{ tr().planning.page }}
|
||||
{{ traceCandidatePage() + 1 }}
|
||||
{{ tr().planning.of }}
|
||||
{{ traceCandidatePages() }}</output
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
[disabled]="
|
||||
traceCandidatePage() ===
|
||||
traceCandidatePages() - 1
|
||||
"
|
||||
[attr.aria-label]="tr().planning.nextCandidates"
|
||||
(click)="stepTraceCandidatePage(1)"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
</nav>
|
||||
}
|
||||
<div class="candidate-list">
|
||||
@for (candidate of visibleTraceCandidates(); track $index) {
|
||||
<details>
|
||||
<summary>
|
||||
<span
|
||||
class="status-symbol"
|
||||
aria-hidden="true"
|
||||
>{{
|
||||
candidate.status === 'chosen'
|
||||
? '✓'
|
||||
: candidate.status === 'ineligible'
|
||||
? '×'
|
||||
: '•'
|
||||
}}</span
|
||||
>
|
||||
{{ traceStatusLabel(candidate.status) }} ·
|
||||
@if (
|
||||
candidate.sourceDate &&
|
||||
candidate.sourceDate !== candidate.date
|
||||
) {
|
||||
{{ candidate.sourceDate }} →
|
||||
}
|
||||
{{
|
||||
candidate.dates?.join(', ') ||
|
||||
candidate.date ||
|
||||
tr().planning.none
|
||||
}}
|
||||
</summary>
|
||||
@if (candidate.reason) {
|
||||
<p>{{ candidate.reason.message }}</p>
|
||||
}
|
||||
@if (candidate.comparison?.firstDecisiveObjective) {
|
||||
<p>
|
||||
<strong
|
||||
>{{
|
||||
tr().planning.firstDecisive
|
||||
}}:</strong
|
||||
>
|
||||
{{
|
||||
formatIdentifier(
|
||||
candidate.comparison!
|
||||
.firstDecisiveObjective!
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
@if (candidate.comparison; as comparison) {
|
||||
@if (comparison.tieBreak) {
|
||||
<p>
|
||||
<strong
|
||||
>{{
|
||||
tr().planning.tieBreak
|
||||
}}:</strong
|
||||
>
|
||||
{{
|
||||
formatIdentifier(
|
||||
comparison.tieBreak
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
}
|
||||
<ul>
|
||||
@for (
|
||||
delta of comparison.delta;
|
||||
track delta.id
|
||||
) {
|
||||
<li>
|
||||
<strong
|
||||
>{{
|
||||
formatIdentifier(delta.id)
|
||||
}}:</strong
|
||||
>
|
||||
{{ delta.value }}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
<div class="comparison-vectors">
|
||||
<div>
|
||||
<strong>{{
|
||||
tr().planning.before
|
||||
}}</strong>
|
||||
<ol>
|
||||
@for (
|
||||
objective of comparison.before
|
||||
.objectives;
|
||||
track objective.id
|
||||
) {
|
||||
<li>
|
||||
{{
|
||||
objectiveLabel(
|
||||
objective
|
||||
)
|
||||
}}: {{ objective.value }}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{{
|
||||
tr().planning.after
|
||||
}}</strong>
|
||||
<ol>
|
||||
@for (
|
||||
objective of comparison.after
|
||||
.objectives;
|
||||
track objective.id
|
||||
) {
|
||||
<li>
|
||||
{{
|
||||
objectiveLabel(
|
||||
objective
|
||||
)
|
||||
}}: {{ objective.value }}
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (candidate.placement) {
|
||||
<button
|
||||
type="button"
|
||||
(click)="analyzeTraceCandidate(candidate)"
|
||||
>
|
||||
{{ tr().planning.compareAlternatives }}
|
||||
</button>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
|
||||
<h4>{{ tr().planning.deltas }}</h4>
|
||||
<ul>
|
||||
@for (delta of decision.deltas; track $index) {
|
||||
<li>
|
||||
<strong>{{ formatIdentifier(delta.kind) }}</strong>
|
||||
@if (delta.fromDate || delta.date) {
|
||||
· {{ delta.fromDate || delta.date }}
|
||||
}
|
||||
@if (delta.toDate) {
|
||||
→ {{ delta.toDate }}
|
||||
}
|
||||
@if (delta.balanceId) {
|
||||
· {{ delta.balanceId }}
|
||||
}
|
||||
@if (delta.remainingAfter !== undefined) {
|
||||
· {{ delta.remainingBefore }} →
|
||||
{{ delta.remainingAfter }}
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@if (traceState(); as state) {
|
||||
<p class="trace-state-summary" role="status">
|
||||
{{
|
||||
traceMoment() === 'before'
|
||||
? tr().planning.before
|
||||
: tr().planning.after
|
||||
}}: {{ state.placements.length }}
|
||||
{{ tr().planning.placements }},
|
||||
{{ state.earnedActivations.length }}
|
||||
{{ tr().planning.activations }}
|
||||
</p>
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<p>{{ tr().planning.noDecisions }}</p>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
} @else if (traceResolved()) {
|
||||
<p>{{ tr().planning.traceUnavailable }}</p>
|
||||
}
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
Reference in new issue
Block a user