diff --git a/apps/console/src/_locales/en-US.json b/apps/console/src/_locales/en-US.json index 51229e3bb..259204a8d 100644 --- a/apps/console/src/_locales/en-US.json +++ b/apps/console/src/_locales/en-US.json @@ -348,12 +348,12 @@ "breadcrumbs": { "findings": "Findings" }, "unknown": "Unknown finding", "kinds": { "minor_nonconformity": "Minor nonconformity", "major_nonconformity": "Major nonconformity", "observation": "Observation", "exception": "Exception" }, - "status": { "open": "Open", "in_progress": "In progress", "closed": "Closed", "mitigated": "Mitigated", "false_positive": "False positive" }, + "status": { "open": "Open", "in_progress": "In progress", "closed": "Closed", "risk_accepted": "Risk accepted", "mitigated": "Mitigated", "false_positive": "False positive" }, "priority": { "low": "Low", "medium": "Medium", "high": "High" }, "messages": { "successTitle": "Success", "deleted": "Finding deleted successfully", "updated": "Finding updated successfully" }, "errors": { "title": "Error", "delete": "Failed to delete finding", "update": "Failed to update finding" }, "deleteConfirmation": "This will permanently delete the finding {{referenceId}}. This action cannot be undone.", - "fields": { "description": "Description", "source": "Source", "owner": "Owner", "status": "Status", "priority": "Priority", "dateIdentified": "Date identified", "dueDate": "Due date", "rootCause": "Root cause", "correctiveAction": "Corrective action", "effectivenessCheck": "Effectiveness check" }, + "fields": { "description": "Description", "source": "Source", "owner": "Owner", "status": "Status", "priority": "Priority", "acceptedRisk": "Accepted risk", "dateIdentified": "Date identified", "dueDate": "Due date", "rootCause": "Root cause", "correctiveAction": "Corrective action", "effectivenessCheck": "Effectiveness check" }, "placeholders": { "description": "Enter description", "source": "Enter source", "rootCause": "Enter root cause", "correctiveAction": "Enter corrective action", "effectivenessCheck": "Enter effectiveness check details" }, "actions": { "delete": "Delete", "updating": "Updating...", "update": "Update" } }, @@ -374,11 +374,11 @@ "createFindingDialog": { "breadcrumbs": { "findings": "Findings", "create": "Create" }, "kinds": { "minorNonconformity": "Minor nonconformity", "majorNonconformity": "Major nonconformity", "observation": "Observation", "exception": "Exception" }, - "status": { "open": "Open", "in_progress": "In progress", "closed": "Closed", "mitigated": "Mitigated", "false_positive": "False positive" }, + "status": { "open": "Open", "in_progress": "In progress", "closed": "Closed", "risk_accepted": "Risk accepted", "mitigated": "Mitigated", "false_positive": "False positive" }, "priority": { "low": "Low", "medium": "Medium", "high": "High" }, "messages": { "successTitle": "Success", "created": "Finding created successfully" }, "errors": { "title": "Error", "create": "Failed to create finding" }, - "fields": { "kind": "Kind", "description": "Description", "source": "Source", "owner": "Owner", "status": "Status", "priority": "Priority", "dateIdentified": "Date identified", "dueDate": "Due date", "rootCause": "Root cause", "correctiveAction": "Corrective action", "effectivenessCheck": "Effectiveness check" }, + "fields": { "kind": "Kind", "description": "Description", "source": "Source", "owner": "Owner", "status": "Status", "priority": "Priority", "acceptedRisk": "Accepted risk", "dateIdentified": "Date identified", "dueDate": "Due date", "rootCause": "Root cause", "correctiveAction": "Corrective action", "effectivenessCheck": "Effectiveness check" }, "placeholders": { "selectKind": "Select kind", "description": "Brief description of the finding...", "source": "Enter source", "selectStatus": "Select status", "rootCause": "Detailed analysis of the root cause...", "correctiveAction": "Proposed corrective actions...", "effectivenessCheck": "Assessment of corrective action effectiveness..." }, "actions": { "creating": "Creating...", "create": "Create finding" } }, @@ -610,6 +610,10 @@ "placeholder": "Select an owner", "none": "None" }, + "riskSelectField": { + "placeholder": "Select a risk", + "none": "None" + }, "processingActivityEnumOptions": { "specialOrCriminalData": { "yes": "Yes", diff --git a/apps/console/src/components/form/RiskSelectField.tsx b/apps/console/src/components/form/RiskSelectField.tsx new file mode 100644 index 000000000..97611b31a --- /dev/null +++ b/apps/console/src/components/form/RiskSelectField.tsx @@ -0,0 +1,199 @@ +// 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 { Combobox, ComboboxItem, Field, InfiniteScrollTrigger } from "@probo/ui"; +import { type ComponentProps, Suspense, useCallback, useMemo, useState } from "react"; +import { + type Control, + Controller, + type FieldPath, + type FieldValues, +} from "react-hook-form"; +import { useTranslation } from "react-i18next"; +import { useDebounceCallback } from "usehooks-ts"; + +import { usePaginatedRisks } from "#/hooks/graph/usePaginatedRisks"; + +type SelectedRisk = { + id: string; + name: string; +}; + +type Props< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + organizationId: string; + control: Control; + name: TName; + label?: string; + error?: string; + disabled?: boolean; + optional?: boolean; + selectedRisk?: SelectedRisk | null; +} & ComponentProps; + +export function RiskSelectField({ + organizationId, + control, + disabled, + optional, + selectedRisk, + ...props +}: Props) { + const { t } = useTranslation(); + + return ( + + {}} + placeholder={t("riskSelectField.placeholder")} + disabled + > +
+ + )} + > + + organizationId={organizationId} + control={control} + name={props.name} + disabled={disabled} + optional={optional} + selectedRisk={selectedRisk} + /> + + + ); +} + +function RiskSelectWithQuery( + props: Pick< + Props, + | "organizationId" + | "control" + | "name" + | "disabled" + | "optional" + | "selectedRisk" + >, +) { + const { t } = useTranslation(); + const { name, organizationId, control, disabled, optional, selectedRisk } + = props; + const { data, loadNext, hasNext, isLoadingNext, refetch } + = usePaginatedRisks(organizationId); + const [search, setSearch] = useState(""); + + const refetchSearch = useDebounceCallback( + useCallback( + (query: string) => { + refetch( + { + first: 50, + filter: { query: query || null }, + }, + { fetchPolicy: "network-only" }, + ); + }, + [refetch], + ), + 300, + ); + + const handleSearch = (query: string) => { + setSearch(query); + refetchSearch(query); + }; + + const risks = useMemo(() => { + return data?.risks.edges?.map(edge => edge.node) ?? []; + }, [data?.risks.edges]); + + return ( + { + const selectedFromList = field.value + ? risks.find(risk => risk.id === field.value) + : null; + const selected + = selectedFromList + ?? (selectedRisk && selectedRisk.id === field.value + ? selectedRisk + : null); + + return ( + + {optional && ( + { + field.onChange(null); + setSearch(""); + refetchSearch(""); + }} + > + {t("riskSelectField.none")} + + )} + {risks.map(risk => ( + { + field.onChange(risk.id); + setSearch(risk.name); + }} + > +
+
+ {risk.name} +
+ {risk.category && ( +
+ {risk.category} +
+ )} +
+
+ ))} + {hasNext && ( + loadNext(50)} + /> + )} +
+ ); + }} + /> + ); +} diff --git a/apps/console/src/hooks/graph/usePaginatedRisks.ts b/apps/console/src/hooks/graph/usePaginatedRisks.ts new file mode 100644 index 000000000..82c3edfe8 --- /dev/null +++ b/apps/console/src/hooks/graph/usePaginatedRisks.ts @@ -0,0 +1,86 @@ +// 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 { graphql, useLazyLoadQuery, usePaginationFragment } from "react-relay"; + +import type { usePaginatedRisksFragment$key } from "#/__generated__/core/usePaginatedRisksFragment.graphql"; +import type { usePaginatedRisksQuery } from "#/__generated__/core/usePaginatedRisksQuery.graphql"; +import type { usePaginatedRisksQuery_fragment } from "#/__generated__/core/usePaginatedRisksQuery_fragment.graphql"; + +/* eslint-disable relay/unused-fields */ + +const risksQuery = graphql` + query usePaginatedRisksQuery($organizationId: ID!) { + organization: node(id: $organizationId) { + id + ... on Organization { + ...usePaginatedRisksFragment + } + } + } +`; + +const risksFragment = graphql` + fragment usePaginatedRisksFragment on Organization + @refetchable(queryName: "usePaginatedRisksQuery_fragment") + @argumentDefinitions( + first: { type: "Int", defaultValue: 50 } + order: { type: "RiskOrder", defaultValue: null } + after: { type: "CursorKey", defaultValue: null } + before: { type: "CursorKey", defaultValue: null } + last: { type: "Int", defaultValue: null } + filter: { type: "RiskFilter", defaultValue: null } + ) { + risks( + first: $first + after: $after + last: $last + before: $before + orderBy: $order + filter: $filter + ) @connection(key: "usePaginatedRisksQuery_risks", filters: ["filter"]) { + edges { + node { + id + name + category + description + } + } + } + } +`; + +/** + * Hook to retrieve risks paginated (used for risk selectors) + */ +export function usePaginatedRisks(organizationId: string) { + const query = useLazyLoadQuery( + risksQuery, + { + organizationId, + }, + { fetchPolicy: "network-only" }, + ); + return usePaginationFragment( + risksFragment, + query.organization as usePaginatedRisksFragment$key, + ); +} diff --git a/apps/console/src/pages/organizations/findings/FindingDetailsPage.tsx b/apps/console/src/pages/organizations/findings/FindingDetailsPage.tsx index 29f0e3626..e514aaa1a 100644 --- a/apps/console/src/pages/organizations/findings/FindingDetailsPage.tsx +++ b/apps/console/src/pages/organizations/findings/FindingDetailsPage.tsx @@ -40,6 +40,7 @@ import { useConfirm, useToast, } from "@probo/ui"; +import { useEffect } from "react"; import { Controller } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { @@ -56,6 +57,7 @@ import type { FindingDetailsPageQuery } from "#/__generated__/core/FindingDetail import type { FindingDetailsPageUpdateMutation } from "#/__generated__/core/FindingDetailsPageUpdateMutation.graphql"; import { ControlledField } from "#/components/form/ControlledField"; import { PeopleSelectField } from "#/components/form/PeopleSelectField"; +import { RiskSelectField } from "#/components/form/RiskSelectField"; import { useFormWithSchema } from "#/hooks/useFormWithSchema"; import { useOrganizationId } from "#/hooks/useOrganizationId"; @@ -80,6 +82,10 @@ export const findingDetailsPageQuery = graphql` owner { id } + risk { + id + name + } canUpdate: permission(action: "core:finding:update") canDelete: permission(action: "core:finding:delete") } @@ -107,6 +113,10 @@ const updateFindingMutation = graphql` id fullName } + risk { + id + name + } updatedAt } } @@ -124,18 +134,34 @@ const deleteFindingMutation = graphql` } `; -const updateFindingSchema = z.object({ - description: z.string().optional(), - source: z.string().optional(), - identifiedOn: z.string().optional(), - dueDate: z.string().optional(), - rootCause: z.string().optional(), - correctiveAction: z.string().optional(), - effectivenessCheck: z.string().optional(), - status: z.enum(["OPEN", "IN_PROGRESS", "CLOSED", "MITIGATED", "FALSE_POSITIVE", "RISK_ACCEPTED"]), - priority: z.enum(["LOW", "MEDIUM", "HIGH"]), - ownerId: z.string().nullable().optional(), -}); +const updateFindingSchema = z + .object({ + description: z.string().optional(), + source: z.string().optional(), + identifiedOn: z.string().optional(), + dueDate: z.string().optional(), + rootCause: z.string().optional(), + correctiveAction: z.string().optional(), + effectivenessCheck: z.string().optional(), + status: z.enum([ + "OPEN", + "IN_PROGRESS", + "CLOSED", + "MITIGATED", + "FALSE_POSITIVE", + "RISK_ACCEPTED", + ]), + priority: z.enum(["LOW", "MEDIUM", "HIGH"]), + ownerId: z.string().nullable().optional(), + riskId: z.string().nullable().optional(), + }) + .refine( + data => data.status !== "RISK_ACCEPTED" || Boolean(data.riskId), + { + message: "A risk is required when status is Risk Accepted", + path: ["riskId"], + }, + ); type Props = { queryRef: PreloadedQuery; @@ -230,7 +256,7 @@ export default function FindingDetailsPage(props: Props) { ); }; - const { control, formState, handleSubmit, register, reset } + const { control, formState, handleSubmit, register, reset, watch, setValue } = useFormWithSchema(updateFindingSchema, { defaultValues: { description: finding.description || "", @@ -243,9 +269,18 @@ export default function FindingDetailsPage(props: Props) { status: finding.status || "OPEN", priority: finding.priority || "MEDIUM", ownerId: finding.owner?.id ?? null, + riskId: finding.risk?.id ?? null, }, }); + const status = watch("status"); + + useEffect(() => { + if (status !== "RISK_ACCEPTED") { + setValue("riskId", null); + } + }, [status, setValue]); + const onSubmit = handleSubmit((formData) => { if (!finding.id) return; @@ -263,6 +298,9 @@ export default function FindingDetailsPage(props: Props) { status: formData.status, priority: formData.priority, ownerId: formData.ownerId || undefined, + riskId: formData.status === "RISK_ACCEPTED" + ? formData.riskId || null + : null, }, }, onCompleted() { @@ -286,8 +324,7 @@ export default function FindingDetailsPage(props: Props) { }); }); - const statusOptions = ["OPEN", "IN_PROGRESS", "CLOSED", "MITIGATED", "FALSE_POSITIVE"] as const; - + const statusOptions = ["OPEN", "IN_PROGRESS", "CLOSED", "RISK_ACCEPTED", "MITIGATED", "FALSE_POSITIVE"] as const; const priorityOptions = [ { value: "LOW", label: t("findingDetails.priority.low") }, { value: "MEDIUM", label: t("findingDetails.priority.medium") }, @@ -427,6 +464,18 @@ export default function FindingDetailsPage(props: Props) { />
+ {status === "RISK_ACCEPTED" && ( + + )} +
data.status !== "RISK_ACCEPTED" || Boolean(data.riskId), + { + message: "A risk is required when status is Risk Accepted", + path: ["riskId"], + }, + ); type FormData = z.infer; @@ -111,8 +132,7 @@ export function CreateFindingDialog({ const { toast } = useToast(); const dialogRef = useDialogRef(); const [createFinding] = useMutation(createFindingMutation); - const statusOptions = ["OPEN", "IN_PROGRESS", "CLOSED", "MITIGATED", "FALSE_POSITIVE"] as const; - + const statusOptions = ["OPEN", "IN_PROGRESS", "CLOSED", "RISK_ACCEPTED", "MITIGATED", "FALSE_POSITIVE"] as const; const kindOptions = [ { value: "MINOR_NONCONFORMITY", label: t("createFindingDialog.kinds.minorNonconformity") }, { value: "MAJOR_NONCONFORMITY", label: t("createFindingDialog.kinds.majorNonconformity") }, @@ -126,7 +146,7 @@ export function CreateFindingDialog({ { value: "HIGH", label: t("createFindingDialog.priority.high") }, ]; - const { register, handleSubmit, formState, reset, control } = useFormWithSchema(schema, { + const { register, handleSubmit, formState, reset, control, watch, setValue } = useFormWithSchema(schema, { defaultValues: { kind: "MINOR_NONCONFORMITY" as const, description: "", @@ -138,10 +158,19 @@ export function CreateFindingDialog({ dueDate: "", status: "OPEN" as const, priority: "MEDIUM" as const, + riskId: null, effectivenessCheck: "", }, }); + const status = watch("status"); + + useEffect(() => { + if (status !== "RISK_ACCEPTED") { + setValue("riskId", null); + } + }, [status, setValue]); + const onSubmit = (formData: FormData) => { createFinding({ variables: { @@ -157,6 +186,9 @@ export function CreateFindingDialog({ dueDate: formatDatetime(formData.dueDate), status: formData.status, priority: formData.priority, + riskId: formData.status === "RISK_ACCEPTED" + ? formData.riskId || undefined + : undefined, effectivenessCheck: formData.effectivenessCheck || undefined, }, connections: connectionIds ?? [], @@ -298,6 +330,17 @@ export function CreateFindingDialog({ />
+ {status === "RISK_ACCEPTED" && ( + + )} +