Add Risk Accepted status to finding forms
Findings create and update views filtered out RISK_ACCEPTED even though the API requires a linked risk for that status. Restore the option, collect riskId via a searchable paginated picker, and clear stale links when status changes. Signed-off-by: Cursor Agent <cursoragent@cursor.com> Co-authored-by: Bryan FRIMIN <bryan@frimin.fr>
This commit is contained in:
committed by
Bryan Frimin
parent
9abea50507
commit
51144d25fc
@@ -348,12 +348,12 @@
|
|||||||
"breadcrumbs": { "findings": "Findings" },
|
"breadcrumbs": { "findings": "Findings" },
|
||||||
"unknown": "Unknown finding",
|
"unknown": "Unknown finding",
|
||||||
"kinds": { "minor_nonconformity": "Minor nonconformity", "major_nonconformity": "Major nonconformity", "observation": "Observation", "exception": "Exception" },
|
"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" },
|
"priority": { "low": "Low", "medium": "Medium", "high": "High" },
|
||||||
"messages": { "successTitle": "Success", "deleted": "Finding deleted successfully", "updated": "Finding updated successfully" },
|
"messages": { "successTitle": "Success", "deleted": "Finding deleted successfully", "updated": "Finding updated successfully" },
|
||||||
"errors": { "title": "Error", "delete": "Failed to delete finding", "update": "Failed to update finding" },
|
"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.",
|
"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" },
|
"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" }
|
"actions": { "delete": "Delete", "updating": "Updating...", "update": "Update" }
|
||||||
},
|
},
|
||||||
@@ -374,11 +374,11 @@
|
|||||||
"createFindingDialog": {
|
"createFindingDialog": {
|
||||||
"breadcrumbs": { "findings": "Findings", "create": "Create" },
|
"breadcrumbs": { "findings": "Findings", "create": "Create" },
|
||||||
"kinds": { "minorNonconformity": "Minor nonconformity", "majorNonconformity": "Major nonconformity", "observation": "Observation", "exception": "Exception" },
|
"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" },
|
"priority": { "low": "Low", "medium": "Medium", "high": "High" },
|
||||||
"messages": { "successTitle": "Success", "created": "Finding created successfully" },
|
"messages": { "successTitle": "Success", "created": "Finding created successfully" },
|
||||||
"errors": { "title": "Error", "create": "Failed to create finding" },
|
"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..." },
|
"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" }
|
"actions": { "creating": "Creating...", "create": "Create finding" }
|
||||||
},
|
},
|
||||||
@@ -610,6 +610,10 @@
|
|||||||
"placeholder": "Select an owner",
|
"placeholder": "Select an owner",
|
||||||
"none": "None"
|
"none": "None"
|
||||||
},
|
},
|
||||||
|
"riskSelectField": {
|
||||||
|
"placeholder": "Select a risk",
|
||||||
|
"none": "None"
|
||||||
|
},
|
||||||
"processingActivityEnumOptions": {
|
"processingActivityEnumOptions": {
|
||||||
"specialOrCriminalData": {
|
"specialOrCriminalData": {
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
|
|||||||
199
apps/console/src/components/form/RiskSelectField.tsx
Normal file
199
apps/console/src/components/form/RiskSelectField.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
organizationId: string;
|
||||||
|
control: Control<TFieldValues>;
|
||||||
|
name: TName;
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
optional?: boolean;
|
||||||
|
selectedRisk?: SelectedRisk | null;
|
||||||
|
} & ComponentProps<typeof Field>;
|
||||||
|
|
||||||
|
export function RiskSelectField<TFieldValues extends FieldValues = FieldValues>({
|
||||||
|
organizationId,
|
||||||
|
control,
|
||||||
|
disabled,
|
||||||
|
optional,
|
||||||
|
selectedRisk,
|
||||||
|
...props
|
||||||
|
}: Props<TFieldValues>) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field {...props}>
|
||||||
|
<Suspense
|
||||||
|
fallback={(
|
||||||
|
<Combobox
|
||||||
|
onSearch={() => {}}
|
||||||
|
placeholder={t("riskSelectField.placeholder")}
|
||||||
|
disabled
|
||||||
|
>
|
||||||
|
<div />
|
||||||
|
</Combobox>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RiskSelectWithQuery<TFieldValues>
|
||||||
|
organizationId={organizationId}
|
||||||
|
control={control}
|
||||||
|
name={props.name}
|
||||||
|
disabled={disabled}
|
||||||
|
optional={optional}
|
||||||
|
selectedRisk={selectedRisk}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RiskSelectWithQuery<TFieldValues extends FieldValues = FieldValues>(
|
||||||
|
props: Pick<
|
||||||
|
Props<TFieldValues>,
|
||||||
|
| "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 (
|
||||||
|
<Controller
|
||||||
|
control={control}
|
||||||
|
name={name}
|
||||||
|
render={({ field }) => {
|
||||||
|
const selectedFromList = field.value
|
||||||
|
? risks.find(risk => risk.id === field.value)
|
||||||
|
: null;
|
||||||
|
const selected
|
||||||
|
= selectedFromList
|
||||||
|
?? (selectedRisk && selectedRisk.id === field.value
|
||||||
|
? selectedRisk
|
||||||
|
: null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Combobox
|
||||||
|
id={name}
|
||||||
|
name={field.name}
|
||||||
|
ref={field.ref}
|
||||||
|
onBlur={field.onBlur}
|
||||||
|
placeholder={t("riskSelectField.placeholder")}
|
||||||
|
value={search || selected?.name || ""}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
{optional && (
|
||||||
|
<ComboboxItem
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(null);
|
||||||
|
setSearch("");
|
||||||
|
refetchSearch("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("riskSelectField.none")}
|
||||||
|
</ComboboxItem>
|
||||||
|
)}
|
||||||
|
{risks.map(risk => (
|
||||||
|
<ComboboxItem
|
||||||
|
key={risk.id}
|
||||||
|
onClick={() => {
|
||||||
|
field.onChange(risk.id);
|
||||||
|
setSearch(risk.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="space-y-1 text-start min-w-0">
|
||||||
|
<div className="max-w-75 ellipsis overflow-hidden whitespace-pre-wrap">
|
||||||
|
{risk.name}
|
||||||
|
</div>
|
||||||
|
{risk.category && (
|
||||||
|
<div className="text-sm text-txt-secondary">
|
||||||
|
{risk.category}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ComboboxItem>
|
||||||
|
))}
|
||||||
|
{hasNext && (
|
||||||
|
<InfiniteScrollTrigger
|
||||||
|
loading={isLoadingNext}
|
||||||
|
onView={() => loadNext(50)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Combobox>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
apps/console/src/hooks/graph/usePaginatedRisks.ts
Normal file
86
apps/console/src/hooks/graph/usePaginatedRisks.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||||
|
//
|
||||||
|
// 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<usePaginatedRisksQuery>(
|
||||||
|
risksQuery,
|
||||||
|
{
|
||||||
|
organizationId,
|
||||||
|
},
|
||||||
|
{ fetchPolicy: "network-only" },
|
||||||
|
);
|
||||||
|
return usePaginationFragment<usePaginatedRisksQuery_fragment, usePaginatedRisksFragment$key>(
|
||||||
|
risksFragment,
|
||||||
|
query.organization as usePaginatedRisksFragment$key,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ import {
|
|||||||
useConfirm,
|
useConfirm,
|
||||||
useToast,
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { Controller } from "react-hook-form";
|
import { Controller } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
@@ -56,6 +57,7 @@ import type { FindingDetailsPageQuery } from "#/__generated__/core/FindingDetail
|
|||||||
import type { FindingDetailsPageUpdateMutation } from "#/__generated__/core/FindingDetailsPageUpdateMutation.graphql";
|
import type { FindingDetailsPageUpdateMutation } from "#/__generated__/core/FindingDetailsPageUpdateMutation.graphql";
|
||||||
import { ControlledField } from "#/components/form/ControlledField";
|
import { ControlledField } from "#/components/form/ControlledField";
|
||||||
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
||||||
|
import { RiskSelectField } from "#/components/form/RiskSelectField";
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
@@ -80,6 +82,10 @@ export const findingDetailsPageQuery = graphql`
|
|||||||
owner {
|
owner {
|
||||||
id
|
id
|
||||||
}
|
}
|
||||||
|
risk {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
canUpdate: permission(action: "core:finding:update")
|
canUpdate: permission(action: "core:finding:update")
|
||||||
canDelete: permission(action: "core:finding:delete")
|
canDelete: permission(action: "core:finding:delete")
|
||||||
}
|
}
|
||||||
@@ -107,6 +113,10 @@ const updateFindingMutation = graphql`
|
|||||||
id
|
id
|
||||||
fullName
|
fullName
|
||||||
}
|
}
|
||||||
|
risk {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
updatedAt
|
updatedAt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,18 +134,34 @@ const deleteFindingMutation = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const updateFindingSchema = z.object({
|
const updateFindingSchema = z
|
||||||
description: z.string().optional(),
|
.object({
|
||||||
source: z.string().optional(),
|
description: z.string().optional(),
|
||||||
identifiedOn: z.string().optional(),
|
source: z.string().optional(),
|
||||||
dueDate: z.string().optional(),
|
identifiedOn: z.string().optional(),
|
||||||
rootCause: z.string().optional(),
|
dueDate: z.string().optional(),
|
||||||
correctiveAction: z.string().optional(),
|
rootCause: z.string().optional(),
|
||||||
effectivenessCheck: z.string().optional(),
|
correctiveAction: z.string().optional(),
|
||||||
status: z.enum(["OPEN", "IN_PROGRESS", "CLOSED", "MITIGATED", "FALSE_POSITIVE", "RISK_ACCEPTED"]),
|
effectivenessCheck: z.string().optional(),
|
||||||
priority: z.enum(["LOW", "MEDIUM", "HIGH"]),
|
status: z.enum([
|
||||||
ownerId: z.string().nullable().optional(),
|
"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 = {
|
type Props = {
|
||||||
queryRef: PreloadedQuery<FindingDetailsPageQuery>;
|
queryRef: PreloadedQuery<FindingDetailsPageQuery>;
|
||||||
@@ -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, {
|
= useFormWithSchema(updateFindingSchema, {
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
description: finding.description || "",
|
description: finding.description || "",
|
||||||
@@ -243,9 +269,18 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
status: finding.status || "OPEN",
|
status: finding.status || "OPEN",
|
||||||
priority: finding.priority || "MEDIUM",
|
priority: finding.priority || "MEDIUM",
|
||||||
ownerId: finding.owner?.id ?? null,
|
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) => {
|
const onSubmit = handleSubmit((formData) => {
|
||||||
if (!finding.id) return;
|
if (!finding.id) return;
|
||||||
|
|
||||||
@@ -263,6 +298,9 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
status: formData.status,
|
status: formData.status,
|
||||||
priority: formData.priority,
|
priority: formData.priority,
|
||||||
ownerId: formData.ownerId || undefined,
|
ownerId: formData.ownerId || undefined,
|
||||||
|
riskId: formData.status === "RISK_ACCEPTED"
|
||||||
|
? formData.riskId || null
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
onCompleted() {
|
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 = [
|
const priorityOptions = [
|
||||||
{ value: "LOW", label: t("findingDetails.priority.low") },
|
{ value: "LOW", label: t("findingDetails.priority.low") },
|
||||||
{ value: "MEDIUM", label: t("findingDetails.priority.medium") },
|
{ value: "MEDIUM", label: t("findingDetails.priority.medium") },
|
||||||
@@ -427,6 +464,18 @@ export default function FindingDetailsPage(props: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{status === "RISK_ACCEPTED" && (
|
||||||
|
<RiskSelectField
|
||||||
|
organizationId={organizationId}
|
||||||
|
control={control}
|
||||||
|
name="riskId"
|
||||||
|
label={t("findingDetails.fields.acceptedRisk")}
|
||||||
|
error={formState.errors.riskId?.message}
|
||||||
|
selectedRisk={finding.risk}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<Field label={t("findingDetails.fields.dateIdentified")}>
|
<Field label={t("findingDetails.fields.dateIdentified")}>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import {
|
|||||||
useDialogRef,
|
useDialogRef,
|
||||||
useToast,
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode, useEffect } from "react";
|
||||||
import { Controller } from "react-hook-form";
|
import { Controller } from "react-hook-form";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { graphql, useMutation } from "react-relay";
|
import { graphql, useMutation } from "react-relay";
|
||||||
@@ -45,6 +45,7 @@ import { z } from "zod";
|
|||||||
|
|
||||||
import type { CreateFindingDialogMutation } from "#/__generated__/core/CreateFindingDialogMutation.graphql";
|
import type { CreateFindingDialogMutation } from "#/__generated__/core/CreateFindingDialogMutation.graphql";
|
||||||
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
||||||
|
import { RiskSelectField } from "#/components/form/RiskSelectField";
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
|
|
||||||
const createFindingMutation = graphql`
|
const createFindingMutation = graphql`
|
||||||
@@ -71,6 +72,10 @@ const createFindingMutation = graphql`
|
|||||||
id
|
id
|
||||||
fullName
|
fullName
|
||||||
}
|
}
|
||||||
|
risk {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
createdAt
|
createdAt
|
||||||
canUpdate: permission(action: "core:finding:update")
|
canUpdate: permission(action: "core:finding:update")
|
||||||
canDelete: permission(action: "core:finding:delete")
|
canDelete: permission(action: "core:finding:delete")
|
||||||
@@ -80,19 +85,35 @@ const createFindingMutation = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z
|
||||||
kind: z.enum(["MINOR_NONCONFORMITY", "MAJOR_NONCONFORMITY", "OBSERVATION", "EXCEPTION"]),
|
.object({
|
||||||
description: z.string().optional(),
|
kind: z.enum(["MINOR_NONCONFORMITY", "MAJOR_NONCONFORMITY", "OBSERVATION", "EXCEPTION"]),
|
||||||
source: z.string().optional(),
|
description: z.string().optional(),
|
||||||
identifiedOn: z.string().optional(),
|
source: z.string().optional(),
|
||||||
rootCause: z.string().optional(),
|
identifiedOn: z.string().optional(),
|
||||||
correctiveAction: z.string().optional(),
|
rootCause: z.string().optional(),
|
||||||
ownerId: z.string().nullable().optional(),
|
correctiveAction: z.string().optional(),
|
||||||
dueDate: z.string().optional(),
|
ownerId: z.string().nullable().optional(),
|
||||||
status: z.enum(["OPEN", "IN_PROGRESS", "CLOSED", "MITIGATED", "FALSE_POSITIVE"]),
|
dueDate: z.string().optional(),
|
||||||
priority: z.enum(["LOW", "MEDIUM", "HIGH"]),
|
status: z.enum([
|
||||||
effectivenessCheck: z.string().optional(),
|
"OPEN",
|
||||||
});
|
"IN_PROGRESS",
|
||||||
|
"CLOSED",
|
||||||
|
"MITIGATED",
|
||||||
|
"FALSE_POSITIVE",
|
||||||
|
"RISK_ACCEPTED",
|
||||||
|
]),
|
||||||
|
priority: z.enum(["LOW", "MEDIUM", "HIGH"]),
|
||||||
|
riskId: z.string().nullable().optional(),
|
||||||
|
effectivenessCheck: z.string().optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
data => data.status !== "RISK_ACCEPTED" || Boolean(data.riskId),
|
||||||
|
{
|
||||||
|
message: "A risk is required when status is Risk Accepted",
|
||||||
|
path: ["riskId"],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
type FormData = z.infer<typeof schema>;
|
type FormData = z.infer<typeof schema>;
|
||||||
|
|
||||||
@@ -111,8 +132,7 @@ export function CreateFindingDialog({
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const dialogRef = useDialogRef();
|
const dialogRef = useDialogRef();
|
||||||
const [createFinding] = useMutation<CreateFindingDialogMutation>(createFindingMutation);
|
const [createFinding] = useMutation<CreateFindingDialogMutation>(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 = [
|
const kindOptions = [
|
||||||
{ value: "MINOR_NONCONFORMITY", label: t("createFindingDialog.kinds.minorNonconformity") },
|
{ value: "MINOR_NONCONFORMITY", label: t("createFindingDialog.kinds.minorNonconformity") },
|
||||||
{ value: "MAJOR_NONCONFORMITY", label: t("createFindingDialog.kinds.majorNonconformity") },
|
{ value: "MAJOR_NONCONFORMITY", label: t("createFindingDialog.kinds.majorNonconformity") },
|
||||||
@@ -126,7 +146,7 @@ export function CreateFindingDialog({
|
|||||||
{ value: "HIGH", label: t("createFindingDialog.priority.high") },
|
{ 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: {
|
defaultValues: {
|
||||||
kind: "MINOR_NONCONFORMITY" as const,
|
kind: "MINOR_NONCONFORMITY" as const,
|
||||||
description: "",
|
description: "",
|
||||||
@@ -138,10 +158,19 @@ export function CreateFindingDialog({
|
|||||||
dueDate: "",
|
dueDate: "",
|
||||||
status: "OPEN" as const,
|
status: "OPEN" as const,
|
||||||
priority: "MEDIUM" as const,
|
priority: "MEDIUM" as const,
|
||||||
|
riskId: null,
|
||||||
effectivenessCheck: "",
|
effectivenessCheck: "",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const status = watch("status");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (status !== "RISK_ACCEPTED") {
|
||||||
|
setValue("riskId", null);
|
||||||
|
}
|
||||||
|
}, [status, setValue]);
|
||||||
|
|
||||||
const onSubmit = (formData: FormData) => {
|
const onSubmit = (formData: FormData) => {
|
||||||
createFinding({
|
createFinding({
|
||||||
variables: {
|
variables: {
|
||||||
@@ -157,6 +186,9 @@ export function CreateFindingDialog({
|
|||||||
dueDate: formatDatetime(formData.dueDate),
|
dueDate: formatDatetime(formData.dueDate),
|
||||||
status: formData.status,
|
status: formData.status,
|
||||||
priority: formData.priority,
|
priority: formData.priority,
|
||||||
|
riskId: formData.status === "RISK_ACCEPTED"
|
||||||
|
? formData.riskId || undefined
|
||||||
|
: undefined,
|
||||||
effectivenessCheck: formData.effectivenessCheck || undefined,
|
effectivenessCheck: formData.effectivenessCheck || undefined,
|
||||||
},
|
},
|
||||||
connections: connectionIds ?? [],
|
connections: connectionIds ?? [],
|
||||||
@@ -298,6 +330,17 @@ export function CreateFindingDialog({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{status === "RISK_ACCEPTED" && (
|
||||||
|
<RiskSelectField
|
||||||
|
organizationId={organizationId}
|
||||||
|
control={control}
|
||||||
|
name="riskId"
|
||||||
|
label={t("createFindingDialog.fields.acceptedRisk")}
|
||||||
|
error={formState.errors.riskId?.message}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="identifiedOn">{t("createFindingDialog.fields.dateIdentified")}</Label>
|
<Label htmlFor="identifiedOn">{t("createFindingDialog.fields.dateIdentified")}</Label>
|
||||||
|
|||||||
Reference in New Issue
Block a user