diff --git a/apps/console/src/_locales/en-US.json b/apps/console/src/_locales/en-US.json index fd8d514bc0..32b4325473 100644 --- a/apps/console/src/_locales/en-US.json +++ b/apps/console/src/_locales/en-US.json @@ -2659,6 +2659,7 @@ "unplannedWarning_one": "{{count}} linked risk has no treatment plan yet.", "unplannedWarning_other": "{{count}} linked risks have no treatment plan yet.", "filter": "{{state}} · L{{likelihood}} × I{{impact}}", + "asOf": "As of", "search": "Search risks", "states": { "inherent": "Inherent", "net": "Net", "residual": "Residual" }, "axes": { "likelihood": "Likelihood", "impact": "Impact" }, @@ -2678,7 +2679,6 @@ "scoreProgress": "{{inherent}} → {{net}} → {{residual}}", "progress": "{{done}}/{{total}}", "progressEmpty": "No measures", - "unassigned": "Unassigned", "actions": { "tasks": "Tasks", "edit": "Edit", @@ -2694,12 +2694,12 @@ "riskTreatmentPlanListItem": { "deleteConfirmation": "This will permanently delete the treatment plan for \"{{name}}\". This action cannot be undone.", "scoreProgress": "{{inherent}} → {{net}} → {{residual}}", - "unassigned": "Unassigned", "actions": { "measures": "Measures", "edit": "Edit", "delete": "Delete" } }, "treatmentPlanMeasureList": { "title": "Measures", "empty": "No measures linked yet.", + "deleted": "Deleted measure", "acceptedEmpty": "Accepted risks cannot have linked measures.", "actions": { "create": "New measure", "link": "Link measure", "unlink": "Unlink", "showMore": "Show more" } }, diff --git a/apps/console/src/_locales/fr-FR.json b/apps/console/src/_locales/fr-FR.json index b533b0fbf8..749917fd96 100644 --- a/apps/console/src/_locales/fr-FR.json +++ b/apps/console/src/_locales/fr-FR.json @@ -5140,6 +5140,7 @@ "unplannedWarning_one": "{{count}} risque lié n’a pas encore de plan de traitement.", "unplannedWarning_other": "{{count}} risques liés n’ont pas encore de plan de traitement.", "filter": "{{state}} · L{{likelihood}} × I{{impact}}", + "asOf": "En date du", "search": "Rechercher des risques", "states": { "inherent": "Inhérent", @@ -5168,7 +5169,6 @@ "scoreProgress": "{{inherent}} → {{net}} → {{residual}}", "progress": "{{done}}/{{total}}", "progressEmpty": "Aucune mesure", - "unassigned": "Non attribué", "actions": { "tasks": "Tâches", "edit": "Modifier", @@ -5189,7 +5189,6 @@ "riskTreatmentPlanListItem": { "deleteConfirmation": "Cela supprimera définitivement le plan de traitement de « {{name}} ». Cette action est irréversible.", "scoreProgress": "{{inherent}} → {{net}} → {{residual}}", - "unassigned": "Non attribué", "actions": { "measures": "Mesures", "edit": "Modifier", @@ -5199,6 +5198,7 @@ "treatmentPlanMeasureList": { "title": "Mesures", "empty": "Aucune mesure liée pour le moment.", + "deleted": "Mesure supprimée", "acceptedEmpty": "Les risques acceptés ne peuvent pas avoir de mesures liées.", "actions": { "create": "Nouvelle mesure", diff --git a/apps/console/src/_locales/nl-NL.json b/apps/console/src/_locales/nl-NL.json index a56c882caa..1c48236e30 100644 --- a/apps/console/src/_locales/nl-NL.json +++ b/apps/console/src/_locales/nl-NL.json @@ -5130,6 +5130,7 @@ "unplannedWarning_one": "{{count}} gekoppeld risico heeft nog geen behandelplan.", "unplannedWarning_other": "{{count}} gekoppelde risico’s hebben nog geen behandelplan.", "filter": "{{state}} · L{{likelihood}} × I{{impact}}", + "asOf": "Per", "search": "Zoek risico's", "states": { "inherent": "Inherent", @@ -5158,7 +5159,6 @@ "scoreProgress": "{{inherent}} → {{net}} → {{residual}}", "progress": "{{done}}/{{total}}", "progressEmpty": "Geen maatregelen", - "unassigned": "Niet toegewezen", "actions": { "tasks": "Taken", "edit": "Bewerken", @@ -5179,7 +5179,6 @@ "riskTreatmentPlanListItem": { "deleteConfirmation": "Dit verwijdert het behandelplan voor \"{{name}}\" permanent. Deze actie kan niet ongedaan worden gemaakt.", "scoreProgress": "{{inherent}} → {{net}} → {{residual}}", - "unassigned": "Niet toegewezen", "actions": { "measures": "Maatregelen", "edit": "Bewerken", @@ -5189,6 +5188,7 @@ "treatmentPlanMeasureList": { "title": "Maatregelen", "empty": "Nog geen maatregelen gekoppeld.", + "deleted": "Verwijderde maatregel", "acceptedEmpty": "Geaccepteerde risico's kunnen geen maatregelen hebben.", "actions": { "create": "Nieuwe maatregel", diff --git a/apps/console/src/pages/organizations/risks/risk-analyses/_components/RiskAnalysisMatrices.tsx b/apps/console/src/pages/organizations/risks/risk-analyses/_components/RiskAnalysisMatrices.tsx index 0ad1fda67d..ccd2c6f7e5 100644 --- a/apps/console/src/pages/organizations/risks/risk-analyses/_components/RiskAnalysisMatrices.tsx +++ b/apps/console/src/pages/organizations/risks/risk-analyses/_components/RiskAnalysisMatrices.tsx @@ -19,13 +19,14 @@ // SOFTWARE. import { getRiskImpacts, getRiskLikelihoods } from "@probo/helpers"; -import { Card, RisksChart } from "@probo/ui"; +import { Card, IconClock, Input, RisksChart } from "@probo/ui"; import { useTranslation } from "react-i18next"; import { graphql, useFragment } from "react-relay"; import type { RiskAnalysisMatrices_analysis$key } from "#/__generated__/core/RiskAnalysisMatrices_analysis.graphql"; import { useOrganizationId } from "#/hooks/useOrganizationId"; +import { asOfDateBounds, clampDateInput, matrixAsOf } from "../_lib/matrixAsOf"; import { graphqlScoreTypes, type MatrixCell, @@ -36,12 +37,16 @@ import { useMatrixCellFilter } from "../_lib/useMatrixCellFilter"; const severityColors = ["bg-txt-success", "bg-txt-warning", "bg-txt-danger"] as const; export const riskAnalysisMatricesFragment = graphql` - fragment RiskAnalysisMatrices_analysis on RiskAnalysis { + fragment RiskAnalysisMatrices_analysis on RiskAnalysis + @argumentDefinitions( + asOf: { type: "Datetime", defaultValue: null } + ) { + createdAt matrixSize { rows cols } - matrixCells { + matrixCells(asOf: $asOf) { type likelihood impact @@ -52,6 +57,9 @@ export const riskAnalysisMatricesFragment = graphql` interface RiskAnalysisMatricesProps { analysisKey: RiskAnalysisMatrices_analysis$key; + asOfDate: string; + isPending: boolean; + onAsOfDateChange: (date: string) => void; } function selectedChartCell( @@ -65,8 +73,8 @@ function selectedChartCell( return { likelihood: cell.likelihood, impact: cell.impact }; } -function cellCountsForScoreType( - counts: ReadonlyArray<{ +function cellCountsForType( + cells: ReadonlyArray<{ type: string; likelihood: number; impact: number; @@ -74,9 +82,9 @@ function cellCountsForScoreType( }>, type: MatrixScoreType, ) { - const graphqlType = graphqlScoreTypes[type]; - return counts - .filter(cell => cell.type === graphqlType) + const scoreType = graphqlScoreTypes[type]; + return cells + .filter(cell => cell.type === scoreType) .map(cell => ({ likelihood: cell.likelihood, impact: cell.impact, @@ -84,7 +92,12 @@ function cellCountsForScoreType( })); } -export function RiskAnalysisMatrices({ analysisKey }: RiskAnalysisMatricesProps) { +export function RiskAnalysisMatrices({ + analysisKey, + asOfDate, + isPending, + onAsOfDateChange, +}: RiskAnalysisMatricesProps) { const { t } = useTranslation(); const organizationId = useOrganizationId(); const { cell, toggleCell } = useMatrixCellFilter(); @@ -94,7 +107,14 @@ export function RiskAnalysisMatrices({ analysisKey }: RiskAnalysisMatricesProps) } const matrixSize = { rows: analysis.matrixSize.rows, cols: analysis.matrixSize.cols }; - const counts = analysis.matrixCells ?? []; + const { minDate, maxDate } = asOfDateBounds(analysis.createdAt); + const cells = analysis.matrixCells; + const hasAsOf = matrixAsOf(asOfDate) != null; + + const handleAsOfDateChange = (value: string) => { + onAsOfDateChange(clampDateInput(value, minDate, maxDate)); + }; + const impacts = getRiskImpacts(t, matrixSize.cols); const likelihoods = getRiskLikelihoods(t, matrixSize.rows); const formatScale = (items: ReadonlyArray<{ value: number; label: string }>) => @@ -112,11 +132,30 @@ export function RiskAnalysisMatrices({ analysisKey }: RiskAnalysisMatricesProps) return ( -
+
+ + + handleAsOfDateChange(event.target.value)} + /> +
+
; + totalCount: number; + hasNext: boolean; + isLoadingNext: boolean; + loadNext: (count: number) => void; + refetch: () => void; +}; + interface RiskAnalysisPlansSectionProps { analysisKey: RiskAnalysisPlansSection_analysis$key; } @@ -144,7 +171,67 @@ export function RiskAnalysisPlansSection({ analysisKey }: RiskAnalysisPlansSecti function RiskAnalysisPlansSectionContent({ analysisKey }: RiskAnalysisPlansSectionProps) { const { t } = useTranslation(); const { cell, clear } = useMatrixCellFilter(); - const analysis = useFragment(riskAnalysisPlansSectionFragment, analysisKey); + const { asOfDate, setAsOfDate } = useMatrixAsOf(); + const [isPending, startTransition] = useTransition(); + const [order, setOrder] = useState({ direction: "DESC", field: "" }); + const [analysis, refetch] = useRefetchableFragment< + RiskAnalysisPlansSectionRefetchQuery, + RiskAnalysisPlansSection_analysis$key + >(riskAnalysisPlansSectionFragment, analysisKey); + const { minDate, maxDate } = asOfDateBounds(analysis.createdAt); + const clampedAsOfDate = clampDateInput(asOfDate, minDate, maxDate); + const asOfNeedsClamp = clampedAsOfDate !== asOfDate; + const skipInitialAsOfRefetch = useRef(!asOfNeedsClamp); + const asOfPending = isPending || asOfNeedsClamp; + const wantUnplanned = matrixAsOf(clampedAsOfDate) == null; + const orderBy = useMemo(() => categoryOrder(order), [order]); + const orderByRef = useRef(orderBy); + const [unplannedReady, setUnplannedReady] = useState(!asOfNeedsClamp && wantUnplanned); + if (!wantUnplanned && unplannedReady) { + setUnplannedReady(false); + } + + useEffect(() => { + orderByRef.current = orderBy; + }); + + useEffect(() => { + if (asOfNeedsClamp) { + setAsOfDate(clampedAsOfDate, { clearCellFilter: false }); + } + }, [asOfNeedsClamp, clampedAsOfDate, setAsOfDate]); + + useEffect(() => { + if (skipInitialAsOfRefetch.current) { + skipInitialAsOfRefetch.current = false; + return; + } + + let cancelled = false; + startTransition(() => { + refetch( + { + asOf: matrixAsOf(clampedAsOfDate), + filter: treatmentPlanFilterFromCell(cell), + includeUnplanned: wantUnplanned, + orderBy: orderByRef.current, + }, + { + onComplete: (error) => { + if (cancelled || error) { + return; + } + + setUnplannedReady(wantUnplanned); + }, + }, + ); + }); + + return () => { + cancelled = true; + }; + }, [clampedAsOfDate, cell, refetch, wantUnplanned]); if (!analysis.id || !analysis.matrixSize) { return null; @@ -152,7 +239,12 @@ function RiskAnalysisPlansSectionContent({ analysisKey }: RiskAnalysisPlansSecti return (
- + {cell && (
)} }> - +
); } function RiskAnalysisPlansTable({ - analysisKey, + analysis, + asOfDate, + isPending, + unplannedReady, + order, + orderBy, + onOrderChange, }: { - analysisKey: RiskAnalysisPlansSection_analysis$key; + analysis: RiskAnalysisPlansSection_analysis$data; + asOfDate: string; + isPending: boolean; + unplannedReady: boolean; + order: Order; + orderBy: ReturnType; + onOrderChange: (order: Order) => void; }) { const { t } = useTranslation(); const { cell } = useMatrixCellFilter(); - const [order, setOrder] = useState({ direction: "DESC", field: "" }); const [, startTransition] = useTransition(); - const analysis = useFragment(riskAnalysisPlansSectionFragment, analysisKey); + const asOf = matrixAsOf(asOfDate); + const hasAsOf = asOf != null; const { data: plansData, hasNext: plansHasNext, @@ -191,46 +303,30 @@ function RiskAnalysisPlansTable({ RiskAnalysisPlansSectionPlansQuery, RiskAnalysisPlansSection_plans$key >(riskAnalysisPlansSectionPlansFragment, analysis); - const { - data: unplannedData, - hasNext: unplannedHasNext, - isLoadingNext: unplannedLoadingNext, - loadNext: loadNextUnplanned, - refetch: refetchUnplanned, - } = usePaginationFragment< - RiskAnalysisPlansSectionUnplannedQuery, - RiskAnalysisPlansSection_unplanned$key - >(riskAnalysisPlansSectionUnplannedFragment, analysis); const filter = useMemo( () => treatmentPlanFilterFromCell(cell), [cell], ); - const orderBy = useMemo( - () => categoryOrder(order), - [order], - ); + const plansQueryRef = useRef({ asOf, filter }); const skipFirstPlansRefetch = useRef(true); - const skipFirstUnplannedRefetch = useRef(true); useEffect(() => { - if (skipFirstPlansRefetch.current) { - skipFirstPlansRefetch.current = false; - return; - } - startTransition(() => { - refetchPlans({ filter, orderBy }); - }); - }, [filter, orderBy, refetchPlans, startTransition]); + plansQueryRef.current = { asOf, filter }; + }); useEffect(() => { - if (skipFirstUnplannedRefetch.current) { - skipFirstUnplannedRefetch.current = false; + if (skipFirstPlansRefetch.current) { + skipFirstPlansRefetch.current = false; return; } startTransition(() => { - refetchUnplanned({ orderBy }); + refetchPlans({ + asOf: plansQueryRef.current.asOf, + filter: plansQueryRef.current.filter, + orderBy, + }); }); - }, [orderBy, refetchUnplanned, startTransition]); + }, [orderBy, refetchPlans]); if (!analysis.matrixSize) { return null; @@ -239,109 +335,174 @@ function RiskAnalysisPlansTable({ const matrixSize = { rows: analysis.matrixSize.rows, cols: analysis.matrixSize.cols }; const plans = plansData.treatmentPlans?.edges.map(edge => edge.node) ?? []; const connectionId = plansData.treatmentPlans?.__id ?? ""; - const unplannedRisks = unplannedData.scenarioRisks?.edges.map(edge => edge.node) ?? []; - const unplannedTotal = unplannedData.scenarioRisks?.totalCount ?? 0; - const visibleUnplannedRisks = cell ? [] : unplannedRisks; - const showMore = cell ? plansHasNext : plansHasNext || unplannedHasNext; - const loadingMore = plansLoadingNext || unplannedLoadingNext; - const reload = () => { - startTransition(() => { - refetchPlans({ filter, orderBy }, { fetchPolicy: "network-only" }); - refetchUnplanned({ orderBy }, { fetchPolicy: "network-only" }); - }); + const renderTable = (unplanned: UnplannedPlans | null) => { + const visibleUnplannedRisks = hasAsOf || cell ? [] : (unplanned?.risks ?? []); + const unplannedTotal = unplanned?.totalCount ?? 0; + const showMore = hasAsOf || cell + ? plansHasNext + : plansHasNext || (unplanned?.hasNext ?? false); + const loadingMore = plansLoadingNext || (unplanned?.isLoadingNext ?? false); + const reload = () => { + startTransition(() => { + refetchPlans({ filter, orderBy, asOf }, { fetchPolicy: "network-only" }); + unplanned?.refetch(); + }); + }; + const isEmpty = plans.length === 0 && visibleUnplannedRisks.length === 0; + const rows = [ + ...plans.map(plan => ({ kind: "plan" as const, plan })), + ...visibleUnplannedRisks.map(risk => ({ kind: "unplanned" as const, risk })), + ]; + + return ( +
+ {unplannedTotal > 0 && ( +
+ + {t("riskAnalysisTreatmentPlansPage.unplannedWarning", { + count: unplannedTotal, + })} +
+ )} + + + + + + + {t("riskAnalysisTreatmentPlansPage.columns.category")} + + + + + + + + + {isEmpty && ( + + + + )} + {rows.map(row => + row.kind === "plan" + ? ( + + ) + : ( + + ), + )} + +
{t("riskAnalysisTreatmentPlansPage.columns.risk")}{t("riskAnalysisTreatmentPlansPage.columns.treatment")}{t("riskAnalysisTreatmentPlansPage.columns.owner")}{t("riskAnalysisTreatmentPlansPage.columns.scores")}{t("riskAnalysisTreatmentPlansPage.columns.progress")} +
+ {cell + ? t("riskAnalysisTreatmentPlansPage.emptyFilter") + : t("riskAnalysisTreatmentPlansPage.empty")} +
+
+ {showMore && ( + + )} +
+ ); }; - const isEmpty = plans.length === 0 && visibleUnplannedRisks.length === 0; - const rows = [ - ...plans.map(plan => ({ kind: "plan" as const, plan })), - ...visibleUnplannedRisks.map(risk => ({ kind: "unplanned" as const, risk })), - ]; + if (hasAsOf || !unplannedReady) { + return renderTable(null); + } return ( -
- {unplannedTotal > 0 && ( -
- - {t("riskAnalysisTreatmentPlansPage.unplannedWarning", { - count: unplannedTotal, - })} -
- )} - - - - - - - {t("riskAnalysisTreatmentPlansPage.columns.category")} - - - - - - - - - {isEmpty && ( - - - - )} - {rows.map(row => - row.kind === "plan" - ? ( - - ) - : ( - - ), - )} - -
{t("riskAnalysisTreatmentPlansPage.columns.risk")}{t("riskAnalysisTreatmentPlansPage.columns.treatment")}{t("riskAnalysisTreatmentPlansPage.columns.owner")}{t("riskAnalysisTreatmentPlansPage.columns.scores")}{t("riskAnalysisTreatmentPlansPage.columns.progress")} -
- {cell - ? t("riskAnalysisTreatmentPlansPage.emptyFilter") - : t("riskAnalysisTreatmentPlansPage.empty")} -
-
- {showMore && ( - - )} -
+ + {unplanned => renderTable(unplanned)} + ); } +function RiskAnalysisUnplannedPlans({ + analysisKey, + orderBy, + children, +}: { + analysisKey: RiskAnalysisPlansSection_unplanned$key; + orderBy: ReturnType; + children: (unplanned: UnplannedPlans) => ReactNode; +}) { + const [, startTransition] = useTransition(); + const { + data, + hasNext, + isLoadingNext, + loadNext, + refetch, + } = usePaginationFragment< + RiskAnalysisPlansSectionUnplannedQuery, + RiskAnalysisPlansSection_unplanned$key + >(riskAnalysisPlansSectionUnplannedFragment, analysisKey); + const skipFirstRefetch = useRef(true); + + useEffect(() => { + if (skipFirstRefetch.current) { + skipFirstRefetch.current = false; + return; + } + startTransition(() => { + refetch({ orderBy }); + }); + }, [orderBy, refetch, startTransition]); + + const risks = data.scenarioRisks?.edges.map(edge => edge.node) ?? []; + + return children({ + risks, + totalCount: data.scenarioRisks?.totalCount ?? 0, + hasNext, + isLoadingNext, + loadNext, + refetch: () => { + refetch({ orderBy }, { fetchPolicy: "network-only" }); + }, + }); +} + function categoryOrder(order: Order) { if (order.field !== "CATEGORY") { return null; diff --git a/apps/console/src/pages/organizations/risks/risk-analyses/_lib/matrixAsOf.ts b/apps/console/src/pages/organizations/risks/risk-analyses/_lib/matrixAsOf.ts new file mode 100644 index 0000000000..5c4af6976e --- /dev/null +++ b/apps/console/src/pages/organizations/risks/risk-analyses/_lib/matrixAsOf.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { todayAsDateInput } from "@probo/helpers"; + +export const matrixAsOfParam = "asOf"; + +const dateInputPattern = /^\d{4}-\d{2}-\d{2}$/; + +function isCalendarDateInput(value: string): boolean { + if (!dateInputPattern.test(value)) { + return false; + } + + const year = Number(value.slice(0, 4)); + const month = Number(value.slice(5, 7)); + const day = Number(value.slice(8, 10)); + const parsed = new Date(Date.UTC(year, month - 1, day)); + + return ( + parsed.getUTCFullYear() === year + && parsed.getUTCMonth() === month - 1 + && parsed.getUTCDate() === day + ); +} + +function localDateInput(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + + return `${year}-${month}-${day}`; +} + +function createdOnDateInput(createdAt: string): string { + const parsed = new Date(createdAt); + if (Number.isNaN(parsed.getTime())) { + return todayAsDateInput(); + } + + return localDateInput(parsed); +} + +export function parseAsOfDate(params: URLSearchParams): string { + const today = todayAsDateInput(); + const value = params.get(matrixAsOfParam); + if (!value || !isCalendarDateInput(value) || value >= today) { + return today; + } + + return value; +} + +export function clampDateInput(dateInput: string, min: string, max: string): string { + if (!dateInput || dateInput > max) { + return max; + } + if (dateInput < min) { + return min; + } + return dateInput; +} + +export function asOfDateBounds(createdAt: string): { minDate: string; maxDate: string } { + const createdDate = createdOnDateInput(createdAt); + const maxDate = todayAsDateInput(); + const minDate = createdDate > maxDate ? maxDate : createdDate; + + return { minDate, maxDate }; +} + +export function matrixAsOf(dateInput: string): string | null { + const date = dateInput || todayAsDateInput(); + if (date === todayAsDateInput()) { + return null; + } + + const year = Number(date.slice(0, 4)); + const month = Number(date.slice(5, 7)); + const day = Number(date.slice(8, 10)); + + return new Date(Date.UTC(year, month - 1, day + 1)).toISOString(); +} diff --git a/apps/console/src/pages/organizations/risks/risk-analyses/_lib/useMatrixAsOf.ts b/apps/console/src/pages/organizations/risks/risk-analyses/_lib/useMatrixAsOf.ts new file mode 100644 index 0000000000..ee6052ff2d --- /dev/null +++ b/apps/console/src/pages/organizations/risks/risk-analyses/_lib/useMatrixAsOf.ts @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import { todayAsDateInput } from "@probo/helpers"; +import { useCallback, useMemo } from "react"; +import { useSearchParams } from "react-router"; + +import { matrixAsOfParam, parseAsOfDate } from "./matrixAsOf"; + +function writeAsOfDate( + params: URLSearchParams, + dateInput: string, + options?: { clearCellFilter?: boolean }, +): URLSearchParams { + const next = new URLSearchParams(params); + const today = todayAsDateInput(); + if (!dateInput || dateInput >= today) { + next.delete(matrixAsOfParam); + } else { + next.set(matrixAsOfParam, dateInput); + } + + if (options?.clearCellFilter !== false) { + next.delete("score"); + next.delete("likelihood"); + next.delete("impact"); + } + + return next; +} + +export function useMatrixAsOf() { + const [params, setParams] = useSearchParams(); + const search = params.toString(); + const asOfDate = useMemo( + () => parseAsOfDate(new URLSearchParams(search)), + [search], + ); + + const setAsOfDate = useCallback(( + next: string, + options?: { clearCellFilter?: boolean }, + ) => { + setParams(prev => writeAsOfDate(prev, next, options), { + replace: true, + preventScrollReset: true, + }); + }, [setParams]); + + return { + asOfDate, + setAsOfDate, + }; +} diff --git a/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/RiskAnalysisTreatmentPlansPage.tsx b/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/RiskAnalysisTreatmentPlansPage.tsx index a7dfd21f9f..3a632d6ff5 100644 --- a/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/RiskAnalysisTreatmentPlansPage.tsx +++ b/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/RiskAnalysisTreatmentPlansPage.tsx @@ -29,11 +29,14 @@ export const riskAnalysisTreatmentPlansPageQuery = graphql` query RiskAnalysisTreatmentPlansPageQuery( $riskAnalysisId: ID! $filter: TreatmentPlanFilter + $asOf: Datetime + $includeUnplanned: Boolean! ) { node(id: $riskAnalysisId) { __typename ... on RiskAnalysis { - ...RiskAnalysisPlansSection_analysis @arguments(filter: $filter) + ...RiskAnalysisPlansSection_analysis + @arguments(filter: $filter, asOf: $asOf, includeUnplanned: $includeUnplanned) } } } diff --git a/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/RiskAnalysisTreatmentPlansPageLoader.tsx b/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/RiskAnalysisTreatmentPlansPageLoader.tsx index 4ea28cf4ff..d41f85d5dd 100644 --- a/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/RiskAnalysisTreatmentPlansPageLoader.tsx +++ b/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/RiskAnalysisTreatmentPlansPageLoader.tsx @@ -18,14 +18,19 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -import { Suspense, useEffect, useRef } from "react"; +import { Suspense, useEffect, useMemo } from "react"; import { useQueryLoader } from "react-relay"; import { useParams, useSearchParams } from "react-router"; import type { RiskAnalysisTreatmentPlansPageQuery } from "#/__generated__/core/RiskAnalysisTreatmentPlansPageQuery.graphql"; import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton"; -import { parseMatrixCell, treatmentPlanFilterFromCell } from "../_lib/matrixCell"; +import { matrixAsOf, parseAsOfDate } from "../_lib/matrixAsOf"; +import { + parseMatrixCell, + treatmentPlanFilterFromCell, + type TreatmentPlanFilterInput, +} from "../_lib/matrixCell"; import RiskAnalysisTreatmentPlansPage, { riskAnalysisTreatmentPlansPageQuery, @@ -34,25 +39,36 @@ import RiskAnalysisTreatmentPlansPage, { export default function RiskAnalysisTreatmentPlansPageLoader() { const { riskAnalysisId } = useParams<{ riskAnalysisId: string }>(); const [params] = useSearchParams(); - const paramsRef = useRef(params); + const search = params.toString(); const [queryRef, loadQuery] = useQueryLoader(riskAnalysisTreatmentPlansPageQuery); + const { asOf, filter, includeUnplanned } = useMemo(() => { + const parsed = new URLSearchParams(search); + const nextAsOf = matrixAsOf(parseAsOfDate(parsed)); - useEffect(() => { - paramsRef.current = params; - }); + return { + asOf: nextAsOf, + filter: treatmentPlanFilterFromCell(parseMatrixCell(parsed)), + includeUnplanned: nextAsOf == null, + }; + }, [search]); useEffect(() => { if (riskAnalysisId) { loadQuery({ riskAnalysisId, - filter: treatmentPlanFilterFromCell(parseMatrixCell(paramsRef.current)), + filter, + asOf, + includeUnplanned, }); } - }, [loadQuery, riskAnalysisId]); + }, [asOf, filter, includeUnplanned, loadQuery, riskAnalysisId]); const currentQueryRef = queryRef != null && queryRef.variables.riskAnalysisId === riskAnalysisId + && queryRef.variables.asOf === asOf + && queryRef.variables.includeUnplanned === includeUnplanned + && sameTreatmentPlanFilter(queryRef.variables.filter, filter) ? queryRef : null; @@ -66,3 +82,17 @@ export default function RiskAnalysisTreatmentPlansPageLoader() { ); } + +function sameTreatmentPlanFilter( + loaded: RiskAnalysisTreatmentPlansPageQuery["variables"]["filter"], + wanted: TreatmentPlanFilterInput | null, +): boolean { + if (wanted == null) { + return loaded == null; + } + + return loaded != null + && loaded.scoreType === wanted.scoreType + && loaded.likelihood === wanted.likelihood + && loaded.impact === wanted.impact; +} diff --git a/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/_components/TreatmentPlanListItem.tsx b/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/_components/TreatmentPlanListItem.tsx index c8c84e6a38..072fad88d8 100644 --- a/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/_components/TreatmentPlanListItem.tsx +++ b/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/_components/TreatmentPlanListItem.tsx @@ -41,46 +41,40 @@ import { updateStoreCounter } from "#/hooks/useMutationWithIncrement"; import { useOrganizationId } from "#/hooks/useOrganizationId"; import { useMutation } from "#/lib/relay/useMutation"; -import type { MatrixSize } from "../../_components/matrixSize"; +import { type MatrixSize } from "../../_components/matrixSize"; import { TreatmentPlanMeasureList } from "./TreatmentPlanMeasureList"; +import { TreatmentPlanProgressBar } from "./TreatmentPlanProgressBar"; import { TreatmentPlanScoreTags } from "./TreatmentPlanScoreTags"; import { UpdateTreatmentPlanDialog } from "./UpdateTreatmentPlanDialog"; export const treatmentPlanListItemFragment = graphql` - fragment TreatmentPlanListItem_treatmentPlan on TreatmentPlan { + fragment TreatmentPlanListItem_treatmentPlan on TreatmentPlan + @argumentDefinitions(asOf: { type: "Datetime", defaultValue: null }) { id treatment inherentLikelihood inherentImpact residualLikelihood residualImpact + category owner { fullName } risk { id name - category } canUpdate: permission(action: "risk-management:treatment-plan:update") canDelete: permission(action: "risk-management:treatment-plan:delete") - measureCount: measures(first: 0) { - totalCount + progress { + done + inProgress + notImplemented + total } - implementedMeasures: measures(first: 0, filter: { state: IMPLEMENTED }) { - totalCount - } - inProgressMeasures: measures(first: 0, filter: { state: IN_PROGRESS }) { - totalCount - } - notImplementedMeasures: measures( - first: 0 - filter: { state: NOT_IMPLEMENTED } - ) { - totalCount - } - ...TreatmentPlanMeasureList_treatmentPlan + ...TreatmentPlanMeasureList_meta + ...TreatmentPlanMeasureList_treatmentPlan @arguments(asOf: $asOf) ...UpdateTreatmentPlanDialog_treatmentPlan } `; @@ -100,6 +94,7 @@ interface TreatmentPlanListItemProps { treatmentPlanKey: TreatmentPlanListItem_treatmentPlan$key; connectionId: string; matrixSize: MatrixSize; + readOnly?: boolean; onChanged?: () => void; } @@ -107,6 +102,7 @@ export function TreatmentPlanListItem({ treatmentPlanKey, connectionId, matrixSize, + readOnly = false, onChanged, }: TreatmentPlanListItemProps) { const { t } = useTranslation(); @@ -118,21 +114,11 @@ export function TreatmentPlanListItem({ const treatmentPlan = useFragment(treatmentPlanListItemFragment, treatmentPlanKey); const relayEnv = useRelayEnvironment(); const [deleteTreatmentPlan] = useMutation(deleteMutation); - const progress = { - done: treatmentPlan.implementedMeasures.totalCount, - inProgress: treatmentPlan.inProgressMeasures.totalCount, - notImplemented: treatmentPlan.notImplementedMeasures.totalCount, - total: treatmentPlan.measureCount.totalCount, - }; - const donePct = progress.total === 0 - ? 0 - : Math.round((progress.done / progress.total) * 100); - const inProgressPct = progress.total === 0 - ? 0 - : Math.round((progress.inProgress / progress.total) * 100); - const notImplementedPct = progress.total === 0 - ? 0 - : Math.round((progress.notImplemented / progress.total) * 100); + const progress = treatmentPlan.progress; + const inherentLikelihood = treatmentPlan.inherentLikelihood; + const inherentImpact = treatmentPlan.inherentImpact; + const residualLikelihood = treatmentPlan.residualLikelihood; + const residualImpact = treatmentPlan.residualImpact; const onDelete = () => { confirm( @@ -158,11 +144,13 @@ export function TreatmentPlanListItem({ return ( <> - + {!readOnly && ( + + )} setExpanded(open => !open)} @@ -194,7 +182,7 @@ export function TreatmentPlanListItem({
- {treatmentPlan.risk.category} + {treatmentPlan.category} {t(`formRiskDialog.treatments.${treatmentPlan.treatment.toLowerCase()}`)} @@ -203,41 +191,23 @@ export function TreatmentPlanListItem({ -
-
-
-
-
-
- - {progress.total === 0 - ? t("treatmentPlanListItem.progressEmpty") - : t("treatmentPlanListItem.progress", { - done: progress.done, - total: progress.total, - })} - -
+ - {(treatmentPlan.canUpdate || treatmentPlan.canDelete) && ( + {!readOnly && (treatmentPlan.canUpdate || treatmentPlan.canDelete) && (
event.stopPropagation()}> {treatmentPlan.canUpdate && ( diff --git a/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/_components/TreatmentPlanMeasureList.tsx b/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/_components/TreatmentPlanMeasureList.tsx index a439f53f56..f3a9680be7 100644 --- a/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/_components/TreatmentPlanMeasureList.tsx +++ b/apps/console/src/pages/organizations/risks/risk-analyses/treatment-plans/_components/TreatmentPlanMeasureList.tsx @@ -25,6 +25,7 @@ import { graphql, useFragment, usePaginationFragment } from "react-relay"; import { Link } from "react-router"; import type { TreatmentPlanMeasureList_measure$key } from "#/__generated__/core/TreatmentPlanMeasureList_measure.graphql"; +import type { TreatmentPlanMeasureList_meta$key } from "#/__generated__/core/TreatmentPlanMeasureList_meta.graphql"; import type { TreatmentPlanMeasureList_treatmentPlan$key } from "#/__generated__/core/TreatmentPlanMeasureList_treatmentPlan.graphql"; import type { TreatmentPlanMeasureListCreateMutation } from "#/__generated__/core/TreatmentPlanMeasureListCreateMutation.graphql"; import type { TreatmentPlanMeasureListDetachMutation } from "#/__generated__/core/TreatmentPlanMeasureListDetachMutation.graphql"; @@ -36,22 +37,30 @@ import MeasureFormDialog from "#/pages/organizations/measures/dialog/MeasureForm const PAGE_SIZE = 50; +export const treatmentPlanMeasureListMetaFragment = graphql` + fragment TreatmentPlanMeasureList_meta on TreatmentPlan { + id + treatment + canUpdate: permission(action: "risk-management:treatment-plan:update") + organization { + canCreateMeasure: permission(action: "core:measure:create") + } + } +`; + export const treatmentPlanMeasureListFragment = graphql` fragment TreatmentPlanMeasureList_treatmentPlan on TreatmentPlan @refetchable(queryName: "TreatmentPlanMeasureListPaginationQuery") @argumentDefinitions( first: { type: "Int", defaultValue: 50 } after: { type: "CursorKey", defaultValue: null } + asOf: { type: "Datetime", defaultValue: null } ) { id - treatment - canUpdate: permission(action: "risk-management:treatment-plan:update") - organization { - canCreateMeasure: permission(action: "core:measure:create") - } - measures(first: $first, after: $after) - @connection(key: "TreatmentPlanMeasureList_measures", filters: []) { + measures(first: $first, after: $after, asOf: $asOf) + @connection(key: "TreatmentPlanMeasureList_measures", filters: ["asOf"]) { __id + asOf edges { node { id @@ -149,7 +158,7 @@ const detachMeasureMutation = graphql` `; interface TreatmentPlanMeasureListProps { - treatmentPlanKey: TreatmentPlanMeasureList_treatmentPlan$key; + treatmentPlanKey: TreatmentPlanMeasureList_treatmentPlan$key & TreatmentPlanMeasureList_meta$key; onChanged?: () => void; } @@ -158,11 +167,18 @@ export function TreatmentPlanMeasureList({ onChanged, }: TreatmentPlanMeasureListProps) { const { t } = useTranslation(); + const meta = useFragment( + treatmentPlanMeasureListMetaFragment, + treatmentPlanKey as TreatmentPlanMeasureList_meta$key, + ); const { data: treatmentPlan, hasNext, isLoadingNext, loadNext } = usePaginationFragment< TreatmentPlanMeasureListPaginationQuery, TreatmentPlanMeasureList_treatmentPlan$key - >(treatmentPlanMeasureListFragment, treatmentPlanKey); + >( + treatmentPlanMeasureListFragment, + treatmentPlanKey as TreatmentPlanMeasureList_treatmentPlan$key, + ); const measures = treatmentPlan.measures?.edges?.map(edge => edge.node) ?? []; const connectionId = treatmentPlan.measures?.__id ?? ""; const [attachMeasure, isAttaching] = useMutation( @@ -173,8 +189,10 @@ export function TreatmentPlanMeasureList({ ); const isLoading = isAttaching || isDetaching; const inFlightRef = useRef(false); - const accepted = treatmentPlan.treatment === "ACCEPTED"; - const readOnly = !treatmentPlan.canUpdate || accepted; + const accepted = meta.treatment === "ACCEPTED"; + const hasAsOf = treatmentPlan.measures?.asOf != null; + const readOnly = !meta.canUpdate || accepted || hasAsOf; + const canCreateMeasure = meta.organization.canCreateMeasure; const onAttach = async (measureId: string) => { if (inFlightRef.current || isLoading) { @@ -228,7 +246,7 @@ export function TreatmentPlanMeasureList({ {!readOnly && (
- {treatmentPlan.organization.canCreateMeasure && ( + {canCreateMeasure && (