From 5a56b050ac71c9638bab1fc60131514396556000 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 8 Oct 2026 13:58:40 +0100 Subject: [PATCH 1/4] feat(governance): defer a pending approval to the next committee (#903) The redesigned executive view offers "Reporter" next to "Approuver" on each decision the committee owes. The approval engine only knows approve and reject, and the owner chose a dated note rather than a new engine state: POST /governance/approvals/:id/defer appends who deferred, when and why to a new deferrals list (migration 0071) and writes a 'defer' audit event. The request stays pending at the same step; quorum, four-eyes and delegations read the decisions only and do not see it. Only someone eligible to sign the open step may defer, so a deferral is never a way round the maker-checker rule. Tests cover success (circuit untouched, approval still works after), not found, the maker, an ineligible role and another tenant. Signed-off-by: alex-dembele --- backend/cmd/server/main.go | 2 + .../governance/approval_defer_test.go | 87 +++++++++++++++++++ .../governance/approval_engine_usecases.go | 58 +++++++++++++ backend/internal/domain/governance.go | 56 ++++++++++-- .../handler/authz_route_coverage_test.go | 2 + .../internal/handler/governance_handler.go | 19 ++++ migrations/0071_approval_deferrals.down.sql | 1 + migrations/0071_approval_deferrals.up.sql | 7 ++ 8 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 backend/internal/application/governance/approval_defer_test.go create mode 100644 migrations/0071_approval_deferrals.down.sql create mode 100644 migrations/0071_approval_deferrals.up.sql diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 5bfdf708..7ff0c530 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -3244,6 +3244,8 @@ func main() { protected.Post("/governance/approvals", governanceHandler.SubmitApproval) protected.Get("/governance/approvals/:id", governanceHandler.GetApproval) protected.Post("/governance/approvals/:id/decide", governanceHandler.DecideApproval) + // Defer to the next committee (#903): eligibility is the decide rule. + protected.Post("/governance/approvals/:id/defer", governanceHandler.DeferApproval) protected.Post("/governance/approvals/:id/cancel", governanceHandler.CancelApproval) // Requests nobody decided in time close as EXPIRED — a distinct outcome from // a refusal, because "nobody said no" and "someone said no" call for diff --git a/backend/internal/application/governance/approval_defer_test.go b/backend/internal/application/governance/approval_defer_test.go new file mode 100644 index 00000000..ec2b2779 --- /dev/null +++ b/backend/internal/application/governance/approval_defer_test.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package governance + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "github.com/opendefender/openrisk/internal/domain" +) + +func submitTwoStep(t *testing.T) (*fakeApprovalRepo, uuid.UUID, uuid.UUID, *domain.ApprovalRequest) { + t.Helper() + repo := newFakeApprovalRepo() + tenant, requester := uuid.New(), uuid.New() + seedTwoStepWorkflow(t, repo, tenant) + req, err := NewSubmitApprovalRequestUseCase(repo, repo).Execute(context.Background(), tenant, requester, SubmitApprovalInput{ + EntityType: "risk_acceptance", Action: "accept", EntityID: "risk-1", Title: "Accept residual risk", + }) + if err != nil { + t.Fatalf("submit: %v", err) + } + return repo, tenant, requester, req +} + +// #903: deferring leaves the request pending at the same step, with a dated +// note, and the next approval still works as before. +func TestDeferApproval_Success(t *testing.T) { + repo, tenant, _, req := submitTwoStep(t) + decide := NewDecideApprovalUseCase(repo) + manager := uuid.New() + + got, err := decide.Defer(context.Background(), tenant, req.ID, + ApproverIdentity{UserID: manager, Email: "m@x.io", Roles: []string{"manager"}}, " needs the Q4 budget ") + if err != nil { + t.Fatalf("defer: %v", err) + } + if got.Status != domain.ApprovalPending || got.CurrentStep != req.CurrentStep { + t.Fatalf("defer moved the circuit: status=%s step=%d", got.Status, got.CurrentStep) + } + if len(got.Deferrals) != 1 || got.Deferrals[0].Comment != "needs the Q4 budget" || got.Deferrals[0].DeferredBy != manager.String() { + t.Fatalf("deferral not recorded: %+v", got.Deferrals) + } + if len(got.Decisions) != 0 { + t.Fatalf("a deferral must not be a decision: %+v", got.Decisions) + } + // The same manager can still approve the step afterwards. + after, err := decide.Execute(context.Background(), tenant, req.ID, + ApproverIdentity{UserID: manager, Roles: []string{"manager"}}, DecideInput{Decision: "approve"}) + if err != nil || after.CurrentStep != 1 { + t.Fatalf("approve after defer: err=%v step=%d", err, after.CurrentStep) + } +} + +func TestDeferApproval_NotFound(t *testing.T) { + repo, tenant, _, _ := submitTwoStep(t) + _, err := NewDecideApprovalUseCase(repo).Defer(context.Background(), tenant, uuid.New(), + ApproverIdentity{UserID: uuid.New(), IsAdmin: true}, "") + if !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("expected not found, got %v", err) + } +} + +// Only someone who could sign may defer: not the requester (four-eyes), not an +// ineligible role, not another tenant. +func TestDeferApproval_Unauthorized(t *testing.T) { + repo, tenant, requester, req := submitTwoStep(t) + decide := NewDecideApprovalUseCase(repo) + if _, err := decide.Defer(context.Background(), tenant, req.ID, + ApproverIdentity{UserID: requester, Roles: []string{"manager"}}, ""); !errors.Is(err, domain.ErrForbidden) { + t.Fatalf("maker deferred own request: %v", err) + } + if _, err := decide.Defer(context.Background(), tenant, req.ID, + ApproverIdentity{UserID: uuid.New(), Roles: []string{"viewer"}}, ""); !errors.Is(err, domain.ErrForbidden) { + t.Fatalf("ineligible role deferred: %v", err) + } + if _, err := decide.Defer(context.Background(), uuid.New(), req.ID, + ApproverIdentity{UserID: uuid.New(), IsAdmin: true}, ""); !errors.Is(err, domain.ErrNotFound) { + t.Fatalf("other tenant reached the request: %v", err) + } +} diff --git a/backend/internal/application/governance/approval_engine_usecases.go b/backend/internal/application/governance/approval_engine_usecases.go index 34168f56..5834895d 100644 --- a/backend/internal/application/governance/approval_engine_usecases.go +++ b/backend/internal/application/governance/approval_engine_usecases.go @@ -419,3 +419,61 @@ func (uc *GetApprovalDetailUseCase) Execute(ctx context.Context, tenantID, id uu } return detail, nil } + +// Defer records that the committee pushed a pending request to its next +// sitting (#903, owner decision 2026-10-08). The request stays pending and +// nothing in the signature circuit moves; only someone who could sign the open +// step may defer it, so a deferral is never a way round the four-eyes rule. +func (uc *DecideApprovalUseCase) Defer(ctx context.Context, tenantID, id uuid.UUID, who ApproverIdentity, comment string) (*domain.ApprovalRequest, error) { + req, err := uc.requests.GetRequestByID(ctx, id, tenantID) + if err != nil { + return nil, err + } + if req == nil { + return nil, domain.NewNotFoundError("approval request", id) + } + now := time.Now().UTC() + if domain.IsExpired(req, now) { + return nil, domain.NewValidationError("this request expired on " + req.ExpiresAt.Format(time.RFC3339) + " and can no longer be deferred") + } + if req.Status != domain.ApprovalPending { + return nil, domain.NewValidationError("request is already " + string(req.Status)) + } + approver := uc.resolveApprover(ctx, tenantID, who, now) + step := domain.StepFor(req, nil) + if step == nil { + return nil, domain.NewValidationError("this request has no step awaiting a decision") + } + if verdict := domain.CanSign(req, step, approver); !verdict.Eligible { + return nil, domain.NewForbiddenError(verdict.Reason) + } + + comment = strings.TrimSpace(comment) + req.Deferrals = append(req.Deferrals, domain.ApprovalDeferral{ + DeferredBy: approver.UserID.String(), + DeferredByEmail: approver.Email, + Comment: comment, + DeferredAt: now, + }) + if err := uc.requests.UpdateRequest(ctx, req); err != nil { + return nil, err + } + + if uc.recorder != nil { + actor := approver.UserID + after := domain.JSONMap{"status": string(req.Status), "deferrals": len(req.Deferrals)} + if comment != "" { + after["comment"] = comment + } + uc.recorder.Record(ctx, domain.AuditEvent{ + TenantID: tenantID, + ActorID: &actor, + Action: domain.AuditActionDefer, + EntityType: "approval_request", + EntityID: req.ID.String(), + Summary: "defer \"" + req.Title + "\" to the next committee", + After: after, + }) + } + return req, nil +} diff --git a/backend/internal/domain/governance.go b/backend/internal/domain/governance.go index 9b925fb1..7c7e24fc 100644 --- a/backend/internal/domain/governance.go +++ b/backend/internal/domain/governance.go @@ -92,6 +92,7 @@ const ( AuditActionLogin AuditAction = "login" AuditActionExport AuditAction = "export" AuditActionTransfer AuditAction = "transfer" // ownership handed to another member + AuditActionDefer AuditAction = "defer" // approval request deferred to the next committee ) // AuditEvent is one immutable row in the audit trail. There is intentionally no @@ -368,6 +369,47 @@ type ApprovalDecision struct { DecidedAt time.Time `json:"decided_at"` } +// ApprovalDeferral records that a committee pushed a request to its next +// sitting (#903, owner decision 2026-10-08): who, when, and why. +type ApprovalDeferral struct { + DeferredBy string `json:"deferred_by"` + DeferredByEmail string `json:"deferred_by_email,omitempty"` + Comment string `json:"comment,omitempty"` + DeferredAt time.Time `json:"deferred_at"` +} + +// ApprovalDeferralList is a jsonb array of deferrals. +type ApprovalDeferralList []ApprovalDeferral + +func (l ApprovalDeferralList) Value() (driver.Value, error) { + if l == nil { + return "[]", nil + } + return json.Marshal(l) +} + +func (l *ApprovalDeferralList) Scan(value interface{}) error { + if value == nil { + *l = nil + return nil + } + switch v := value.(type) { + case []byte: + if len(v) == 0 { + *l = nil + return nil + } + return json.Unmarshal(v, l) + case string: + if v == "" { + *l = nil + return nil + } + return json.Unmarshal([]byte(v), l) + } + return nil +} + // ApprovalDecisionList is a jsonb array of decisions. type ApprovalDecisionList []ApprovalDecision @@ -419,11 +461,15 @@ type ApprovalRequest struct { CurrentStep int `gorm:"default:0" json:"current_step"` // Mode and ExpiresAt are snapshotted from the workflow at submit time, like // Steps: editing the workflow must never move an in-flight request's goalposts. - Mode string `gorm:"type:varchar(16);default:'sequential'" json:"mode"` - ExpiresAt *time.Time `gorm:"index" json:"expires_at,omitempty"` - RequestType string `gorm:"type:varchar(64);index" json:"request_type,omitempty"` - Steps WorkflowStepList `gorm:"type:jsonb" json:"steps"` - Decisions ApprovalDecisionList `gorm:"type:jsonb" json:"decisions"` + Mode string `gorm:"type:varchar(16);default:'sequential'" json:"mode"` + ExpiresAt *time.Time `gorm:"index" json:"expires_at,omitempty"` + RequestType string `gorm:"type:varchar(64);index" json:"request_type,omitempty"` + Steps WorkflowStepList `gorm:"type:jsonb" json:"steps"` + Decisions ApprovalDecisionList `gorm:"type:jsonb" json:"decisions"` + // Deferrals are dated notes "deferred to the next committee" (#903). They + // never touch the signature circuit: the request stays pending, and the + // engine reads Decisions only. + Deferrals ApprovalDeferralList `gorm:"type:jsonb;default:'[]'" json:"deferrals"` RequestedBy uuid.UUID `gorm:"type:uuid;index" json:"requested_by"` RequestedByEmail string `gorm:"-" json:"requested_by_email,omitempty"` ResolvedAt *time.Time `json:"resolved_at,omitempty"` diff --git a/backend/internal/handler/authz_route_coverage_test.go b/backend/internal/handler/authz_route_coverage_test.go index afa2651d..de0c288a 100644 --- a/backend/internal/handler/authz_route_coverage_test.go +++ b/backend/internal/handler/authz_route_coverage_test.go @@ -176,6 +176,8 @@ var routesWithoutPermissionGuard = []string{ "POST /governance/approvals", "POST /governance/approvals/:id/cancel", "POST /governance/approvals/:id/decide", + // #903: same rule as decide — the engine checks the caller may sign the open step. + "POST /governance/approvals/:id/defer", "POST /governance/delegations", "POST /governance/delegations/:id/revoke", "POST /onboarding/complete", diff --git a/backend/internal/handler/governance_handler.go b/backend/internal/handler/governance_handler.go index a8248a48..0387e42d 100644 --- a/backend/internal/handler/governance_handler.go +++ b/backend/internal/handler/governance_handler.go @@ -700,6 +700,25 @@ func (h *GovernanceHandler) DecideApproval(c *fiber.Ctx) error { return c.JSON(req) } +// DeferApproval POST /governance/approvals/:id/defer — "Reporter au prochain +// comité" (#903). The request stays pending; a dated note is added. +func (h *GovernanceHandler) DeferApproval(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid request id"}) + } + var body struct { + Comment string `json:"comment"` + } + // An empty body is fine: the comment is optional. + _ = c.BodyParser(&body) + req, err := h.decideApproval.Defer(govCtx(c), tenantID(c), id, approverFromCtx(c), body.Comment) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(req) +} + func (h *GovernanceHandler) CancelApproval(c *fiber.Ctx) error { id, err := uuid.Parse(c.Params("id")) if err != nil { diff --git a/migrations/0071_approval_deferrals.down.sql b/migrations/0071_approval_deferrals.down.sql new file mode 100644 index 00000000..78479000 --- /dev/null +++ b/migrations/0071_approval_deferrals.down.sql @@ -0,0 +1 @@ +ALTER TABLE approval_requests DROP COLUMN IF EXISTS deferrals; diff --git a/migrations/0071_approval_deferrals.up.sql b/migrations/0071_approval_deferrals.up.sql new file mode 100644 index 00000000..9a1842fa --- /dev/null +++ b/migrations/0071_approval_deferrals.up.sql @@ -0,0 +1,7 @@ +-- #903 — "Reporter au prochain comité" on a pending approval. +-- +-- A dated note on the request, kept apart from the decisions so the signature +-- engine (quorum, four-eyes, delegations) is untouched: a deferred request is +-- still pending. AutoMigrate adds the column on boot too. + +ALTER TABLE approval_requests ADD COLUMN IF NOT EXISTS deferrals jsonb DEFAULT '[]'::jsonb; From 4b4409ce5390c0f29e861950e7429170d2879e91 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Fri, 9 Oct 2026 07:38:26 +0100 Subject: [PATCH 2/4] feat(score): quarter baseline for the executive view's deltas (#903) The executive view says how each headline moved since last quarter ("+4 pts depuis le T3"), and only the score had a past. The daily snapshot now also keeps the annualised exposure, the live critical risks and the average compliance coverage (migration 0072), each read on its own so one failing source leaves only its figure empty. /score/history returns quarter_baseline: the last snapshot before the current quarter began, or null when none exists, so no delta is ever invented. The financial summary's top risks also carry ale_after, the exposure targeted once mitigations are in place, which the view draws next to the inherent ALE. Signed-off-by: alex-dembele --- backend/cmd/server/main.go | 1 + backend/cmd/server/score_wiring.go | 40 ++++++++++++ .../application/risk/financial_summary.go | 8 ++- backend/internal/application/score/history.go | 64 ++++++++++++++++++- .../application/score/history_test.go | 55 ++++++++++++++++ backend/internal/domain/score_snapshot.go | 10 ++- .../gorm_score_snapshot_repository.go | 6 +- .../0072_score_snapshot_figures.down.sql | 3 + migrations/0072_score_snapshot_figures.up.sql | 9 +++ 9 files changed, 187 insertions(+), 9 deletions(-) create mode 100644 migrations/0072_score_snapshot_figures.down.sql create mode 100644 migrations/0072_score_snapshot_figures.up.sql diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 7ff0c530..310df6d2 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -2925,6 +2925,7 @@ func main() { getGapAnalysisUC, incidentService, scoreSnapshotRepo, ) + scoreHistoryUC.WithFigures(postureFiguresAdapter{financial: financialSummaryUseCase, gaps: getGapAnalysisUC}) // One snapshot per tenant per day, so the dashboard can draw the year and // the 30-day movement even for a tenant nobody opened that day (#901). go workers.NewScoreSnapshotWorker(scoreSnapshotRepo, scoreHistoryUC, zeroLogger). diff --git a/backend/cmd/server/score_wiring.go b/backend/cmd/server/score_wiring.go index 3473c516..6f17493f 100644 --- a/backend/cmd/server/score_wiring.go +++ b/backend/cmd/server/score_wiring.go @@ -7,12 +7,16 @@ package main import ( "context" + "time" "github.com/google/uuid" "github.com/opendefender/openrisk/internal/application/compliance" + "github.com/opendefender/openrisk/internal/application/risk" "github.com/opendefender/openrisk/internal/application/score" + "github.com/opendefender/openrisk/internal/domain/timeframe" handlers "github.com/opendefender/openrisk/internal/handler" + "github.com/opendefender/openrisk/internal/infrastructure/database" "github.com/opendefender/openrisk/internal/infrastructure/repository" "github.com/opendefender/openrisk/internal/service" ) @@ -78,3 +82,39 @@ func newScoreHandler( history := score.NewHistory(uc, snapshots) return handlers.NewScoreHandler(uc).WithHistory(history), history } + +// postureFiguresAdapter reads the executive view's headline figures for the +// daily snapshot (#903): the annualised exposure, the live critical risks and +// the average compliance coverage. Each source is read on its own; a failure +// leaves that one figure nil rather than failing the snapshot. +type postureFiguresAdapter struct { + financial *risk.FinancialSummaryUseCase + gaps *compliance.GetGapAnalysisUseCase +} + +func (a postureFiguresAdapter) Figures(ctx context.Context, tenantID uuid.UUID) score.Figures { + var f score.Figures + if a.financial != nil { + if sum, err := a.financial.Execute(ctx, tenantID); err == nil && sum != nil { + v := sum.TotalALE.XAF + f.ALEXAF = &v + } + } + if w, err := timeframe.Parse("all", "", "", time.Now().UTC()); err == nil { + if stats, err := handlers.ComputeDashboardStats(ctx, database.DB, tenantID, w); err == nil { + n := int(stats.CriticalLive) + f.CriticalRisks = &n + } + } + if a.gaps != nil { + if g, err := a.gaps.Execute(ctx, tenantID, uuid.Nil); err == nil && g != nil && len(g.Frameworks) > 0 { + var sum float64 + for _, fw := range g.Frameworks { + sum += fw.PercentComplete + } + v := sum / float64(len(g.Frameworks)) + f.CompliancePct = &v + } + } + return f +} diff --git a/backend/internal/application/risk/financial_summary.go b/backend/internal/application/risk/financial_summary.go index 7cd272e2..4b67c477 100644 --- a/backend/internal/application/risk/financial_summary.go +++ b/backend/internal/application/risk/financial_summary.go @@ -45,8 +45,11 @@ type TopRiskFinancial struct { Criticality string `json:"criticality"` ALE crq.Money `json:"ale"` ALEWorst crq.Money `json:"ale_worst"` - ROSI float64 `json:"rosi"` - ROSIOK bool `json:"rosi_computable"` + // ALEAfter is the exposure targeted once the mitigations are in place + // (#903: the executive view draws it beside the inherent ALE). + ALEAfter crq.Money `json:"ale_after"` + ROSI float64 `json:"rosi"` + ROSIOK bool `json:"rosi_computable"` } // FinancialSummary is the tenant-wide financial posture rendered by the CFO/CISO @@ -175,6 +178,7 @@ func (uc *FinancialSummaryUseCase) Execute(ctx context.Context, tenantID uuid.UU Criticality: string(r.Criticality), ALE: a.ALE, ALEWorst: a.ALEWorst, + ALEAfter: a.ALEAfter, ROSI: a.ROSI, ROSIOK: a.ROSIComputable, }) diff --git a/backend/internal/application/score/history.go b/backend/internal/application/score/history.go index 3fdcf6cf..7637fc6a 100644 --- a/backend/internal/application/score/history.go +++ b/backend/internal/application/score/history.go @@ -21,6 +21,30 @@ type SnapshotStore interface { ListSince(ctx context.Context, tenantID uuid.UUID, since time.Time) ([]domain.TenantScoreSnapshot, error) } +// Figures are the other headline numbers recorded with the score (#903). +// A nil field means its source could not be read. +type Figures struct { + ALEXAF *float64 + CriticalRisks *int + CompliancePct *float64 +} + +// FiguresSource reads today's figures for one tenant. +type FiguresSource interface { + Figures(ctx context.Context, tenantID uuid.UUID) Figures +} + +// Baseline is the last snapshot before the current quarter began: what the +// executive view compares today's figures with ("depuis le T3"). +type Baseline struct { + Quarter string `json:"quarter"` // e.g. "2026-Q3" + Day time.Time `json:"day"` + Value float64 `json:"value"` + ALEXAF *float64 `json:"ale_xaf"` + CriticalRisks *int `json:"critical_risks"` + CompliancePct *float64 `json:"compliance_pct"` +} + // HistoryPoint is one month of the tenant score: the last snapshot taken in // that month. A month with no snapshot is absent, never zero. type HistoryPoint struct { @@ -44,13 +68,23 @@ type History struct { // Since is the oldest snapshot kept for the tenant within the window, so // the UI can say how far back the line really goes. Since *time.Time `json:"since"` + // QuarterBaseline is nil until a snapshot exists from before the current + // quarter: a delta needs a real reading at both ends. + QuarterBaseline *Baseline `json:"quarter_baseline"` } // HistoryUseCase records and reads the daily tenant score history. type HistoryUseCase struct { - score *UseCase - store SnapshotStore - now func() time.Time + score *UseCase + store SnapshotStore + figures FiguresSource + now func() time.Time +} + +// WithFigures records the executive figures alongside the score (#903). +func (h *HistoryUseCase) WithFigures(f FiguresSource) *HistoryUseCase { + h.figures = f + return h } func NewHistory(score *UseCase, store SnapshotStore) *HistoryUseCase { @@ -77,6 +111,10 @@ func (h *HistoryUseCase) Record(ctx context.Context, tenantID uuid.UUID) (*scori Band: string(res.Band), FormulaVersion: res.FormulaVersion, } + if h.figures != nil { + f := h.figures.Figures(ctx, tenantID) + snap.ALEXAF, snap.CriticalRisks, snap.CompliancePct = f.ALEXAF, f.CriticalRisks, f.CompliancePct + } if err := h.store.Upsert(ctx, snap); err != nil { return res, err } @@ -98,12 +136,17 @@ func (h *HistoryUseCase) Execute(ctx context.Context, tenantID uuid.UUID, months today := domain.SnapshotDay(h.now()) start := time.Date(today.Year(), today.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, -(months - 1), 0) + quarterStart := time.Date(today.Year(), time.Month(((int(today.Month())-1)/3)*3+1), 1, 0, 0, 0, 0, time.UTC) // Reach 30 days further back than the window when needed, so the delta can // be computed even for a one-month window. from := start if d := today.AddDate(0, 0, -30); d.Before(from) { from = d } + // The previous quarter's closing snapshot can be older than the window. + if d := quarterStart.AddDate(0, -3, 0); d.Before(from) { + from = d + } rows, err := h.store.ListSince(ctx, tenantID, from) if err != nil { return nil, err @@ -134,6 +177,7 @@ func (h *HistoryUseCase) Execute(ctx context.Context, tenantID uuid.UUID, months var current *domain.TenantScoreSnapshot var past *domain.TenantScoreSnapshot + var base *domain.TenantScoreSnapshot cutoff := today.AddDate(0, 0, -30) for i := range rows { day := domain.SnapshotDay(rows[i].Day) @@ -143,6 +187,9 @@ func (h *HistoryUseCase) Execute(ctx context.Context, tenantID uuid.UUID, months if !day.After(cutoff) { past = &rows[i] } + if day.Before(quarterStart) { + base = &rows[i] + } } if current != nil { v := current.Value @@ -152,6 +199,17 @@ func (h *HistoryUseCase) Execute(ctx context.Context, tenantID uuid.UUID, months out.Delta30d = &d } } + if base != nil { + bd := domain.SnapshotDay(base.Day) + out.QuarterBaseline = &Baseline{ + Quarter: bd.Format("2006") + "-Q" + string(rune('0'+(int(bd.Month())-1)/3+1)), + Day: bd, + Value: base.Value, + ALEXAF: base.ALEXAF, + CriticalRisks: base.CriticalRisks, + CompliancePct: base.CompliancePct, + } + } return out, nil } diff --git a/backend/internal/application/score/history_test.go b/backend/internal/application/score/history_test.go index 3f241921..aa479122 100644 --- a/backend/internal/application/score/history_test.go +++ b/backend/internal/application/score/history_test.go @@ -164,3 +164,58 @@ func TestScoreHistory_StoreFailureSurfaces(t *testing.T) { _, err := h.Execute(context.Background(), uuid.New(), 12) assert.Error(t, err) } + +type fixedFigures struct{ f Figures } + +func (s fixedFigures) Figures(context.Context, uuid.UUID) Figures { return s.f } + +// #903: today's snapshot carries the executive figures, and the history +// returns the last snapshot from before the current quarter as the baseline. +func TestScoreHistory_QuarterBaseline(t *testing.T) { + store := newMemSnapshots() + tenant := uuid.New() + ale, crit, comp := 117.2e6, 2, 73.0 + store.put(tenant, "2026-08-14", 60) + store.put(tenant, "2026-09-29", 57) // last reading of Q3 + store.put(tenant, "2026-10-02", 55) // already Q4: not the baseline + d, _ := time.Parse("2006-01-02", "2026-09-29") + r := store.rows[store.key(tenant, d)] + oldALE, oldCrit := 129.6e6, 3 + r.ALEXAF, r.CriticalRisks = &oldALE, &oldCrit + store.rows[store.key(tenant, d)] = r + + uc := measuredUseCase() + uc.now = fixedNow("2026-10-08") + h := NewHistory(uc, store).WithFigures(fixedFigures{Figures{ALEXAF: &ale, CriticalRisks: &crit, CompliancePct: &comp}}) + h.now = fixedNow("2026-10-08") + + out, err := h.Execute(context.Background(), tenant, 12) + require.NoError(t, err) + require.NotNil(t, out.QuarterBaseline) + assert.Equal(t, "2026-Q3", out.QuarterBaseline.Quarter) + assert.Equal(t, 57.0, out.QuarterBaseline.Value) + assert.Equal(t, oldALE, *out.QuarterBaseline.ALEXAF) + assert.Equal(t, oldCrit, *out.QuarterBaseline.CriticalRisks) + assert.Nil(t, out.QuarterBaseline.CompliancePct, "a figure not read that day stays a gap") + + today, _ := time.Parse("2006-01-02", "2026-10-08") + snap := store.rows[store.key(tenant, today)] + require.NotNil(t, snap.ALEXAF) + assert.Equal(t, ale, *snap.ALEXAF) + assert.Equal(t, crit, *snap.CriticalRisks) + assert.Equal(t, comp, *snap.CompliancePct) +} + +// No snapshot before the quarter: no baseline, so no delta is invented. +func TestScoreHistory_NoQuarterBaseline(t *testing.T) { + store := newMemSnapshots() + tenant := uuid.New() + store.put(tenant, "2026-10-02", 55) + uc := measuredUseCase() + uc.now = fixedNow("2026-10-08") + h := NewHistory(uc, store) + h.now = fixedNow("2026-10-08") + out, err := h.Execute(context.Background(), tenant, 12) + require.NoError(t, err) + assert.Nil(t, out.QuarterBaseline) +} diff --git a/backend/internal/domain/score_snapshot.go b/backend/internal/domain/score_snapshot.go index 472938f1..bcb90099 100644 --- a/backend/internal/domain/score_snapshot.go +++ b/backend/internal/domain/score_snapshot.go @@ -27,8 +27,14 @@ type TenantScoreSnapshot struct { Value float64 `gorm:"type:double precision;not null" json:"value"` Band string `gorm:"size:16;not null" json:"band"` FormulaVersion string `gorm:"size:16;not null" json:"formula_version"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + // The other headline figures of the day (#903), so the executive view can + // say how each moved since the last quarter. Nil when the source could not + // be read that day: a gap, never a zero. + ALEXAF *float64 `gorm:"column:ale_xaf;type:double precision" json:"ale_xaf"` + CriticalRisks *int `json:"critical_risks"` + CompliancePct *float64 `gorm:"type:double precision" json:"compliance_pct"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName pins the table so the SQL migration and AutoMigrate agree. diff --git a/backend/internal/infrastructure/repository/gorm_score_snapshot_repository.go b/backend/internal/infrastructure/repository/gorm_score_snapshot_repository.go index 7f32c628..30239c54 100644 --- a/backend/internal/infrastructure/repository/gorm_score_snapshot_repository.go +++ b/backend/internal/infrastructure/repository/gorm_score_snapshot_repository.go @@ -37,8 +37,10 @@ func (r *GormScoreSnapshotRepository) Upsert(ctx context.Context, s *domain.Tena } s.Day = domain.SnapshotDay(s.Day) return r.db.WithContext(ctx).Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "tenant_id"}, {Name: "day"}}, - DoUpdates: clause.AssignmentColumns([]string{"value", "band", "formula_version", "updated_at"}), + Columns: []clause.Column{{Name: "tenant_id"}, {Name: "day"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "value", "band", "formula_version", "updated_at", "ale_xaf", "critical_risks", "compliance_pct", + }), }).Create(s).Error } diff --git a/migrations/0072_score_snapshot_figures.down.sql b/migrations/0072_score_snapshot_figures.down.sql new file mode 100644 index 00000000..1e40e7d9 --- /dev/null +++ b/migrations/0072_score_snapshot_figures.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE tenant_score_snapshots DROP COLUMN IF EXISTS compliance_pct; +ALTER TABLE tenant_score_snapshots DROP COLUMN IF EXISTS critical_risks; +ALTER TABLE tenant_score_snapshots DROP COLUMN IF EXISTS ale_xaf; diff --git a/migrations/0072_score_snapshot_figures.up.sql b/migrations/0072_score_snapshot_figures.up.sql new file mode 100644 index 00000000..83053f53 --- /dev/null +++ b/migrations/0072_score_snapshot_figures.up.sql @@ -0,0 +1,9 @@ +-- #903 — the executive view's quarter-on-quarter deltas. +-- +-- The daily snapshot keeps, next to the exposure score, the annualised +-- exposure (XAF), the live critical risks and the average compliance +-- coverage. Nullable: a figure whose source failed that day is a gap. + +ALTER TABLE tenant_score_snapshots ADD COLUMN IF NOT EXISTS ale_xaf double precision; +ALTER TABLE tenant_score_snapshots ADD COLUMN IF NOT EXISTS critical_risks integer; +ALTER TABLE tenant_score_snapshots ADD COLUMN IF NOT EXISTS compliance_pct double precision; From e2d2fe76d90ef89dd55e2bccdc7cb97782b3cca3 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Fri, 9 Oct 2026 07:58:56 +0100 Subject: [PATCH 3/4] fix(governance): save the deferrals list when updating a request (#903) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UpdateRequest writes a fixed set of columns and deferrals was not among them. POST /governance/approvals/:id/defer answered 200 and the audit trail recorded one deferral, but the row kept an empty list, so the executive view never showed "Reportée le …" after a reload. The use-case tests ran on a fake repository and could not see it; a repository test now saves a deferral and reads it back. Signed-off-by: alex-dembele --- .../approval_deferral_repository_test.go | 66 +++++++++++++ .../repository/gorm_governance_repository.go | 1 + .../features/analytics/executiveService.ts | 94 ------------------- .../src/features/analytics/useExecutive.ts | 16 ---- 4 files changed, 67 insertions(+), 110 deletions(-) create mode 100644 backend/internal/infrastructure/repository/approval_deferral_repository_test.go delete mode 100644 frontend/src/features/analytics/executiveService.ts delete mode 100644 frontend/src/features/analytics/useExecutive.ts diff --git a/backend/internal/infrastructure/repository/approval_deferral_repository_test.go b/backend/internal/infrastructure/repository/approval_deferral_repository_test.go new file mode 100644 index 00000000..d62c1df8 --- /dev/null +++ b/backend/internal/infrastructure/repository/approval_deferral_repository_test.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package repository + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/opendefender/openrisk/internal/domain" +) + +// "Reporter au prochain comité" (#903) appends a dated note to a pending +// request and saves it through UpdateRequest. The use-case tests run on a fake +// repository, so they never saw that the update wrote a fixed column list +// without deferrals: the API answered 200, the audit trail said one deferral, +// and the row stayed empty. +func TestUpdateRequest_PersistsDeferrals(t *testing.T) { + ctx := context.Background() + repo := NewGormApprovalRepository(newGovernanceDB(t)) + + req := &domain.ApprovalRequest{ + ID: uuid.New(), TenantID: govA, + EntityType: "risk_acceptance", Action: "accept", Title: "accept R-0115", + Status: domain.ApprovalPending, RequestedBy: uuid.New(), + } + if err := repo.CreateRequest(ctx, req); err != nil { + t.Fatalf("create request: %v", err) + } + + at := time.Date(2026, 10, 9, 8, 0, 0, 0, time.UTC) + req.Deferrals = append(req.Deferrals, domain.ApprovalDeferral{ + DeferredBy: uuid.NewString(), DeferredByEmail: "rssi@example.com", + Comment: "budget review first", DeferredAt: at, + }) + if err := repo.UpdateRequest(ctx, req); err != nil { + t.Fatalf("update request: %v", err) + } + + got, err := repo.GetRequestByID(ctx, req.ID, govA) + if err != nil || got == nil { + t.Fatalf("reload request: %v", err) + } + if len(got.Deferrals) != 1 { + t.Fatalf("deferral not persisted: got %d deferrals", len(got.Deferrals)) + } + d := got.Deferrals[0] + if d.Comment != "budget review first" || d.DeferredByEmail != "rssi@example.com" || !d.DeferredAt.Equal(at) { + t.Fatalf("deferral read back wrong: %+v", d) + } + if got.Status != domain.ApprovalPending { + t.Fatalf("a deferral must leave the request pending, got %s", got.Status) + } + + // Another tenant's id pair never matches the row. + other := *req + other.TenantID = govB + if err := repo.UpdateRequest(ctx, &other); err == nil { + t.Fatal("updating with another tenant must fail as not found") + } +} diff --git a/backend/internal/infrastructure/repository/gorm_governance_repository.go b/backend/internal/infrastructure/repository/gorm_governance_repository.go index 2a5cfaee..8a5b6213 100644 --- a/backend/internal/infrastructure/repository/gorm_governance_repository.go +++ b/backend/internal/infrastructure/repository/gorm_governance_repository.go @@ -321,6 +321,7 @@ func (r *GormApprovalRepository) UpdateRequest(ctx context.Context, req *domain. "status": req.Status, "current_step": req.CurrentStep, "decisions": req.Decisions, + "deferrals": req.Deferrals, "resolved_at": req.ResolvedAt, }) if res.Error != nil { diff --git a/frontend/src/features/analytics/executiveService.ts b/frontend/src/features/analytics/executiveService.ts deleted file mode 100644 index 40a9207c..00000000 --- a/frontend/src/features/analytics/executiveService.ts +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// This program is free software: you can redistribute it and/or modify it under -// -// Typed client for the Executive Dashboard API (spec §11 « Tableau de bord -// exécutif »). Mirrors internal/application/dashboard.ExecutiveDashboard — one -// consolidated, tenant-scoped aggregation. Zero `any`. - -import { api } from '../../lib/api'; - -/** An amount in both currencies (XAF is canonical, USD derived). */ -export interface Money { - xaf: number; - usd: number; -} - -export interface FinancialHeadline { - total_ale: Money; - total_ale_worst: Money; - total_risks: number; - quantified_risks: number; -} - -export type KRISeverity = 'ok' | 'warn' | 'critical'; - -export interface KRI { - key: string; - label: string; - value: number; - unit: '' | 'days' | '%'; - severity: KRISeverity; -} - -export interface ExecRisk { - id: string; - title: string; - score: number; - probability: number; // 1..5 band for the heatmap - impact: number; // 1..5 band for the heatmap - criticality: string; - status: string; - lifecycle_phase: string; - ale: Money; -} - -export interface ComplianceCoverage { - framework_id: string; - name: string; - percent: number; - implemented: number; - total: number; -} - -export interface DistributionSlice { - criticality: string; - count: number; -} - -export interface MonthlyRiskPoint { - month: string; // "YYYY-MM" - avg_score: number; - critical: number; - high: number; - total: number; -} - -export interface IncidentTrendPoint { - month: string; // "YYYY-MM" - total: number; - critical: number; - high: number; -} - -/** The single consolidated executive dashboard payload. */ -export interface ExecutiveDashboard { - generated_at: string; - currency: string; - xaf_per_usd: number; - financial: FinancialHeadline; - kris: KRI[]; - top_risks: ExecRisk[]; - risk_trend: MonthlyRiskPoint[]; - risk_distribution: DistributionSlice[]; - compliance: ComplianceCoverage[]; - incident_trend: IncidentTrendPoint[]; -} - -export const executiveService = { - /** One consolidated request backing the whole executive board. */ - get: async (): Promise => { - const res = await api.get('/analytics/executive'); - return res.data; - }, -}; diff --git a/frontend/src/features/analytics/useExecutive.ts b/frontend/src/features/analytics/useExecutive.ts deleted file mode 100644 index 710c3f62..00000000 --- a/frontend/src/features/analytics/useExecutive.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only -// This program is free software: you can redistribute it and/or modify it under - -import { useQuery } from '@tanstack/react-query'; -import { executiveService } from './executiveService'; - -/** The consolidated executive dashboard (spec §11). Refetches every 60 s so a - * board left open stays current without hammering the API. */ -export function useExecutiveDashboard() { - return useQuery({ - queryKey: ['analytics', 'executive'], - queryFn: executiveService.get, - refetchInterval: 60_000, - }); -} From ef6992f86daea6aa43092478d2ee1c03dca869dc Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Fri, 9 Oct 2026 07:59:42 +0100 Subject: [PATCH 4/4] feat(executive): rebuild the executive view on the October 2026 redesign (#903) The page a risk committee reads, laid out as the mockup: eyebrow with the committee and quarter, Copier le lien and Exporter le rapport (board report, then the reports page), four headline figures with their movement since last quarter, the five largest exposures with inherent and targeted residual bars, compliance by framework, and the decisions the committee owes. Deltas compare today with the last daily snapshot before the quarter began; with none, the tile says there is no reading yet instead of inventing a movement. Decisions are the tenant's pending approvals. Approuver signs the open step, Reporter adds a dated note and moves the request to the bottom. Both are optimistic and roll back on refusal, and neither retries: a refused signature is final, and the app-wide mutation retry sent it four times. A request's own summary is its sub-line when the requester wrote one. Every block has its skeleton, error with retry and empty state. The old analytics service this page used is removed; nothing else imported it. Signed-off-by: alex-dembele --- .../features/analytics/ExecutiveDashboard.tsx | 1032 ++++++++--------- .../__tests__/committeeDecisions.test.tsx | 165 +++ .../src/features/dashboard/useScoreHistory.ts | 9 + .../features/financial/financialService.ts | 2 + .../features/governance/governanceService.ts | 14 + .../__tests__/emptyTenantSurfaces.test.tsx | 25 +- frontend/src/locales/en.json | 55 + frontend/src/locales/fr.json | 55 + 8 files changed, 797 insertions(+), 560 deletions(-) create mode 100644 frontend/src/features/analytics/__tests__/committeeDecisions.test.tsx diff --git a/frontend/src/features/analytics/ExecutiveDashboard.tsx b/frontend/src/features/analytics/ExecutiveDashboard.tsx index 249f097c..a270ddce 100644 --- a/frontend/src/features/analytics/ExecutiveDashboard.tsx +++ b/frontend/src/features/analytics/ExecutiveDashboard.tsx @@ -1,602 +1,556 @@ // Copyright (c) 2026 OpenDefender Contributors // SPDX-License-Identifier: AGPL-3.0-only -// This program is free software: you can redistribute it and/or modify it under // -// Executive dashboard (spec §11 « Tableau de bord exécutif ») — a board-level view -// of the whole security posture: the security score, financial exposure, key risk -// indicators, the top-10 risks, risk & incident trends and compliance coverage. -// Every figure but the score comes from ONE consolidated request -// (GET /analytics/executive) — no fixtures. The score is the canonical tenant -// score (GET /score, shared query key): this page used to draw an A–F "cyber -// score" from a second formula that disagreed with the sidebar (#287). Charts follow the project's dc.html tokens: reserved status colours for -// severity (with labels/legend), one hue for magnitude, ink tokens for text, and -// they render in light + dark. +// The executive view of the October 2026 redesign (#903): what a risk +// committee reads in one page. +// +// Four headline figures with their movement since last quarter, the five +// largest exposures (inherent and targeted residual), compliance by +// framework, and the decisions the committee owes, with Approve and Defer. +// +// Deltas compare today with the last daily snapshot taken before the current +// quarter began (/score/history quarter_baseline). With no such snapshot the +// tile says so instead of printing a movement nobody measured. -import { localeTag, type LocaleCode } from '../../i18n/locales'; -import { useMemo } from 'react'; -import { useNavigate } from 'react-router'; +import { useMemo, useState } from 'react'; +import { Link, useNavigate } from 'react-router'; +import { Download, Link2 } from 'lucide-react'; import { toast } from 'sonner'; -import { CartesianChart, RadarChart } from '../../shared/ds/charts'; -import { - ShieldCheck, - RefreshCw, - Coins, - AlertTriangle, - Bug, - Activity, - TrendingUp, - type LucideIcon, -} from 'lucide-react'; -import { PageFrame, PageHeader, Card, Btn, Skeleton, ErrorState } from '../../shared/ui'; -import { softFill } from '../../shared/riskColors'; -import { useUIStore } from '../../store/uiStore'; -import { useExecutiveDashboard } from './useExecutive'; -import { useScore } from '../../hooks/useScore'; -import { ScoreGauge } from '../../shared/ScoreGauge'; -import type { - ExecutiveDashboard as ExecData, - KRI, - ExecRisk, - ComplianceCoverage, - MonthlyRiskPoint, - IncidentTrendPoint, - DistributionSlice, -} from './executiveService'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; -/* ---------------- colours & formatters ---------------- */ +import { useI18n } from '../../hooks/useI18n'; +import { useScore } from '../../hooks/useScore'; +import { formatDate, formatNumber } from '../../i18n/format'; +import { PageFrame, PageHeader } from '../../shared/ui'; +import { Button } from '../../shared/ds'; +import { bandTextColor } from '../../services/scoreService'; +import { complianceService } from '../../services/complianceService'; +import { boardService } from '../../services/boardService'; +import { useFeature } from '../billing/useEntitlements'; +import { useFinancialSummary } from '../financial/useFinancial'; +import { currencyLabel, millions, toDisplay } from '../financial/money'; +import { useDashboardStats } from '../dashboard/useCommandCenter'; +import { useScoreHistory } from '../dashboard/useScoreHistory'; +import { frameworkColorFor } from '../compliance/complianceOverview'; +import { governanceService, type ApprovalRequest } from '../governance/governanceService'; +import { + BlockEmpty, + BlockError, + BlockSkeleton, + Panel, + PanelTitle, +} from '../dashboard/console/Panel'; -const CRIT: Record = { - critical: 'var(--critical)', - high: 'var(--high)', - medium: 'var(--medium)', - low: 'var(--low)', -}; -const SEV_COLOR: Record = { - critical: 'var(--critical)', - warn: 'var(--high)', - ok: 'var(--low)', +const ALL_TIME = { kind: 'preset', preset: 'all' } as const; +const BAND_FILL: Record = { + critical: 'var(--risk-critical)', + high: 'var(--risk-high)', + medium: 'var(--risk-moderate)', + low: 'var(--risk-low)', }; -function fmtInt(n: number, lang: LocaleCode): string { - return Math.round(n).toLocaleString(localeTag(lang)); +function quarterOf(d: Date) { + return Math.floor(d.getMonth() / 3) + 1; } -function fmtCompactFCFA(n: number, lang: LocaleCode): string { - const abs = Math.abs(n); - const u = lang === 'fr' ? { b: ' Md', m: ' M', k: ' k' } : { b: 'B', m: 'M', k: 'K' }; - const f = (v: number) => (lang === 'fr' ? v.toFixed(1).replace('.', ',') : v.toFixed(1)); - if (abs >= 1e9) return `${f(n / 1e9)}${u.b} FCFA`; - if (abs >= 1e6) return `${f(n / 1e6)}${u.m} FCFA`; - if (abs >= 1e3) return `${f(n / 1e3)}${u.k} FCFA`; - return `${Math.round(n)} FCFA`; -} -/** "2026-07" → "07/26" (compact, locale-neutral). */ -function monthLabel(m: string): string { - const [y, mo] = m.split('-'); - return y && mo ? `${mo}/${y.slice(2)}` : m; -} -/* ---------------- page ---------------- */ export function ExecutiveDashboard() { - const lang = useUIStore((s) => s.lang); - const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); + const { t } = useI18n(); const navigate = useNavigate(); - const { data, isLoading, isError, refetch, isFetching } = useExecutiveDashboard(); - // Called before the early returns: hooks must run in the same order every render. - const tenantScore = useScore('tenant'); + const now = new Date(); + const q = quarterOf(now); - if (isLoading) return ; - if (isError || !data) { - return ( - - - refetch()} - retryLabel={tr('Réessayer', 'Retry')} - /> - - ); - } - - const gen = new Date(data.generated_at).toLocaleString(localeTag(lang), { - dateStyle: 'medium', - timeStyle: 'short', + const exportReport = useMutation({ + mutationFn: () => + boardService.generate({ + period_label: t('executive.eyebrow', { q, year: now.getFullYear() }), + }), + onSuccess: () => { + toast.success(t('executive.exported')); + navigate('/reports/board'); + }, + onError: () => toast.error(t('executive.exportFailed')), }); + const copyLink = async () => { + try { + await navigator.clipboard.writeText(window.location.href); + toast.success(t('executive.linkCopied')); + } catch { + toast.error(t('executive.linkFailed')); + } + }; + return ( - + svg]:animate-spin' : ''} - onClick={async () => { - const res = await refetch(); - if (res.error) toast.error(tr('Actualisation impossible', 'Could not refresh')); - else toast.success(tr('Tableau de bord à jour', 'Dashboard up to date')); - }} - /> + <> + + + } /> - {/* row 1 — security score + financial + KRIs */} -
- navigate('/score')} - /> -
- - -
-
+ - {/* row 2 — risk trend + distribution */} -
- - +
+ +
- {/* row 3 — top risks + control coverage radar */} -
- - -
- - {/* row 4 — compliance donuts + incident trend */} -
- - -
+ ); } -/* ---------------- Financial exposure ---------------- */ -function FinancialCard({ - data, - lang, - tr, -}: { - data: ExecData; - lang: LocaleCode; - tr: (f: string, e: string) => string; -}) { - const f = data.financial; - return ( - -
-
-
- - {tr('Exposition financière annuelle (ALE)', 'Annual financial exposure (ALE)')} -
-
- {fmtCompactFCFA(f.total_ale.xaf, lang)} -
-
- {tr('Pire cas', 'Worst case')} :{' '} - - {fmtCompactFCFA(f.total_ale_worst.xaf, lang)} - - {' · '}${fmtInt(f.total_ale.usd, 'en')} -
-
-
-
- {f.quantified_risks}/{f.total_risks} -
-
- {tr('risques quantifiés', 'quantified risks')} -
-
-
-
- ); +/* ------------------------------------------------------------- KPI strip */ + +interface Delta { + text: string; + color: string; } -/* ---------------- KRI strip ---------------- */ -const KRI_ICON: Record = { - open_vulns: Bug, - kev_exploited: AlertTriangle, - critical_vulns: Bug, - critical_risks: ShieldCheck, - open_incidents: Activity, - avg_mttr_days: TrendingUp, - compliance_coverage: ShieldCheck, -}; -function KriStrip({ kris, lang }: { kris: KRI[]; lang: LocaleCode }) { - if (kris.length === 0) return null; +function KpiStrip() { + const { t, locale } = useI18n(); + const score = useScore('tenant'); + const history = useScoreHistory(12); + const stats = useDashboardStats(ALL_TIME); + const fin = useFeature('financial_quantification'); + const finance = useFinancialSummary(); + const gaps = useQuery({ + queryKey: ['compliance', 'gap-analysis', 'all'], + queryFn: () => complianceService.getGapAnalysis(), + }); + + const base = history.data?.quarter_baseline ?? null; + const baseQ = base ? base.quarter.split('-Q')[1] : null; + const measured = score.data?.measured ? score.data : undefined; + const fw = gaps.data?.frameworks ?? []; + const coverage = fw.length + ? fw.reduce((a, f) => a + (f.percent_complete ?? 0), 0) / fw.length + : undefined; + const locked = !fin.loading && !fin.enabled; + const f = !locked ? finance.data : undefined; + const ale = f ? toDisplay(f.total_ale.xaf, f.fx_rate_xaf) : undefined; + const baseAle = f && base?.ale_xaf != null ? toDisplay(base.ale_xaf, f.fx_rate_xaf) : undefined; + + const fmt1 = (n: number) => formatNumber(locale, Math.abs(n), { maximumFractionDigits: 1 }); + const signed = (n: number, text: string) => `${n > 0 ? '+' : n < 0 ? '−' : ''}${text}`; + + // A falling exposure, fewer critical risks and more coverage are good news. + const delta = ( + cur: number | undefined, + prev: number | null | undefined, + lowerIsBetter: boolean, + unit: (n: number) => string, + ): Delta => { + if (cur === undefined || prev === undefined || prev === null || !baseQ) { + return { text: t('executive.kpi.noBaseline'), color: 'var(--fg-muted)' }; + } + const d = cur - prev; + const good = lowerIsBetter ? d <= 0 : d >= 0; + return { + text: `${signed(d, unit(d))} ${t('executive.kpi.sinceQuarter', { q: baseQ })}`, + color: d === 0 ? 'var(--fg-secondary)' : good ? 'var(--success-text)' : 'var(--danger-text)', + }; + }; + + const tiles = [ + { + key: 'score', + label: t('executive.kpi.score'), + value: measured ? String(Math.round(measured.value)) : '—', + unit: '/100', + color: measured ? bandTextColor(measured.band) : 'var(--fg-muted)', + delta: delta(measured?.value, base?.value, true, (d) => + t('executive.kpi.pts', { delta: fmt1(d) }), + ), + loading: score.isLoading, + }, + { + key: 'ale', + label: t('executive.kpi.ale'), + value: ale !== undefined ? millions(ale, locale) : '—', + unit: `M ${currencyLabel(f?.currency)}`, + color: 'var(--fg-primary)', + delta: locked + ? { text: t('executive.kpi.locked', { plan: 'Business' }), color: 'var(--fg-muted)' } + : delta(ale, baseAle, true, (d) => `${millions(Math.abs(d), locale)} M`), + loading: !locked && (finance.isLoading || fin.loading), + }, + { + key: 'critical', + label: t('executive.kpi.critical'), + value: stats.data ? String(stats.data.critical_live) : '—', + unit: t('executive.kpi.criticalUnit'), + color: 'var(--risk-critical)', + delta: delta(stats.data?.critical_live, base?.critical_risks, true, (d) => + String(Math.abs(d)), + ), + loading: stats.isLoading, + }, + { + key: 'compliance', + label: t('executive.kpi.compliance'), + value: coverage !== undefined ? String(Math.round(coverage)) : '—', + unit: '%', + color: 'var(--fg-primary)', + delta: delta(coverage, base?.compliance_pct, false, (d) => + t('executive.kpi.pts', { delta: fmt1(d) }), + ), + loading: gaps.isLoading, + }, + ]; + return ( -
- {kris.map((k) => { - const col = SEV_COLOR[k.severity] ?? 'var(--accent)'; - const Icon = KRI_ICON[k.key] ?? Activity; - const val = - k.unit === '%' - ? `${fmtInt(k.value, lang)} %` - : k.unit === 'days' - ? `${k.value.toLocaleString(localeTag(lang), { maximumFractionDigits: 1 })} ${lang === 'fr' ? 'j' : 'd'}` - : fmtInt(k.value, lang); - return ( - -
+
+ {tiles.map((k) => ( +
+
+ {k.label} +
+ {k.loading ? ( +
+ ) : ( +
- + {k.value} + {k.unit}
-
{val}
-
{k.label}
- - ); - })} + )} +
+ {k.delta.text} +
+
+ ))}
); } -/* ---------------- Risk trend (global risk level over time) ---------------- */ -function RiskTrendCard({ - points, - tr, -}: { - points: MonthlyRiskPoint[]; - tr: (f: string, e: string) => string; -}) { - const rows = points.map((p) => ({ ...p, m: monthLabel(p.month) })); - return ( - -
- {tr('Évolution du niveau de risque', 'Risk level trend')} -
-
- {tr('Score de risque moyen du registre', 'Register average risk score')} -
- {rows.length === 0 ? ( - - ) : ( - - )} -
- ); -} +/* ---------------------------------------------------------- top 5 risks */ + +function TopExposures({ className = '' }: { className?: string }) { + const { t, locale } = useI18n(); + const fin = useFeature('financial_quantification'); + const finance = useFinancialSummary(); + const f = finance.data; + const top = (f?.top_risks ?? []).filter((r) => r.ale.xaf > 0).slice(0, 5); + const max = Math.max(1, ...top.map((r) => r.ale.xaf)); + const m = (xaf: number) => millions(toDisplay(xaf, f?.fx_rate_xaf), locale); -/* ---------------- Risk distribution donut ---------------- */ -type PieRow = { criticality: string; count: number; [k: string]: string | number }; -function RiskDistributionCard({ - slices, - lang, - tr, -}: { - slices: DistributionSlice[]; - lang: LocaleCode; - tr: (f: string, e: string) => string; -}) { - const data: PieRow[] = slices - .filter((s) => s.count > 0) - .map((s) => ({ criticality: s.criticality, count: s.count })); - const total = slices.reduce((a, s) => a + s.count, 0); - const label = (c: string) => - ({ - critical: tr('Critique', 'Critical'), - high: tr('Élevé', 'High'), - medium: tr('Moyen', 'Medium'), - low: tr('Faible', 'Low'), - })[c] ?? c; return ( - -
- {tr('Répartition par criticité', 'Distribution by criticality')} -
- {total === 0 ? ( - + + + {!fin.loading && !fin.enabled ? ( + {t('dashboard.exposure.locked', { plan: 'Business' })} + ) : finance.isLoading || fin.loading ? ( + + ) : finance.isError || !f ? ( + void finance.refetch()} /> + ) : top.length === 0 ? ( + {t('executive.top.empty')} ) : ( -
- {/* D-025: this was a doughnut with the total in the middle, across four - slices. That is the pattern #444's anti-cliché table bans RingChart - for — "a number in the middle that a KPI tile would say better" — - and four slices is past the three-slice pie limit. The total is now - the KPI figure it always was, and the split is bars, which people - compare by length instead of by angle. */} -
- - {fmtInt(total, lang)} - - {tr('risques', 'risks')} + <> +
+ {top.map((r) => { + const color = BAND_FILL[r.criticality.toLowerCase()] ?? 'var(--fg-muted)'; + const after = r.ale_after?.xaf ?? r.ale.xaf; + return ( + +
+ #{r.id.slice(0, 8)} + {r.title} + + {t('executive.top.value', { value: m(r.ale.xaf) })} + +
+
+ + +
+ + ); + })}
- -
+
+ {t('executive.top.inherent')} + {t('executive.top.residual')} +
+ )} -
- {slices.map((s) => ( - - - {label(s.criticality)} · {s.count} - - ))} -
- + ); } -/* ---------------- Top 10 risks ---------------- */ -function TopRisksCard({ - risks, - lang, - tr, -}: { - risks: ExecRisk[]; - lang: LocaleCode; - tr: (f: string, e: string) => string; -}) { - return ( - -
- {tr('Top 10 des risques', 'Top 10 risks')} -
- {risks.length === 0 ? ( - - ) : ( - // The table is wider than a phone, so this box scrolls at 393px and a - // keyboard user could not reach it (axe scrollable-region-focusable, - // #589). tabIndex makes it reachable; role + name stop it from being an - // anonymous tab stop that announces nothing. -
- - - - - - - - - - - {risks.map((r) => { - const col = CRIT[r.criticality] ?? 'var(--fg-muted)'; - return ( - - - - - - - ); - })} - -
{tr('Risque', 'Risk')}{tr('Criticité', 'Criticality')}{tr('Score', 'Score')}ALE
-
- - - {r.title} - -
-
- - {r.criticality} - - - {r.score.toFixed(1)} - - {r.ale.xaf > 0 ? fmtCompactFCFA(r.ale.xaf, lang) : '—'} -
-
- )} -
- ); -} +/* ------------------------------------------------------ compliance bars */ -/* ---------------- Control coverage radar ---------------- */ -function ControlCoverageRadar({ - frameworks, - tr, -}: { - frameworks: ComplianceCoverage[]; - tr: (f: string, e: string) => string; -}) { - const data = frameworks.slice(0, 8).map((f) => ({ name: f.name, percent: f.percent })); +function FrameworkBars({ className = '' }: { className?: string }) { + const { t } = useI18n(); + const gaps = useQuery({ + queryKey: ['compliance', 'gap-analysis', 'all'], + queryFn: () => complianceService.getGapAnalysis(), + }); + const fw = gaps.data?.frameworks ?? []; return ( - -
- {tr('Couverture des contrôles', 'Control coverage')} -
-
- {tr('% implémenté par référentiel', '% implemented per framework')} -
- {data.length === 0 ? ( - + + + {gaps.isLoading ? ( + + ) : gaps.isError ? ( + void gaps.refetch()} /> + ) : fw.length === 0 ? ( + {t('executive.fw.empty')} ) : ( - ({ key: d.name, label: d.name }))} - series={[ - { - key: 'coverage', - label: tr('Couverture', 'Coverage'), - values: data.map((d) => d.percent), - }, - ]} - max={100} - size={230} - formatValue={(v) => `${v} %`} - ariaLabel={tr( - 'Pourcentage de contrôles implémentés par référentiel', - 'Percentage of controls implemented per framework', - )} - /> + <> +
+ {fw.map((f, i) => { + const pct = Math.round(f.percent_complete ?? 0); + return ( + + + {shortName(f.framework_name)} + + + + + {pct} % + + ); + })} +
+
{t('executive.fw.note')}
+ )} -
+ ); } -/* ---------------- Compliance donuts per framework ---------------- */ -/* `Ring` — a 72px progress ring with the percentage in the middle, eight of them - in a grid — was deleted under D-025. It is the same banned pattern as the - distribution doughnut ("a number in the middle that a KPI tile would say - better"), and eight of them side by side asked the reader to compare eight - arc lengths. The comparison across frameworks IS the reading, so it is now - one bar chart: same data, one axis, ranked by eye in a glance. */ -function ComplianceCard({ - frameworks, - tr, -}: { - frameworks: ComplianceCoverage[]; - tr: (f: string, e: string) => string; -}) { - return ( - -
- {tr('Conformité par référentiel', 'Compliance by framework')} -
- {frameworks.length === 0 ? ( - - ) : ( - `${Math.round(v)}%`} - ariaLabel={tr( - 'Pourcentage de conformité par référentiel', - 'Compliance percentage per framework', - )} - /> - )} -
- ); +/** + * "ISO/IEC 27001:2022" → "ISO 27001", "NIST CSF 2.0" → "NIST CSF", + * "COBAC R-2016/04" → "COBAC", "BCEAO — Sécurité des SI" → "BCEAO". + */ +function shortName(name: string): string { + const iso = name.match(/ISO(?:\/IEC)?\s*(\d{4,5})/i); + if (iso) return `ISO ${iso[1]}`; + const head = name.split(/\s[—–-]\s|:/)[0].trim(); + // Drop a trailing version or regulation number, never the whole name. + return head.replace(/\s+\S*\d\S*$/, '').trim() || head; } -/* ---------------- Incident trend histogram ---------------- */ -function IncidentTrendCard({ - points, - tr, -}: { - points: IncidentTrendPoint[]; - tr: (f: string, e: string) => string; -}) { - const rows = useMemo( - () => - points.map((p) => ({ - m: monthLabel(p.month), - critical: p.critical, - high: p.high, - other: Math.max(0, p.total - p.critical - p.high), - })), - [points], - ); - const hasData = points.some((p) => p.total > 0); - return ( - -
- {tr('Tendance des incidents', 'Incident trend')} -
-
- {tr('Volume mensuel par sévérité', 'Monthly volume by severity')} -
- {!hasData ? ( - - ) : ( - - )} -
- ); -} +/* ------------------------------------------------- committee decisions */ -/* ---------------- shared small bits ---------------- */ -function ChartEmpty({ label }: { label: string }) { - return ( -
- {label} -
- ); -} +const APPROVALS_KEY = ['governance', 'approvals', 'pending']; + +function CommitteeDecisions() { + const { t, locale } = useI18n(); + const qc = useQueryClient(); + const approvals = useQuery({ + queryKey: APPROVALS_KEY, + queryFn: () => governanceService.listApprovals({ status: 'pending' }), + }); + const [approved, setApproved] = useState>(new Set()); + + const onError = (e: unknown) => { + const forbidden = isAxiosError(e) && e.response?.status === 403; + toast.error(forbidden ? t('executive.decisions.forbidden') : t('executive.decisions.failed')); + }; + // Both are optimistic: the row changes on click and rolls back if the + // server refuses. retry:false — a refused signature is a final answer, and + // the app-wide mutation retry would send it four times. + const approve = useMutation({ + mutationFn: (id: string) => governanceService.decideApproval(id, { decision: 'approve' }), + retry: false, + onMutate: (id) => setApproved((s) => new Set(s).add(id)), + onSuccess: () => { + toast.success(t('executive.decisions.done')); + void qc.invalidateQueries({ queryKey: ['action-center'] }); + }, + onError: (e, id) => { + setApproved((s) => { + const next = new Set(s); + next.delete(id); + return next; + }); + onError(e); + }, + }); + const defer = useMutation({ + mutationFn: (id: string) => governanceService.deferApproval(id), + retry: false, + onMutate: async (id) => { + await qc.cancelQueries({ queryKey: APPROVALS_KEY }); + const prev = qc.getQueryData(APPROVALS_KEY); + qc.setQueryData(APPROVALS_KEY, (rows) => + rows?.map((r) => + r.id === id + ? { + ...r, + deferrals: [ + ...(r.deferrals ?? []), + { deferred_by: '', deferred_at: new Date().toISOString() }, + ], + } + : r, + ), + ); + return { prev }; + }, + onSuccess: () => toast.success(t('executive.decisions.deferred')), + onError: (e, _id, ctx) => { + if (ctx?.prev) qc.setQueryData(APPROVALS_KEY, ctx.prev); + onError(e); + }, + onSettled: () => void qc.invalidateQueries({ queryKey: APPROVALS_KEY }), + }); + + // Deferred requests stay pending but go to the bottom, as the design shows. + const list = useMemo(() => { + const rows = Array.isArray(approvals.data) ? approvals.data : []; + const deferred = (r: ApprovalRequest) => (r.deferrals?.length ?? 0) > 0; + return [...rows].sort((a, b) => Number(deferred(a)) - Number(deferred(b))); + }, [approvals.data]); + const waiting = list.filter((r) => !approved.has(r.id) && !(r.deferrals?.length ?? 0)).length; -function ExecSkeleton() { return ( - - -
- -
- -
- {Array.from({ length: 4 }).map((_, i) => ( - - ))} -
+ + + {t('executive.decisions.pending', { count: waiting })} + + ) : undefined + } + /> + {approvals.isLoading ? ( +
+
-
-
- - -
-
- - -
- + ) : approvals.isError ? ( +
+ void approvals.refetch()} /> +
+ ) : list.length === 0 ? ( +
+ {t('executive.decisions.empty')} +
+ ) : ( + list.map((r) => { + const last = r.deferrals?.[r.deferrals.length - 1]; + const done = approved.has(r.id); + return ( +
+ + {r.title} + + {/* The requester's own summary reads like the design ("Exposition + 2,1 M FCFA/an…"); without one, say what it is and who asked. */} + {r.description?.trim() || + t('executive.decisions.sub', { + type: r.workflow_name || r.request_type || r.entity_type, + who: r.requested_by_email || '—', + })} + {r.expires_at && + ` · ${t('executive.decisions.expires', { + date: formatDate(locale, r.expires_at, { day: 'numeric', month: 'short' }), + })}`} + + + {done ? ( + + {t('executive.decisions.approved')} + + ) : ( + <> + {last ? ( + + {t('executive.decisions.deferredOn', { + date: formatDate(locale, last.deferred_at, { + day: 'numeric', + month: 'short', + }), + })} + + ) : ( + + )} + + + )} +
+ ); + }) + )} + ); } diff --git a/frontend/src/features/analytics/__tests__/committeeDecisions.test.tsx b/frontend/src/features/analytics/__tests__/committeeDecisions.test.tsx new file mode 100644 index 00000000..779a5d79 --- /dev/null +++ b/frontend/src/features/analytics/__tests__/committeeDecisions.test.tsx @@ -0,0 +1,165 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// +// "Décisions attendues du comité" on the executive view (#903). +// +// What the live pass caught and these pin down: +// - Reporter shows the dated note at once and rolls back if refused; +// - a refused signature is sent once, not four times through the app-wide +// mutation retry, and the row gets its buttons back; +// - the requester's own summary is the sub-line when there is one. +// +// The server is mocked at the HTTP boundary. + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor, fireEvent, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +import { useUIStore } from '../../../store/uiStore'; +import type { ApprovalRequest } from '../../governance/governanceService'; + +const posts = vi.fn<(url: string) => Promise<{ data: unknown }>>(); +const toastError = vi.hoisted(() => vi.fn()); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: toastError } })); + +function forbidden() { + return Promise.reject( + Object.assign(new Error('forbidden'), { + isAxiosError: true, + response: { status: 403, data: {} }, + }), + ); +} + +function request(id: string, title: string, description = ''): ApprovalRequest { + return { + id, + tenant_id: 't', + entity_type: 'risk_acceptance', + title, + description, + status: 'pending', + current_step: 0, + steps: [], + decisions: [], + deferrals: [], + requested_by: 'u', + requested_by_email: 'rssi@example.com', + workflow_name: 'Acceptation de risque', + mode: 'sequential', + created_at: '2026-10-01T09:00:00Z', + }; +} + +let pending: ApprovalRequest[] = []; + +vi.mock('../../../lib/api', () => ({ + api: { + get: (url: string) => { + if (url.startsWith('/governance/approvals')) return Promise.resolve({ data: pending }); + return Promise.resolve({ data: {} }); + }, + post: (url: string) => posts(url), + interceptors: { request: { use: () => 0 }, response: { use: () => 0 } }, + }, + onMFARequired: () => () => undefined, +})); + +import { ExecutiveDashboard } from '../ExecutiveDashboard'; + +function renderView() { + // The app's own mutation policy (main.tsx): three retries. + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: 3, retryDelay: 0 } }, + }); + return render( + + + + + , + ); +} + +async function row(title: string) { + const rows = await screen.findAllByTestId('exec-decision'); + const r = rows.find((el) => el.textContent?.includes(title)); + if (!r) throw new Error(`no row ${title}`); + return within(r); +} + +beforeEach(() => { + posts.mockReset(); + toastError.mockReset(); + useUIStore.setState({ lang: 'fr' }); + pending = [ + request( + 'a1', + 'Acceptation du risque R-0115', + 'Exposition 2,1 M FCFA/an, sous le seuil de tolérance', + ), + request('b2', 'Budget du PRA (M-284)'), + ]; +}); + +describe('#903 — committee decisions', () => { + it('uses the requester summary as the sub-line, else type and requester', async () => { + renderView(); + expect( + (await row('R-0115')).getByText('Exposition 2,1 M FCFA/an, sous le seuil de tolérance'), + ).toBeInTheDocument(); + expect( + (await row('M-284')).getByText(/Acceptation de risque · demandée par rssi@example.com/), + ).toBeInTheDocument(); + }); + + it('Reporter shows the dated note at once', async () => { + let resolve: (v: { data: unknown }) => void = () => undefined; + posts.mockImplementation(() => new Promise((r) => (resolve = r))); + renderView(); + fireEvent.click((await row('M-284')).getByRole('button', { name: 'Reporter' })); + + await waitFor(async () => + expect((await row('M-284')).getByText(/Reportée le/)).toBeInTheDocument(), + ); + expect(posts).toHaveBeenCalledWith('/governance/approvals/b2/defer'); + resolve({ data: {} }); + }); + + it('a refused deferral rolls back and is sent once', async () => { + posts.mockImplementation(forbidden); + renderView(); + fireEvent.click((await row('M-284')).getByRole('button', { name: 'Reporter' })); + + await waitFor(() => + expect(toastError).toHaveBeenCalledWith('Vous ne pouvez pas signer cette étape.'), + ); + expect((await row('M-284')).getByRole('button', { name: 'Reporter' })).toBeInTheDocument(); + expect(posts).toHaveBeenCalledTimes(1); + }); + + it('a refused signature is sent once and the buttons come back', async () => { + posts.mockImplementation(forbidden); + renderView(); + fireEvent.click((await row('R-0115')).getByRole('button', { name: 'Approuver' })); + + await waitFor(() => + expect(toastError).toHaveBeenCalledWith('Vous ne pouvez pas signer cette étape.'), + ); + const r = await row('R-0115'); + expect(r.getByRole('button', { name: 'Approuver' })).toBeInTheDocument(); + expect(r.queryByText('Approuvé')).toBeNull(); + expect(posts).toHaveBeenCalledTimes(1); + expect(posts).toHaveBeenCalledWith('/governance/approvals/a1/decide'); + }); + + it('an accepted signature marks the row approved', async () => { + posts.mockResolvedValue({ data: {} }); + renderView(); + fireEvent.click((await row('R-0115')).getByRole('button', { name: 'Approuver' })); + expect(await (await row('R-0115')).findByText('Approuvé')).toBeInTheDocument(); + expect(posts).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/features/dashboard/useScoreHistory.ts b/frontend/src/features/dashboard/useScoreHistory.ts index ead1cb33..b07211b9 100644 --- a/frontend/src/features/dashboard/useScoreHistory.ts +++ b/frontend/src/features/dashboard/useScoreHistory.ts @@ -21,6 +21,15 @@ export interface ScoreHistory { current: number | null; delta_30d: number | null; since: string | null; + /** Last snapshot before the current quarter, for "depuis le T3" (#903). */ + quarter_baseline: { + quarter: string; + day: string; + value: number; + ale_xaf: number | null; + critical_risks: number | null; + compliance_pct: number | null; + } | null; } export function useScoreHistory(months = 12) { diff --git a/frontend/src/features/financial/financialService.ts b/frontend/src/features/financial/financialService.ts index 50b0431d..c5e22fab 100644 --- a/frontend/src/features/financial/financialService.ts +++ b/frontend/src/features/financial/financialService.ts @@ -110,6 +110,8 @@ export interface TopRiskFinancial { criticality: string; ale: Money; ale_worst: Money; + /** Targeted exposure once mitigations are in place (#903). */ + ale_after: Money; rosi: number; rosi_computable: boolean; } diff --git a/frontend/src/features/governance/governanceService.ts b/frontend/src/features/governance/governanceService.ts index f9491aa3..f7a375b6 100644 --- a/frontend/src/features/governance/governanceService.ts +++ b/frontend/src/features/governance/governanceService.ts @@ -239,6 +239,13 @@ export interface ApprovalDecision { decided_at: string; } +export interface ApprovalDeferral { + deferred_by: string; + deferred_by_email?: string; + comment?: string; + deferred_at: string; +} + export interface ApprovalRequest { id: string; tenant_id: string; @@ -254,6 +261,8 @@ export interface ApprovalRequest { current_step: number; steps: WorkflowStep[]; decisions: ApprovalDecision[]; + /** Dated "deferred to the next committee" notes (#903). The request stays pending. */ + deferrals?: ApprovalDeferral[]; requested_by: string; requested_by_email?: string; mode: ApprovalMode; @@ -366,6 +375,11 @@ export const governanceService = { api.post('/governance/approvals', input).then((r) => r.data), decideApproval: (id: string, input: DecideApprovalInput): Promise => api.post(`/governance/approvals/${id}/decide`, input).then((r) => r.data), + /** "Reporter au prochain comité" (#903): a dated note, the request stays pending. */ + deferApproval: (id: string, comment?: string): Promise => + api + .post(`/governance/approvals/${id}/defer`, { comment: comment ?? '' }) + .then((r) => r.data), cancelApproval: (id: string): Promise => api.post(`/governance/approvals/${id}/cancel`, {}).then((r) => r.data), }; diff --git a/frontend/src/features/score/__tests__/emptyTenantSurfaces.test.tsx b/frontend/src/features/score/__tests__/emptyTenantSurfaces.test.tsx index bc5950b3..10a1b6f1 100644 --- a/frontend/src/features/score/__tests__/emptyTenantSurfaces.test.tsx +++ b/frontend/src/features/score/__tests__/emptyTenantSurfaces.test.tsx @@ -18,7 +18,6 @@ import { MemoryRouter } from 'react-router'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { UnmeasuredScore } from '../../../services/scoreService'; -import type { ExecutiveDashboard as ExecData } from '../../analytics/executiveService'; import { useUIStore } from '../../../store/uiStore'; const EMPTY_SCORE: UnmeasuredScore = { @@ -48,24 +47,6 @@ const EMPTY_SCORE: UnmeasuredScore = { ], }; -const EMPTY_EXEC: ExecData = { - generated_at: '2026-09-25T10:00:00Z', - currency: 'XAF', - xaf_per_usd: 600, - financial: { - total_ale: { xaf: 0, usd: 0 }, - total_ale_worst: { xaf: 0, usd: 0 }, - total_risks: 0, - quantified_risks: 0, - }, - kris: [], - top_risks: [], - risk_trend: [], - risk_distribution: [], - compliance: [], - incident_trend: [], -}; - const scoreCalls = vi.fn(); // The HTTP boundary, answering like a brand-new tenant. Anything a surface asks @@ -76,7 +57,6 @@ vi.mock('../../../lib/api', () => { scoreCalls(); return Promise.resolve({ data: EMPTY_SCORE }); } - if (url === '/analytics/executive') return Promise.resolve({ data: EMPTY_EXEC }); return Promise.resolve({ data: {} }); }; return { @@ -140,7 +120,6 @@ describe('#287 — empty tenant, every score surface agrees', () => { const gauges = [ 'surface-home', - 'surface-exec-analytics', 'surface-score-page', ]; @@ -162,6 +141,10 @@ describe('#287 — empty tenant, every score surface agrees', () => { ); } + // The executive view (#903) shows the figure in its KPI strip: a dash. + const exec = within(screen.getByTestId('surface-exec-analytics')); + expect(exec.getByTestId('exec-kpi-score-value')).toHaveTextContent('—'); + // The dashboard card (#901) has its own layout and the same answer. const dash = within(screen.getByTestId('surface-dashboard')); expect(dash.getByTestId('dash-score-value')).toHaveTextContent('—'); diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 95c0cfc4..16911a21 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -1198,5 +1198,60 @@ }, "retry": "Retry", "loadError": "Could not load this block." + }, + "executive": { + "title": "Executive view", + "eyebrow": "Risk committee · Q{q} {year}", + "copyLink": "Copy link", + "linkCopied": "Link copied", + "linkFailed": "Could not copy the link", + "export": "Export the report", + "exporting": "Generating…", + "exported": "Board report generated", + "exportFailed": "The report could not be generated", + "kpi": { + "score": "Exposure score", + "ale": "Annualised exposure", + "critical": "Critical risks", + "criticalUnit": "open", + "compliance": "Average compliance", + "sinceQuarter": "since Q{q}", + "pts": "{delta} pts", + "noBaseline": "no reading from last quarter yet", + "locked": "{plan} plan" + }, + "top": { + "title": "Top five exposures", + "inherent": "Light bar: inherent exposure", + "residual": "Dark bar: targeted residual", + "empty": "No quantified risk yet.", + "value": "{value} M" + }, + "fw": { + "title": "Compliance by framework", + "note": "Implemented controls over applicable controls · not-applicable excluded", + "empty": "No framework imported." + }, + "decisions": { + "title": "Decisions the committee owes", + "pending": { + "exact": { + "0": "none pending" + }, + "one": "{count} pending", + "other": "{count} pending" + }, + "defer": "Defer", + "approve": "Approve", + "approved": "Approved", + "deferredOn": "Deferred on {date}", + "sub": "{type} · requested by {who}", + "expires": "expires {date}", + "empty": "No decision pending. Approval requests put to the committee show up here.", + "done": "Decision recorded and timestamped in the audit trail", + "deferred": "Deferred to the next committee", + "forbidden": "You cannot sign this step.", + "failed": "The decision could not be recorded." + } } } diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index c0536646..676db505 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -1198,5 +1198,60 @@ }, "retry": "Réessayer", "loadError": "Impossible de charger ce bloc." + }, + "executive": { + "title": "Tableau exécutif", + "eyebrow": "Comité des risques · T{q} {year}", + "copyLink": "Copier le lien", + "linkCopied": "Lien copié", + "linkFailed": "Impossible de copier le lien", + "export": "Exporter le rapport", + "exporting": "Génération…", + "exported": "Rapport au conseil généré", + "exportFailed": "Le rapport n'a pas pu être généré", + "kpi": { + "score": "Score d'exposition", + "ale": "Exposition annualisée", + "critical": "Risques critiques", + "criticalUnit": "ouverts", + "compliance": "Conformité moyenne", + "sinceQuarter": "depuis le T{q}", + "pts": "{delta} pts", + "noBaseline": "pas encore de relevé au trimestre précédent", + "locked": "offre {plan}" + }, + "top": { + "title": "Cinq premières expositions", + "inherent": "Barre claire : exposition inhérente", + "residual": "Barre sombre : résiduelle visée", + "empty": "Aucun risque quantifié pour l'instant.", + "value": "{value} M" + }, + "fw": { + "title": "Conformité par référentiel", + "note": "Contrôles mis en œuvre sur contrôles applicables · hors non applicables", + "empty": "Aucun référentiel importé." + }, + "decisions": { + "title": "Décisions attendues du comité", + "pending": { + "exact": { + "0": "aucune en attente" + }, + "one": "{count} en attente", + "other": "{count} en attente" + }, + "defer": "Reporter", + "approve": "Approuver", + "approved": "Approuvé", + "deferredOn": "Reportée le {date}", + "sub": "{type} · demandée par {who}", + "expires": "expire le {date}", + "empty": "Aucune décision en attente. Les demandes d'approbation soumises au comité apparaissent ici.", + "done": "Décision enregistrée et horodatée dans la piste d'audit", + "deferred": "Reportée au prochain comité", + "forbidden": "Vous ne pouvez pas signer cette étape.", + "failed": "La décision n'a pas pu être enregistrée." + } } }