diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 5bfdf708d..310df6d28 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). @@ -3244,6 +3245,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/cmd/server/score_wiring.go b/backend/cmd/server/score_wiring.go index 3473c5163..6f17493f4 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/governance/approval_defer_test.go b/backend/internal/application/governance/approval_defer_test.go new file mode 100644 index 000000000..ec2b2779e --- /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 34168f567..5834895d6 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/application/risk/financial_summary.go b/backend/internal/application/risk/financial_summary.go index 7cd272e27..4b67c4772 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 3fdcf6cfc..7637fc6ae 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 3f2419216..aa4791229 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/governance.go b/backend/internal/domain/governance.go index 9b925fb11..7c7e24fc0 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/domain/score_snapshot.go b/backend/internal/domain/score_snapshot.go index 472938f1d..bcb900994 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/handler/authz_route_coverage_test.go b/backend/internal/handler/authz_route_coverage_test.go index afa2651d5..de0c288a7 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 a8248a481..0387e42d1 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/backend/internal/infrastructure/repository/approval_deferral_repository_test.go b/backend/internal/infrastructure/repository/approval_deferral_repository_test.go new file mode 100644 index 000000000..d62c1df86 --- /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 2a5cfaee0..8a5b6213d 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/backend/internal/infrastructure/repository/gorm_score_snapshot_repository.go b/backend/internal/infrastructure/repository/gorm_score_snapshot_repository.go index 7f32c628f..30239c54f 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/frontend/src/features/analytics/ExecutiveDashboard.tsx b/frontend/src/features/analytics/ExecutiveDashboard.tsx index 249f097cd..a270ddceb 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 000000000..779a5d795 --- /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/analytics/executiveService.ts b/frontend/src/features/analytics/executiveService.ts deleted file mode 100644 index 40a9207c2..000000000 --- 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 710c3f626..000000000 --- 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, - }); -} diff --git a/frontend/src/features/dashboard/useScoreHistory.ts b/frontend/src/features/dashboard/useScoreHistory.ts index ead1cb33d..b07211b98 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 50b0431d8..c5e22fab6 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 f9491aa38..f7a375b6a 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 bc5950b3e..10a1b6f12 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 95c0cfc48..16911a212 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 c05366468..676db5054 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." + } } } diff --git a/migrations/0071_approval_deferrals.down.sql b/migrations/0071_approval_deferrals.down.sql new file mode 100644 index 000000000..784790009 --- /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 000000000..9a1842fac --- /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; diff --git a/migrations/0072_score_snapshot_figures.down.sql b/migrations/0072_score_snapshot_figures.down.sql new file mode 100644 index 000000000..1e40e7d99 --- /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 000000000..83053f53e --- /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;