Replace console frontend with unified findings pages

Add FindingsPage, FindingDetailsPage, and CreateFindingDialog
supporting all finding kinds (nonconformity, observation, exception)
with filtering, sorting, and audit linking.

Remove the separate nonconformity and continual improvement pages,
routes, and graph hooks. Update sidebar navigation, routes, and
components for nullable audit framework field.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-17 00:38:44 +01:00
parent ad0cf38e76
commit 0e0539f1f3
22 changed files with 1530 additions and 2500 deletions

View File

@@ -105,8 +105,8 @@ function AuditSelectWithQuery<T extends FieldValues = FieldValues>(
<div className="flex items-center justify-between w-full">
<span>
{audit.name
? `${audit.framework.name} - ${audit.name}`
: audit.framework.name}
? `${audit.framework?.name} - ${audit.name}`
: audit.framework?.name}
</span>
<div className="ml-3">
<Badge variant={getAuditStateVariant(audit.state)}>

View File

@@ -136,6 +136,7 @@ type RowProps = {
name: string;
category: string;
id: string;
description?: string | null;
};
linkedRisks: Set<string>;
disabled?: boolean;

View File

@@ -121,7 +121,7 @@ export const deleteAuditMutation = graphql`
`;
export const useDeleteAudit = (
audit: { id: string; framework: { name: string } },
audit: { id: string; framework?: { name: string } | null },
connectionId: string,
onSuccess?: () => void,
) => {
@@ -150,7 +150,7 @@ export const useDeleteAudit = (
__(
"This will permanently delete the audit for %s. This action cannot be undone.",
),
audit.framework.name,
audit.framework?.name ?? "",
),
},
);

View File

@@ -1,232 +0,0 @@
import { promisifyMutation, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { useConfirm } from "@probo/ui";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "../useMutationWithToasts";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
export const ContinualImprovementsConnectionKey
= "ContinualImprovementsPage_continualImprovements";
export const continualImprovementsQuery = graphql`
query ContinualImprovementGraphListQuery(
$organizationId: ID!
$snapshotId: ID
) {
node(id: $organizationId) {
... on Organization {
canCreateContinualImprovement: permission(
action: "core:continual-improvement:create"
)
...ContinualImprovementsPageFragment @arguments(snapshotId: $snapshotId)
}
}
}
`;
export const continualImprovementNodeQuery = graphql`
query ContinualImprovementGraphNodeQuery($continualImprovementId: ID!) {
node(id: $continualImprovementId) {
... on ContinualImprovement {
id
snapshotId
sourceId
referenceId
description
source
targetDate
status
priority
owner {
id
fullName
}
organization {
id
name
}
createdAt
updatedAt
canUpdate: permission(action: "core:continual-improvement:update")
canDelete: permission(action: "core:continual-improvement:delete")
}
}
}
`;
export const createContinualImprovementMutation = graphql`
mutation ContinualImprovementGraphCreateMutation(
$input: CreateContinualImprovementInput!
$connections: [ID!]!
) {
createContinualImprovement(input: $input) {
continualImprovementEdge @prependEdge(connections: $connections) {
node {
id
referenceId
description
source
targetDate
status
priority
owner {
id
fullName
}
createdAt
canUpdate: permission(action: "core:continual-improvement:update")
canDelete: permission(action: "core:continual-improvement:delete")
}
}
}
}
`;
export const updateContinualImprovementMutation = graphql`
mutation ContinualImprovementGraphUpdateMutation(
$input: UpdateContinualImprovementInput!
) {
updateContinualImprovement(input: $input) {
continualImprovement {
id
referenceId
description
source
targetDate
status
priority
owner {
id
fullName
}
updatedAt
}
}
}
`;
export const deleteContinualImprovementMutation = graphql`
mutation ContinualImprovementGraphDeleteMutation(
$input: DeleteContinualImprovementInput!
$connections: [ID!]!
) {
deleteContinualImprovement(input: $input) {
deletedContinualImprovementId @deleteEdge(connections: $connections)
}
}
`;
export const useDeleteContinualImprovement = (
improvement: { id: string; referenceId: string },
connectionId: string,
) => {
const { __ } = useTranslate();
const [mutate] = useMutationWithToasts(deleteContinualImprovementMutation, {
successMessage: __("Continual improvement deleted successfully"),
errorMessage: __("Failed to delete continual improvement"),
});
const confirm = useConfirm();
return () => {
confirm(
() =>
mutate({
variables: {
input: {
continualImprovementId: improvement.id,
},
connections: [connectionId],
},
}),
{
message: sprintf(
__(
"This will permanently delete the continual improvement %s. This action cannot be undone.",
),
improvement.referenceId,
),
},
);
};
};
export const useCreateContinualImprovement = (connectionId: string) => {
// eslint-disable-next-line relay/generated-typescript-types
const [mutate] = useMutation(createContinualImprovementMutation);
const { __ } = useTranslate();
return (input: {
organizationId: string;
referenceId: string;
description?: string;
source?: string;
ownerId: string;
targetDate?: string;
status: string;
priority: string;
}) => {
if (!input.organizationId) {
return alert(
__("Failed to create continual improvement: organization is required"),
);
}
if (!input.referenceId) {
return alert(
__("Failed to create continual improvement: reference ID is required"),
);
}
if (!input.ownerId) {
return alert(
__("Failed to create continual improvement: owner is required"),
);
}
return promisifyMutation(mutate)({
variables: {
input: {
organizationId: input.organizationId,
referenceId: input.referenceId,
description: input.description,
source: input.source,
ownerId: input.ownerId,
targetDate: input.targetDate,
status: input.status || "OPEN",
priority: input.priority || "MEDIUM",
},
connections: [connectionId],
},
});
};
};
export const useUpdateContinualImprovement = () => {
// eslint-disable-next-line relay/generated-typescript-types
const [mutate] = useMutation(updateContinualImprovementMutation);
const { __ } = useTranslate();
return (input: {
id: string;
referenceId?: string;
description?: string;
source?: string;
ownerId?: string;
targetDate?: string | null;
status?: string;
priority?: string;
}) => {
if (!input.id) {
return alert(
__("Failed to update continual improvement: ID is required"),
);
}
return promisifyMutation(mutate)({
variables: {
input,
},
});
};
};

View File

@@ -1,261 +0,0 @@
import { promisifyMutation, sprintf } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import { useConfirm } from "@probo/ui";
import { useMutation } from "react-relay";
import { graphql } from "relay-runtime";
import { useMutationWithToasts } from "../useMutationWithToasts";
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
export const NonconformitiesConnectionKey
= "NonconformitiesPage_nonconformities";
export const nonconformitiesQuery = graphql`
query NonconformityGraphListQuery($organizationId: ID!, $snapshotId: ID) {
node(id: $organizationId) {
... on Organization {
canCreateNonconformity: permission(action: "core:nonconformity:create")
...NonconformitiesPageFragment @arguments(snapshotId: $snapshotId)
}
}
}
`;
export const nonconformityNodeQuery = graphql`
query NonconformityGraphNodeQuery($nonconformityId: ID!) {
node(id: $nonconformityId) {
... on Nonconformity {
id
snapshotId
referenceId
description
dateIdentified
rootCause
correctiveAction
dueDate
status
effectivenessCheck
audit {
id
framework {
id
name
}
}
owner {
id
fullName
}
organization {
id
name
}
createdAt
updatedAt
canUpdate: permission(action: "core:nonconformity:update")
canDelete: permission(action: "core:nonconformity:delete")
}
}
}
`;
export const createNonconformityMutation = graphql`
mutation NonconformityGraphCreateMutation(
$input: CreateNonconformityInput!
$connections: [ID!]!
) {
createNonconformity(input: $input) {
nonconformityEdge @prependEdge(connections: $connections) {
node {
id
referenceId
description
status
dateIdentified
dueDate
rootCause
audit {
id
framework {
name
}
}
owner {
id
fullName
}
createdAt
canUpdate: permission(action: "core:nonconformity:update")
canDelete: permission(action: "core:nonconformity:delete")
}
}
}
}
`;
export const updateNonconformityMutation = graphql`
mutation NonconformityGraphUpdateMutation($input: UpdateNonconformityInput!) {
updateNonconformity(input: $input) {
nonconformity {
id
referenceId
description
dateIdentified
rootCause
correctiveAction
dueDate
status
effectivenessCheck
owner {
id
fullName
}
audit {
id
framework {
id
name
}
}
updatedAt
}
}
}
`;
export const deleteNonconformityMutation = graphql`
mutation NonconformityGraphDeleteMutation(
$input: DeleteNonconformityInput!
$connections: [ID!]!
) {
deleteNonconformity(input: $input) {
deletedNonconformityId @deleteEdge(connections: $connections)
}
}
`;
export const useDeleteNonconformity = (
nonconformity: { id: string; referenceId: string },
connectionId: string,
) => {
const { __ } = useTranslate();
const [mutate] = useMutationWithToasts(deleteNonconformityMutation, {
successMessage: __("Nonconformity deleted successfully"),
errorMessage: __("Failed to delete nonconformity"),
});
const confirm = useConfirm();
return () => {
confirm(
() =>
mutate({
variables: {
input: {
nonconformityId: nonconformity.id,
},
connections: [connectionId],
},
}),
{
message: sprintf(
__(
"This will permanently delete the nonconformity %s. This action cannot be undone.",
),
nonconformity.referenceId,
),
},
);
};
};
export const useCreateNonconformity = (connectionId: string) => {
// eslint-disable-next-line relay/generated-typescript-types
const [mutate] = useMutation(createNonconformityMutation);
const { __ } = useTranslate();
return (input: {
organizationId: string;
referenceId: string;
description?: string;
auditId?: string;
dateIdentified?: string;
rootCause: string;
correctiveAction?: string;
ownerId: string;
dueDate?: string;
status: string;
effectivenessCheck?: string;
}) => {
if (!input.organizationId) {
return alert(
__("Failed to create nonconformity: organization is required"),
);
}
if (!input.referenceId) {
return alert(
__("Failed to create nonconformity: reference ID is required"),
);
}
if (!input.ownerId) {
return alert(__("Failed to create nonconformity: owner is required"));
}
if (!input.rootCause) {
return alert(
__("Failed to create nonconformity: root cause is required"),
);
}
return promisifyMutation(mutate)({
variables: {
input: {
organizationId: input.organizationId,
referenceId: input.referenceId,
description: input.description,
auditId: input.auditId || undefined,
dateIdentified: input.dateIdentified,
rootCause: input.rootCause,
correctiveAction: input.correctiveAction,
ownerId: input.ownerId,
dueDate: input.dueDate,
status: input.status || "OPEN",
effectivenessCheck: input.effectivenessCheck,
},
connections: [connectionId],
},
});
};
};
export const useUpdateNonconformity = () => {
// eslint-disable-next-line relay/generated-typescript-types
const [mutate] = useMutation(updateNonconformityMutation);
const { __ } = useTranslate();
return (input: {
id: string;
referenceId?: string;
description?: string;
dateIdentified?: string | null;
rootCause?: string;
correctiveAction?: string;
ownerId?: string;
auditId?: string | null;
dueDate?: string | null;
status?: string;
effectivenessCheck?: string;
}) => {
if (!input.id) {
return alert(__("Failed to update nonconformity: ID is required"));
}
return promisifyMutation(mutate)({
variables: {
input: {
...input,
auditId: input.auditId || null,
},
},
});
};
};

View File

@@ -6,16 +6,15 @@ import {
IconCalendar1,
IconCircleProgress,
IconClock,
IconCrossLargeX,
IconFire3,
IconGroup1,
IconInboxEmpty,
IconListStack,
IconLock,
IconMagnifyingGlass,
IconMedal,
IconPageCheck,
IconPageTextLine,
IconRotateCw,
IconSettingsGear2,
IconShield,
IconStore,
@@ -41,11 +40,8 @@ const fragment = graphql`
canListAssets: permission(action: "core:asset:list")
canListData: permission(action: "core:datum:list")
canListAudits: permission(action: "core:audit:list")
canListNonconformities: permission(action: "core:nonconformity:list")
canListFindings: permission(action: "core:finding:list")
canListObligations: permission(action: "core:obligation:list")
canListContinualImprovements: permission(
action: "core:continual-improvement:list"
)
canListProcessingActivities: permission(
action: "core:processing-activity:list"
)
@@ -148,11 +144,11 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) {
to={`${prefix}/audits`}
/>
)}
{organization.canListNonconformities && (
{organization.canListFindings && (
<SidebarItem
label={__("Nonconformities")}
icon={IconCrossLargeX}
to={`${prefix}/nonconformities`}
label={__("Findings")}
icon={IconMagnifyingGlass}
to={`${prefix}/findings`}
/>
)}
{organization.canListObligations && (
@@ -162,13 +158,6 @@ export function Sidebar(props: { fKey: SidebarFragment$key }) {
to={`${prefix}/obligations`}
/>
)}
{organization.canListContinualImprovements && (
<SidebarItem
label={__("Continual Improvements")}
icon={IconRotateCw}
to={`${prefix}/continual-improvements`}
/>
)}
{organization.canListProcessingActivities && (
<SidebarItem
label={__("Processing Activities")}

View File

@@ -88,7 +88,7 @@ export function CompliancePageAuditListItem(props: {
return (
<Tr to={`/organizations/${organizationId}/audits/${audit.id}`}>
<Td>
<div className="flex gap-4 items-center">{audit.framework.name}</div>
<div className="flex gap-4 items-center">{audit.framework?.name}</div>
</Td>
<Td>{audit.name || __("Untitled")}</Td>
<Td>{validUntilFormatted}</Td>

View File

@@ -1,339 +0,0 @@
import { formatError, type GraphQLError } from "@probo/helpers";
import {
formatDatetime,
getStatusLabel,
getStatusVariant,
validateSnapshotConsistency,
} from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Badge,
Breadcrumb,
Button,
Card,
DropdownItem,
Field,
Input,
Label,
Option,
Select,
Textarea,
useToast,
} from "@probo/ui";
import { Controller } from "react-hook-form";
import {
ConnectionHandler,
type PreloadedQuery,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { z } from "zod";
import type { ContinualImprovementGraphNodeQuery } from "#/__generated__/core/ContinualImprovementGraphNodeQuery.graphql";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import {
continualImprovementNodeQuery,
ContinualImprovementsConnectionKey,
useDeleteContinualImprovement,
useUpdateContinualImprovement,
} from "../../../hooks/graph/ContinualImprovementGraph";
const updateImprovementSchema = z.object({
referenceId: z.string().min(1, "Reference ID is required"),
description: z.string().optional(),
source: z.string().optional(),
targetDate: z.string().optional(),
status: z.enum(["OPEN", "IN_PROGRESS", "CLOSED"]),
priority: z.enum(["LOW", "MEDIUM", "HIGH"]),
ownerId: z.string().min(1, "Owner is required"),
});
type Props = {
queryRef: PreloadedQuery<ContinualImprovementGraphNodeQuery>;
};
export default function ContinualImprovementDetailsPage(props: Props) {
const { node: improvement }
= usePreloadedQuery<ContinualImprovementGraphNodeQuery>(
continualImprovementNodeQuery,
props.queryRef,
);
const { __ } = useTranslate();
const { toast } = useToast();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
validateSnapshotConsistency(improvement, snapshotId);
const updateImprovement = useUpdateContinualImprovement();
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
ContinualImprovementsConnectionKey,
{ filter: { snapshotId: snapshotId || null } },
);
const deleteImprovement = useDeleteContinualImprovement(
{ id: improvement.id!, referenceId: improvement.referenceId! },
connectionId,
);
const { register, handleSubmit, formState, control } = useFormWithSchema(
updateImprovementSchema,
{
defaultValues: {
referenceId: improvement.referenceId || "",
description: improvement.description || "",
source: improvement.source || "",
targetDate: improvement.targetDate
? new Date(improvement.targetDate).toISOString().split("T")[0]
: "",
status: improvement.status || "OPEN",
priority: improvement.priority || "MEDIUM",
ownerId: improvement.owner?.id || "",
},
},
);
const onSubmit = handleSubmit(async (formData) => {
try {
await updateImprovement({
id: improvement.id!,
referenceId: formData.referenceId,
description: formData.description || undefined,
source: formData.source || undefined,
targetDate: formatDatetime(formData.targetDate) ?? null,
status: formData.status,
priority: formData.priority,
ownerId: formData.ownerId,
});
toast({
title: __("Success"),
description: __("Continual improvement entry updated successfully"),
variant: "success",
});
} catch (error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to update continual improvement"),
error as GraphQLError,
),
variant: "error",
});
}
});
const statusOptions = [
{ value: "OPEN", label: __("Open") },
{ value: "IN_PROGRESS", label: __("In Progress") },
{ value: "CLOSED", label: __("Closed") },
];
const priorityOptions = [
{ value: "LOW", label: __("Low") },
{ value: "MEDIUM", label: __("Medium") },
{ value: "HIGH", label: __("High") },
];
const breadcrumbImprovementsUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${snapshotId}/continual-improvements`
: `/organizations/${organizationId}/continual-improvements`;
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<div className="flex items-center justify-between">
<Breadcrumb
items={[
{
label: __("Continual Improvements"),
to: breadcrumbImprovementsUrl,
},
{ label: improvement.referenceId! },
]}
/>
{!isSnapshotMode && improvement.canDelete && (
<ActionDropdown>
<DropdownItem onClick={deleteImprovement} variant="danger">
{__("Delete")}
</DropdownItem>
</ActionDropdown>
)}
</div>
<Card>
<div className="p-6">
<div className="mb-6">
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">{improvement.referenceId}</h1>
<Badge variant={getStatusVariant(improvement.status || "OPEN")}>
{getStatusLabel(improvement.status || "OPEN")}
</Badge>
<Badge
variant={
improvement.priority === "HIGH"
? "danger"
: improvement.priority === "MEDIUM"
? "warning"
: "success"
}
>
{improvement.priority === "HIGH"
? __("High")
: improvement.priority === "MEDIUM"
? __("Medium")
: __("Low")}
</Badge>
</div>
</div>
<form onSubmit={e => void onSubmit(e)} className="space-y-4">
<Field
label={__("Reference ID")}
{...register("referenceId")}
error={formState.errors.referenceId?.message}
readOnly={isSnapshotMode}
required
/>
<div>
<Label>{__("Description")}</Label>
<Textarea
{...register("description")}
placeholder={__("Enter description")}
rows={3}
readOnly={isSnapshotMode}
/>
{formState.errors.description?.message && (
<div className="text-red-500 text-sm mt-1">
{formState.errors.description.message}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Field
label={__("Source")}
{...register("source")}
error={formState.errors.source?.message}
readOnly={isSnapshotMode}
/>
<div>
<Label>{__("Target Date")}</Label>
<Input
type="date"
{...register("targetDate")}
readOnly={isSnapshotMode}
/>
{formState.errors.targetDate?.message && (
<div className="text-red-500 text-sm mt-1">
{formState.errors.targetDate.message}
</div>
)}
</div>
</div>
<PeopleSelectField
organizationId={organizationId}
control={control}
name="ownerId"
label={__("Owner")}
error={formState.errors.ownerId?.message}
disabled={isSnapshotMode}
required
/>
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="status"
render={({ field }) => (
<div>
<Label>
{__("Status")}
{" "}
*
</Label>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={isSnapshotMode}
>
{statusOptions.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
{formState.errors.status?.message && (
<div className="text-red-500 text-sm mt-1">
{formState.errors.status.message}
</div>
)}
</div>
)}
/>
<Controller
control={control}
name="priority"
render={({ field }) => (
<div>
<Label>
{__("Priority")}
{" "}
*
</Label>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={isSnapshotMode}
>
{priorityOptions.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
{formState.errors.priority?.message && (
<div className="text-red-500 text-sm mt-1">
{formState.errors.priority.message}
</div>
)}
</div>
)}
/>
</div>
{!isSnapshotMode && (
<div className="flex justify-end pt-4">
{improvement.canUpdate && (
<Button
type="submit"
variant="primary"
disabled={formState.isSubmitting}
>
{formState.isSubmitting
? __("Saving...")
: __("Save Changes")}
</Button>
)}
</div>
)}
</form>
</div>
</Card>
</div>
);
}

View File

@@ -1,311 +0,0 @@
import {
formatDate,
getStatusLabel,
getStatusVariant,
promisifyMutation,
sprintf,
} from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Badge,
Button,
Card,
DropdownItem,
IconPlusLarge,
IconTrashCan,
PageHeader,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
useConfirm,
} from "@probo/ui";
import {
ConnectionHandler,
graphql,
type PreloadedQuery,
useMutation,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import type { ContinualImprovementGraphDeleteMutation } from "#/__generated__/core/ContinualImprovementGraphDeleteMutation.graphql";
import type { ContinualImprovementGraphListQuery } from "#/__generated__/core/ContinualImprovementGraphListQuery.graphql";
import type {
ContinualImprovementsPageFragment$data,
ContinualImprovementsPageFragment$key,
} from "#/__generated__/core/ContinualImprovementsPageFragment.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import type { NodeOf } from "#/types";
import {
ContinualImprovementsConnectionKey,
continualImprovementsQuery,
deleteContinualImprovementMutation,
} from "../../../hooks/graph/ContinualImprovementGraph";
import { CreateContinualImprovementDialog } from "./dialogs/CreateContinualImprovementDialog";
interface ContinualImprovementsPageProps {
queryRef: PreloadedQuery<ContinualImprovementGraphListQuery>;
}
const continualImprovementsPageFragment = graphql`
fragment ContinualImprovementsPageFragment on Organization
@refetchable(queryName: "ContinualImprovementsPageRefetchQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 10 }
after: { type: "CursorKey" }
snapshotId: { type: "ID", defaultValue: null }
) {
id
continualImprovements(
first: $first
after: $after
filter: { snapshotId: $snapshotId }
)
@connection(
key: "ContinualImprovementsPage_continualImprovements"
filters: ["filter"]
) {
edges {
node {
id
snapshotId
referenceId
description
targetDate
status
priority
owner {
id
fullName
}
canUpdate: permission(action: "core:continual-improvement:update")
canDelete: permission(action: "core:continual-improvement:delete")
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
export default function ContinualImprovementsPage({
queryRef,
}: ContinualImprovementsPageProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
usePageTitle(__("Continual Improvements"));
const organization = usePreloadedQuery(continualImprovementsQuery, queryRef);
const { data, loadNext, hasNext, isLoadingNext } = usePaginationFragment<
ContinualImprovementGraphListQuery,
ContinualImprovementsPageFragment$key
>(continualImprovementsPageFragment, organization.node);
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
ContinualImprovementsConnectionKey,
{ filter: { snapshotId: snapshotId || null } },
);
const improvements
= data?.continualImprovements?.edges?.map(edge => edge.node) ?? [];
const hasAnyAction
= !isSnapshotMode
&& improvements.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<PageHeader
title={__("Continual Improvements")}
description={__("Manage your continual improvements.")}
>
{!isSnapshotMode && organization.node.canCreateContinualImprovement && (
<CreateContinualImprovementDialog
organizationId={organizationId}
connectionId={connectionId}
>
<Button icon={IconPlusLarge}>
{__("Add continual improvement")}
</Button>
</CreateContinualImprovementDialog>
)}
</PageHeader>
{improvements.length > 0
? (
<Card>
<Table>
<Thead>
<Tr>
<Th>{__("Reference ID")}</Th>
<Th>{__("Description")}</Th>
<Th>{__("Status")}</Th>
<Th>{__("Priority")}</Th>
<Th>{__("Owner")}</Th>
<Th>{__("Target Date")}</Th>
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
{improvements.map(improvement => (
<ImprovementRow
key={improvement.id}
improvement={improvement}
connectionId={connectionId}
snapshotId={snapshotId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
</Table>
{hasNext && (
<div className="p-4 border-t">
<Button
variant="secondary"
onClick={() => loadNext(10)}
disabled={isLoadingNext}
>
{isLoadingNext ? __("Loading...") : __("Load more")}
</Button>
</div>
)}
</Card>
)
: (
<Card padded>
<div className="text-center py-12">
<h3 className="text-lg font-semibold mb-2">
{__("No continual improvements yet")}
</h3>
<p className="text-txt-tertiary mb-4">
{__("Create your first continual improvement to get started.")}
</p>
</div>
</Card>
)}
</div>
);
}
function ImprovementRow({
improvement,
connectionId,
snapshotId,
hasAnyAction,
}: {
improvement: NodeOf<
NonNullable<ContinualImprovementsPageFragment$data["continualImprovements"]>
>;
connectionId: string;
snapshotId?: string;
hasAnyAction: boolean;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const [deleteImprovement] = useMutation<ContinualImprovementGraphDeleteMutation>(deleteContinualImprovementMutation);
const confirm = useConfirm();
const isSnapshotMode = Boolean(snapshotId);
const handleDelete = () => {
confirm(
() =>
promisifyMutation(deleteImprovement)({
variables: {
input: {
continualImprovementId: improvement.id,
},
connections: [connectionId],
},
}),
{
message: sprintf(
__(
"This will permanently delete the continual improvement entry %s. This action cannot be undone.",
),
improvement.referenceId,
),
},
);
};
const detailsUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${snapshotId}/continual-improvements/${improvement.id}`
: `/organizations/${organizationId}/continual-improvements/${improvement.id}`;
return (
<Tr to={detailsUrl}>
<Td>
<span className="font-mono text-sm">{improvement.referenceId}</span>
</Td>
<Td>{improvement.description || "-"}</Td>
<Td>
<Badge variant={getStatusVariant(improvement.status)}>
{getStatusLabel(improvement.status)}
</Badge>
</Td>
<Td>
<Badge
variant={
improvement.priority === "HIGH"
? "danger"
: improvement.priority === "MEDIUM"
? "warning"
: "success"
}
>
{improvement.priority === "HIGH"
? __("High")
: improvement.priority === "MEDIUM"
? __("Medium")
: __("Low")}
</Badge>
</Td>
<Td>{improvement.owner?.fullName || "-"}</Td>
<Td>
{improvement.targetDate
? (
<time dateTime={improvement.targetDate}>
{formatDate(improvement.targetDate)}
</time>
)
: (
<span className="text-txt-tertiary">{__("No target date")}</span>
)}
</Td>
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
{improvement.canDelete && (
<DropdownItem
icon={IconTrashCan}
variant="danger"
onSelect={handleDelete}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
</Tr>
);
}

View File

@@ -1,246 +0,0 @@
import { formatError, type GraphQLError } from "@probo/helpers";
import { formatDatetime } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
Button,
Dialog,
DialogContent,
DialogFooter,
Field,
Input,
Label,
Option,
Select,
Textarea,
useDialogRef,
useToast,
} from "@probo/ui";
import { type ReactNode } from "react";
import { Controller } from "react-hook-form";
import { z } from "zod";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useCreateContinualImprovement } from "../../../../hooks/graph/ContinualImprovementGraph";
const schema = z.object({
referenceId: z.string().min(1, "Reference ID is required"),
description: z.string().optional(),
source: z.string().optional(),
ownerId: z.string().min(1, "Owner is required"),
targetDate: z.string().optional(),
status: z.enum(["OPEN", "IN_PROGRESS", "CLOSED"]),
priority: z.enum(["LOW", "MEDIUM", "HIGH"]),
});
type FormData = z.infer<typeof schema>;
interface CreateContinualImprovementDialogProps {
children: ReactNode;
organizationId: string;
connectionId?: string;
}
export function CreateContinualImprovementDialog({
children,
organizationId,
connectionId,
}: CreateContinualImprovementDialogProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const createImprovement = useCreateContinualImprovement(connectionId || "");
const { register, handleSubmit, formState, reset, control } = useFormWithSchema(schema, {
defaultValues: {
referenceId: "",
description: "",
source: "",
ownerId: "",
targetDate: "",
status: "OPEN" as const,
priority: "MEDIUM" as const,
},
});
const onSubmit = async (formData: FormData) => {
try {
await createImprovement({
organizationId,
referenceId: formData.referenceId,
description: formData.description || undefined,
source: formData.source || undefined,
ownerId: formData.ownerId,
targetDate: formatDatetime(formData.targetDate),
status: formData.status,
priority: formData.priority,
});
toast({
title: __("Success"),
description: __("Continual improvement entry created successfully"),
variant: "success",
});
reset();
dialogRef.current?.close();
} catch (error) {
toast({
title: __("Error"),
description: formatError(__("Failed to create continual improvement"), error as GraphQLError),
variant: "error",
});
}
};
const statusOptions = [
{ value: "OPEN", label: __("Open") },
{ value: "IN_PROGRESS", label: __("In Progress") },
{ value: "CLOSED", label: __("Closed") },
];
const priorityOptions = [
{ value: "LOW", label: __("Low") },
{ value: "MEDIUM", label: __("Medium") },
{ value: "HIGH", label: __("High") },
];
return (
<Dialog
ref={dialogRef}
trigger={children}
title={<Breadcrumb items={[__("Continual Improvements"), __("Create Entry")]} />}
className="max-w-2xl"
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded className="space-y-4">
<Field
label={__("Reference ID")}
{...register("referenceId")}
placeholder="CI-001"
error={formState.errors.referenceId?.message}
required
/>
<div>
<Label>{__("Description")}</Label>
<Textarea
{...register("description")}
placeholder={__("Enter description of the continual improvement item")}
rows={3}
/>
{formState.errors.description?.message && (
<div className="text-red-500 text-sm mt-1">
{formState.errors.description.message}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-4">
<Field
label={__("Source")}
{...register("source")}
placeholder={__("Enter source")}
error={formState.errors.source?.message}
/>
<div>
<Label>{__("Target Date")}</Label>
<Input
type="date"
{...register("targetDate")}
/>
{formState.errors.targetDate?.message && (
<div className="text-red-500 text-sm mt-1">
{formState.errors.targetDate.message}
</div>
)}
</div>
</div>
<PeopleSelectField
organizationId={organizationId}
control={control}
name="ownerId"
label={__("Owner")}
error={formState.errors.ownerId?.message}
required
/>
<div className="grid grid-cols-2 gap-4">
<Controller
control={control}
name="status"
render={({ field }) => (
<div>
<Label>
{__("Status")}
{" "}
*
</Label>
<Select
value={field.value}
onValueChange={field.onChange}
>
{statusOptions.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
{formState.errors.status?.message && (
<div className="text-red-500 text-sm mt-1">
{formState.errors.status.message}
</div>
)}
</div>
)}
/>
<Controller
control={control}
name="priority"
render={({ field }) => (
<div>
<Label>
{__("Priority")}
{" "}
*
</Label>
<Select
value={field.value}
onValueChange={field.onChange}
>
{priorityOptions.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
{formState.errors.priority?.message && (
<div className="text-red-500 text-sm mt-1">
{formState.errors.priority.message}
</div>
)}
</div>
)}
/>
</div>
</DialogContent>
<DialogFooter>
<Button
type="submit"
variant="primary"
disabled={formState.isSubmitting}
>
{formState.isSubmitting ? __("Creating...") : __("Create Entry")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -0,0 +1,513 @@
import {
formatDatetime,
formatError,
getStatusLabel,
getStatusOptions,
getStatusVariant,
type GraphQLError,
sprintf,
validateSnapshotConsistency,
} from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Badge,
Breadcrumb,
Button,
Card,
DropdownItem,
Field,
IconTrashCan,
Input,
Label,
Option,
Select,
Textarea,
useConfirm,
useToast,
} from "@probo/ui";
import { Controller } from "react-hook-form";
import {
ConnectionHandler,
graphql,
type PreloadedQuery,
useMutation,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { z } from "zod";
import type { FindingDetailsPageDeleteMutation } from "#/__generated__/core/FindingDetailsPageDeleteMutation.graphql";
import type { FindingDetailsPageQuery } from "#/__generated__/core/FindingDetailsPageQuery.graphql";
import type { FindingDetailsPageUpdateMutation } from "#/__generated__/core/FindingDetailsPageUpdateMutation.graphql";
import { ControlledField } from "#/components/form/ControlledField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { FindingsConnectionKey } from "./FindingsPage";
export const findingDetailsPageQuery = graphql`
query FindingDetailsPageQuery($findingId: ID!) {
node(id: $findingId) {
... on Finding {
id
snapshotId
kind
referenceId
description
source
identifiedOn
rootCause
correctiveAction
dueDate
status
priority
effectivenessCheck
owner {
id
}
canUpdate: permission(action: "core:finding:update")
canDelete: permission(action: "core:finding:delete")
}
}
}
`;
const updateFindingMutation = graphql`
mutation FindingDetailsPageUpdateMutation($input: UpdateFindingInput!) {
updateFinding(input: $input) {
finding {
id
kind
referenceId
description
source
identifiedOn
rootCause
correctiveAction
dueDate
status
priority
effectivenessCheck
owner {
id
fullName
}
updatedAt
}
}
}
`;
const deleteFindingMutation = graphql`
mutation FindingDetailsPageDeleteMutation(
$input: DeleteFindingInput!
$connections: [ID!]!
) {
deleteFinding(input: $input) {
deletedFindingId @deleteEdge(connections: $connections)
}
}
`;
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(),
});
type Props = {
queryRef: PreloadedQuery<FindingDetailsPageQuery>;
};
function getKindLabel(kind: string, __: (s: string) => string): string {
switch (kind) {
case "NONCONFORMITY":
return __("Nonconformity");
case "OBSERVATION":
return __("Observation");
case "EXCEPTION":
return __("Exception");
default:
return kind;
}
}
export default function FindingDetailsPage(props: Props) {
const { node: finding } = usePreloadedQuery<FindingDetailsPageQuery>(
findingDetailsPageQuery,
props.queryRef,
);
const { __ } = useTranslate();
const { toast } = useToast();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
const confirm = useConfirm();
validateSnapshotConsistency(finding, snapshotId);
const [updateFinding] = useMutation<FindingDetailsPageUpdateMutation>(updateFindingMutation);
const [deleteFinding] = useMutation<FindingDetailsPageDeleteMutation>(deleteFindingMutation);
const connections = [
ConnectionHandler.getConnectionID(
organizationId,
FindingsConnectionKey,
{
filter: {
snapshotId: snapshotId || null,
kind: null,
status: null,
priority: null,
ownerId: null,
},
},
),
ConnectionHandler.getConnectionID(
organizationId,
FindingsConnectionKey,
{
filter: {
snapshotId: snapshotId || null,
kind: finding.kind,
status: null,
priority: null,
ownerId: null,
},
},
),
];
const handleDelete = () => {
confirm(
() =>
new Promise<void>((resolve) => {
deleteFinding({
variables: {
input: { findingId: finding.id! },
connections,
},
onCompleted(_, error) {
if (error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete finding"),
error as GraphQLError[],
),
variant: "error",
});
} else {
toast({
title: __("Success"),
description: __("Finding deleted successfully"),
variant: "success",
});
}
resolve();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete finding"),
error as GraphQLError,
),
variant: "error",
});
resolve();
},
});
}),
{
message: sprintf(
__(
"This will permanently delete the finding %s. This action cannot be undone.",
),
finding.referenceId!,
),
},
);
};
const { control, formState, handleSubmit, register, reset }
= useFormWithSchema(updateFindingSchema, {
defaultValues: {
description: finding.description || "",
source: finding.source || "",
identifiedOn: finding.identifiedOn?.split("T")[0] || "",
dueDate: finding.dueDate?.split("T")[0] || "",
rootCause: finding.rootCause || "",
correctiveAction: finding.correctiveAction || "",
effectivenessCheck: finding.effectivenessCheck || "",
status: finding.status || "OPEN",
priority: finding.priority || "MEDIUM",
ownerId: finding.owner?.id ?? null,
},
});
const onSubmit = handleSubmit((formData) => {
if (!finding.id) return;
updateFinding({
variables: {
input: {
id: finding.id,
description: formData.description || undefined,
source: formData.source || undefined,
identifiedOn: formatDatetime(formData.identifiedOn) ?? null,
dueDate: formatDatetime(formData.dueDate) ?? null,
rootCause: formData.rootCause || undefined,
correctiveAction: formData.correctiveAction || undefined,
effectivenessCheck: formData.effectivenessCheck || undefined,
status: formData.status,
priority: formData.priority,
ownerId: formData.ownerId || undefined,
},
},
onCompleted() {
reset(formData);
toast({
title: __("Success"),
description: __("Finding updated successfully"),
variant: "success",
});
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to update finding"),
error as GraphQLError,
),
variant: "error",
});
},
});
});
const statusOptions = getStatusOptions(__).filter(
opt => opt.value !== "RISK_ACCEPTED",
);
const priorityOptions = [
{ value: "LOW", label: __("Low") },
{ value: "MEDIUM", label: __("Medium") },
{ value: "HIGH", label: __("High") },
];
const breadcrumbFindingsUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${snapshotId}/findings`
: `/organizations/${organizationId}/findings`;
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<Breadcrumb
items={[
{
label: __("Findings"),
to: breadcrumbFindingsUrl,
},
{
label: finding.referenceId || __("Unknown Finding"),
},
]}
/>
<div className="flex justify-between items-start">
<div className="flex items-center gap-4">
<div className="text-2xl font-semibold">
{finding.referenceId}
</div>
<Badge variant="neutral">
{getKindLabel(finding.kind || "", __)}
</Badge>
<Badge variant={getStatusVariant(finding.status || "OPEN")}>
{getStatusLabel(finding.status || "OPEN")}
</Badge>
<Badge
variant={
finding.priority === "HIGH"
? "danger"
: finding.priority === "MEDIUM"
? "warning"
: "success"
}
>
{finding.priority === "HIGH"
? __("High")
: finding.priority === "MEDIUM"
? __("Medium")
: __("Low")}
</Badge>
</div>
{!isSnapshotMode && (
<ActionDropdown variant="secondary">
{finding.canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={handleDelete}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
)}
</div>
<div className="max-w-4xl">
<Card padded>
<form onSubmit={e => void onSubmit(e)} className="space-y-6">
<Field label={__("Description")}>
<Textarea
{...register("description")}
placeholder={__("Enter description")}
rows={3}
disabled={isSnapshotMode}
/>
</Field>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field
label={__("Source")}
error={formState.errors.source?.message}
>
<Input
{...register("source")}
placeholder={__("Enter source")}
disabled={isSnapshotMode}
/>
</Field>
<PeopleSelectField
organizationId={organizationId}
control={control}
name="ownerId"
label={__("Owner")}
error={formState.errors.ownerId?.message}
optional
disabled={isSnapshotMode}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ControlledField
control={control}
name="status"
type="select"
label={__("Status")}
required
disabled={isSnapshotMode}
>
{statusOptions.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</ControlledField>
<Controller
control={control}
name="priority"
render={({ field }) => (
<div>
<Label>
{__("Priority")}
{" "}
*
</Label>
<Select
value={field.value}
onValueChange={field.onChange}
disabled={isSnapshotMode}
>
{priorityOptions.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
{formState.errors.priority?.message && (
<div className="text-red-500 text-sm mt-1">
{formState.errors.priority.message}
</div>
)}
</div>
)}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label={__("Date Identified")}>
<Input
{...register("identifiedOn")}
type="date"
disabled={isSnapshotMode}
/>
</Field>
<Field label={__("Due Date")}>
<Input
{...register("dueDate")}
type="date"
disabled={isSnapshotMode}
/>
</Field>
</div>
<Field label={__("Root Cause")}>
<Textarea
{...register("rootCause")}
placeholder={__("Enter root cause")}
rows={3}
disabled={isSnapshotMode}
/>
</Field>
<Field label={__("Corrective Action")}>
<Textarea
{...register("correctiveAction")}
placeholder={__("Enter corrective action")}
rows={3}
disabled={isSnapshotMode}
/>
</Field>
<Field label={__("Effectiveness Check")}>
<Textarea
{...register("effectivenessCheck")}
placeholder={__("Enter effectiveness check details")}
rows={3}
disabled={isSnapshotMode}
/>
</Field>
<div className="flex justify-end">
{formState.isDirty
&& !isSnapshotMode
&& finding.canUpdate && (
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}
</Button>
)}
</div>
</form>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,30 @@
import { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { FindingDetailsPageQuery } from "#/__generated__/core/FindingDetailsPageQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import FindingDetailsPage, { findingDetailsPageQuery } from "./FindingDetailsPage";
export default function FindingDetailsPageLoader() {
const { findingId } = useParams<{ findingId: string }>();
const [queryRef, loadQuery]
= useQueryLoader<FindingDetailsPageQuery>(findingDetailsPageQuery);
useEffect(() => {
if (findingId) {
loadQuery({ findingId });
}
}, [loadQuery, findingId]);
if (!queryRef) {
return <PageSkeleton />;
}
return (
<Suspense fallback={<PageSkeleton />}>
<FindingDetailsPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,549 @@
import {
formatDate,
formatError,
getStatusLabel,
getStatusOptions,
getStatusVariant,
type GraphQLError,
sprintf,
} from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Avatar,
Badge,
Button,
Card,
DropdownItem,
IconPlusLarge,
IconTrashCan,
Option,
PageHeader,
Select,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
useConfirm,
useToast,
} from "@probo/ui";
import { Suspense, useState, useTransition } from "react";
import {
ConnectionHandler,
graphql,
type PreloadedQuery,
useFragment,
useMutation,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import type { FindingsPageDeleteMutation } from "#/__generated__/core/FindingsPageDeleteMutation.graphql";
import type { FindingsPageFragment$key } from "#/__generated__/core/FindingsPageFragment.graphql";
import type { FindingsPageListQuery } from "#/__generated__/core/FindingsPageListQuery.graphql";
import type {
FindingKind,
FindingPriority,
FindingsPageRefetchQuery,
FindingStatus,
} from "#/__generated__/core/FindingsPageRefetchQuery.graphql";
import type { FindingsPageRowFragment$key } from "#/__generated__/core/FindingsPageRowFragment.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { usePeople } from "#/hooks/graph/PeopleGraph";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import { CreateFindingDialog } from "./dialogs/CreateFindingDialog";
export const FindingsConnectionKey = "FindingsPage_findings";
export const findingsPageQuery = graphql`
query FindingsPageListQuery($organizationId: ID!, $snapshotId: ID) {
node(id: $organizationId) {
... on Organization {
canCreateFinding: permission(action: "core:finding:create")
...FindingsPageFragment @arguments(snapshotId: $snapshotId)
}
}
}
`;
const deleteFindingMutation = graphql`
mutation FindingsPageDeleteMutation(
$input: DeleteFindingInput!
$connections: [ID!]!
) {
deleteFinding(input: $input) {
deletedFindingId @deleteEdge(connections: $connections)
}
}
`;
const findingRowFragment = graphql`
fragment FindingsPageRowFragment on Finding {
id
kind
referenceId
description
status
priority
dueDate
owner {
id
fullName
}
canUpdate: permission(action: "core:finding:update")
canDelete: permission(action: "core:finding:delete")
}
`;
const findingsPageFragment = graphql`
fragment FindingsPageFragment on Organization
@refetchable(queryName: "FindingsPageRefetchQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 10 }
after: { type: "CursorKey" }
snapshotId: { type: "ID", defaultValue: null }
kind: { type: "FindingKind", defaultValue: null }
status: { type: "FindingStatus", defaultValue: null }
priority: { type: "FindingPriority", defaultValue: null }
ownerId: { type: "ID", defaultValue: null }
) {
id
findings(
first: $first
after: $after
filter: {
snapshotId: $snapshotId
kind: $kind
status: $status
priority: $priority
ownerId: $ownerId
}
)
@connection(
key: "FindingsPage_findings"
filters: ["filter"]
) {
edges {
node {
id
canUpdate: permission(action: "core:finding:update")
canDelete: permission(action: "core:finding:delete")
...FindingsPageRowFragment
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
interface FindingsPageProps {
queryRef: PreloadedQuery<FindingsPageListQuery>;
}
export default function FindingsPage({ queryRef }: FindingsPageProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
usePageTitle(__("Findings"));
const organization = usePreloadedQuery(findingsPageQuery, queryRef);
const [isPending, startTransition] = useTransition();
const [kindFilter, setKindFilter] = useState<FindingKind | null>(null);
const [statusFilter, setStatusFilter] = useState<FindingStatus | null>(null);
const [priorityFilter, setPriorityFilter] = useState<FindingPriority | null>(null);
const [ownerFilter, setOwnerFilter] = useState<string | null>(null);
const { data, loadNext, hasNext, isLoadingNext, refetch }
= usePaginationFragment<FindingsPageRefetchQuery, FindingsPageFragment$key>(
findingsPageFragment,
organization.node,
);
const refetchFilters = (overrides: Record<string, unknown> = {}) => {
startTransition(() => {
refetch(
{
kind: kindFilter,
status: statusFilter,
priority: priorityFilter,
ownerId: ownerFilter,
snapshotId: snapshotId || null,
...overrides,
},
{ fetchPolicy: "network-only" },
);
});
};
const handleKindFilterChange = (value: string) => {
const newKind = value === "ALL" ? null : (value as FindingKind);
setKindFilter(newKind);
refetchFilters({ kind: newKind });
};
const handleStatusFilterChange = (value: string) => {
const newStatus = value === "ALL" ? null : (value as FindingStatus);
setStatusFilter(newStatus);
refetchFilters({ status: newStatus });
};
const handlePriorityFilterChange = (value: string) => {
const newPriority = value === "ALL" ? null : (value as FindingPriority);
setPriorityFilter(newPriority);
refetchFilters({ priority: newPriority });
};
const handleOwnerFilterChange = (value: string) => {
const newOwner = value === "ALL" ? null : value;
setOwnerFilter(newOwner);
refetchFilters({ ownerId: newOwner });
};
const currentFilter = {
snapshotId: snapshotId || null,
kind: kindFilter,
status: statusFilter,
priority: priorityFilter,
ownerId: ownerFilter,
};
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
FindingsConnectionKey,
{ filter: currentFilter },
);
const allFiltersNullConnectionId = ConnectionHandler.getConnectionID(
organizationId,
FindingsConnectionKey,
{
filter: {
snapshotId: snapshotId || null,
kind: null,
status: null,
priority: null,
ownerId: null,
},
},
);
const hasActiveFilter = kindFilter || statusFilter || priorityFilter || ownerFilter;
const createConnectionIds = hasActiveFilter
? [allFiltersNullConnectionId, connectionId]
: [connectionId];
const findings = data?.findings?.edges?.map(edge => edge.node) ?? [];
const hasAnyAction
= !isSnapshotMode
&& findings.some(({ canDelete, canUpdate }) => canDelete || canUpdate);
return (
<div className="space-y-6">
{isSnapshotMode && snapshotId && (
<SnapshotBanner snapshotId={snapshotId} />
)}
<PageHeader
title={__("Findings")}
description={__("Manage your organization's findings.")}
>
{!isSnapshotMode && organization.node.canCreateFinding && (
<CreateFindingDialog
organizationId={organizationId}
connectionIds={createConnectionIds}
>
<Button icon={IconPlusLarge}>{__("Add finding")}</Button>
</CreateFindingDialog>
)}
</PageHeader>
<div className="flex items-center gap-4">
<Select
value={kindFilter ?? "ALL"}
onValueChange={handleKindFilterChange}
>
<Option value="ALL">{__("All kinds")}</Option>
<Option value="NONCONFORMITY">{__("Nonconformity")}</Option>
<Option value="OBSERVATION">{__("Observation")}</Option>
<Option value="EXCEPTION">{__("Exception")}</Option>
</Select>
<Select
value={statusFilter ?? "ALL"}
onValueChange={handleStatusFilterChange}
>
<Option value="ALL">{__("All statuses")}</Option>
{getStatusOptions(__).map(opt => (
<Option key={opt.value} value={opt.value}>
{opt.label}
</Option>
))}
</Select>
<Select
value={priorityFilter ?? "ALL"}
onValueChange={handlePriorityFilterChange}
>
<Option value="ALL">{__("All priorities")}</Option>
<Option value="LOW">{__("Low")}</Option>
<Option value="MEDIUM">{__("Medium")}</Option>
<Option value="HIGH">{__("High")}</Option>
</Select>
<Suspense fallback={<Select loading placeholder={__("Loading...")} />}>
<OwnerFilterSelect
organizationId={organizationId}
value={ownerFilter}
onChange={handleOwnerFilterChange}
/>
</Suspense>
</div>
<div className={isPending ? "opacity-50 pointer-events-none transition-opacity" : ""}>
{findings.length > 0
? (
<Card>
<Table>
<Thead>
<Tr>
<Th>{__("Kind")}</Th>
<Th>{__("Reference ID")}</Th>
<Th>{__("Description")}</Th>
<Th>{__("Status")}</Th>
<Th>{__("Priority")}</Th>
<Th>{__("Owner")}</Th>
<Th>{__("Due Date")}</Th>
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
{findings.map(finding => (
<FindingRow
key={finding.id}
findingKey={finding}
connectionId={connectionId}
snapshotId={snapshotId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
</Table>
{hasNext && (
<div className="p-4 border-t">
<Button
variant="secondary"
onClick={() => loadNext(10)}
disabled={isLoadingNext}
>
{isLoadingNext ? __("Loading...") : __("Load more")}
</Button>
</div>
)}
</Card>
)
: (
<Card padded>
<div className="text-center py-12">
<h3 className="text-lg font-semibold mb-2">
{__("No findings yet")}
</h3>
<p className="text-txt-tertiary mb-4">
{__("Create your first finding to get started.")}
</p>
</div>
</Card>
)}
</div>
</div>
);
}
function getKindLabel(kind: string, __: (s: string) => string): string {
switch (kind) {
case "NONCONFORMITY":
return __("Nonconformity");
case "OBSERVATION":
return __("Observation");
case "EXCEPTION":
return __("Exception");
default:
return kind;
}
}
type FindingRowProps = {
findingKey: FindingsPageRowFragment$key;
connectionId: string;
snapshotId?: string;
hasAnyAction: boolean;
};
function FindingRow(props: FindingRowProps) {
const finding = useFragment(findingRowFragment, props.findingKey);
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const [deleteFinding] = useMutation<FindingsPageDeleteMutation>(deleteFindingMutation);
const { toast } = useToast();
const confirm = useConfirm();
const isSnapshotMode = Boolean(props.snapshotId);
const handleDelete = () => {
confirm(
() =>
new Promise<void>((resolve) => {
deleteFinding({
variables: {
input: {
findingId: finding.id,
},
connections: [props.connectionId],
},
onCompleted(_, error) {
if (error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete finding"),
error as GraphQLError[],
),
variant: "error",
});
} else {
toast({
title: __("Success"),
description: __("Finding deleted successfully"),
variant: "success",
});
}
resolve();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to delete finding"),
error as GraphQLError,
),
variant: "error",
});
resolve();
},
});
}),
{
message: sprintf(
__(
"This will permanently delete the finding %s. This action cannot be undone.",
),
finding.referenceId,
),
},
);
};
const detailsUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${props.snapshotId}/findings/${finding.id}`
: `/organizations/${organizationId}/findings/${finding.id}`;
return (
<Tr to={detailsUrl}>
<Td>
<Badge variant="neutral">
{getKindLabel(finding.kind, __)}
</Badge>
</Td>
<Td>
<span className="font-mono text-sm">{finding.referenceId}</span>
</Td>
<Td>
<div className="min-w-0">
<p className="whitespace-pre-wrap break-words">
{finding.description || __("No description")}
</p>
</div>
</Td>
<Td>
<Badge variant={getStatusVariant(finding.status)}>
{getStatusLabel(finding.status)}
</Badge>
</Td>
<Td>
<Badge
variant={
finding.priority === "HIGH"
? "danger"
: finding.priority === "MEDIUM"
? "warning"
: "success"
}
>
{finding.priority === "HIGH"
? __("High")
: finding.priority === "MEDIUM"
? __("Medium")
: __("Low")}
</Badge>
</Td>
<Td>{finding.owner?.fullName || "-"}</Td>
<Td>
{finding.dueDate
? (
<time dateTime={finding.dueDate}>
{formatDate(finding.dueDate)}
</time>
)
: (
<span className="text-txt-tertiary">{__("No due date")}</span>
)}
</Td>
{props.hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
{finding.canDelete && (
<DropdownItem
icon={IconTrashCan}
variant="danger"
onSelect={handleDelete}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
</Tr>
);
}
type OwnerFilterSelectProps = {
organizationId: string;
value: string | null;
onChange: (value: string) => void;
};
function OwnerFilterSelect({
organizationId,
value,
onChange,
}: OwnerFilterSelectProps) {
const { __ } = useTranslate();
const people = usePeople(organizationId, { excludeContractEnded: true });
return (
<Select value={value ?? "ALL"} onValueChange={onChange}>
<Option value="ALL">{__("All owners")}</Option>
{people.map(p => (
<Option key={p.id} value={p.id}>
<Avatar name={p.fullName} />
{p.fullName}
</Option>
))}
</Select>
);
}

View File

@@ -0,0 +1,33 @@
import { Suspense, useEffect } from "react";
import { useQueryLoader } from "react-relay";
import { useParams } from "react-router";
import type { FindingsPageListQuery } from "#/__generated__/core/FindingsPageListQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import FindingsPage, { findingsPageQuery } from "./FindingsPage";
export default function FindingsPageLoader() {
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const [queryRef, loadQuery]
= useQueryLoader<FindingsPageListQuery>(findingsPageQuery);
useEffect(() => {
loadQuery({
organizationId,
snapshotId: snapshotId ?? null,
});
}, [loadQuery, organizationId, snapshotId]);
if (!queryRef) {
return <PageSkeleton />;
}
return (
<Suspense fallback={<PageSkeleton />}>
<FindingsPage queryRef={queryRef} />
</Suspense>
);
}

View File

@@ -0,0 +1,352 @@
import {
formatDatetime,
formatError,
getStatusOptions,
type GraphQLError,
} from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
Button,
Dialog,
DialogContent,
DialogFooter,
Field,
Input,
Label,
Option,
Select,
Textarea,
useDialogRef,
useToast,
} from "@probo/ui";
import { type ReactNode } from "react";
import { Controller } from "react-hook-form";
import { graphql, useMutation } from "react-relay";
import { z } from "zod";
import type { CreateFindingDialogMutation } from "#/__generated__/core/CreateFindingDialogMutation.graphql";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
const createFindingMutation = graphql`
mutation CreateFindingDialogMutation(
$input: CreateFindingInput!
$connections: [ID!]!
) {
createFinding(input: $input) {
findingEdge @prependEdge(connections: $connections) {
node {
id
kind
referenceId
description
source
identifiedOn
rootCause
correctiveAction
dueDate
status
priority
effectivenessCheck
owner {
id
fullName
}
createdAt
canUpdate: permission(action: "core:finding:update")
canDelete: permission(action: "core:finding:delete")
}
}
}
}
`;
const schema = z.object({
kind: z.enum(["NONCONFORMITY", "OBSERVATION", "EXCEPTION"]),
description: z.string().optional(),
source: z.string().optional(),
identifiedOn: z.string().optional(),
rootCause: z.string().optional(),
correctiveAction: z.string().optional(),
ownerId: z.string().nullable().optional(),
dueDate: z.string().optional(),
status: z.enum(["OPEN", "IN_PROGRESS", "CLOSED", "MITIGATED", "FALSE_POSITIVE"]),
priority: z.enum(["LOW", "MEDIUM", "HIGH"]),
effectivenessCheck: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
interface CreateFindingDialogProps {
children: ReactNode;
organizationId: string;
connectionIds?: string[];
}
export function CreateFindingDialog({
children,
organizationId,
connectionIds,
}: CreateFindingDialogProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const [createFinding] = useMutation<CreateFindingDialogMutation>(createFindingMutation);
const statusOptions = getStatusOptions(__).filter(
opt => opt.value !== "RISK_ACCEPTED",
);
const kindOptions = [
{ value: "NONCONFORMITY", label: __("Nonconformity") },
{ value: "OBSERVATION", label: __("Observation") },
{ value: "EXCEPTION", label: __("Exception") },
];
const priorityOptions = [
{ value: "LOW", label: __("Low") },
{ value: "MEDIUM", label: __("Medium") },
{ value: "HIGH", label: __("High") },
];
const { register, handleSubmit, formState, reset, control } = useFormWithSchema(schema, {
defaultValues: {
kind: "NONCONFORMITY" as const,
description: "",
source: "",
identifiedOn: "",
rootCause: "",
correctiveAction: "",
ownerId: null,
dueDate: "",
status: "OPEN" as const,
priority: "MEDIUM" as const,
effectivenessCheck: "",
},
});
const onSubmit = (formData: FormData) => {
createFinding({
variables: {
input: {
organizationId,
kind: formData.kind,
description: formData.description || undefined,
source: formData.source || undefined,
identifiedOn: formatDatetime(formData.identifiedOn),
rootCause: formData.rootCause || undefined,
correctiveAction: formData.correctiveAction || undefined,
ownerId: formData.ownerId || undefined,
dueDate: formatDatetime(formData.dueDate),
status: formData.status,
priority: formData.priority,
effectivenessCheck: formData.effectivenessCheck || undefined,
},
connections: connectionIds ?? [],
},
onCompleted() {
toast({
title: __("Success"),
description: __("Finding created successfully"),
variant: "success",
});
reset();
dialogRef.current?.close();
},
onError(error) {
toast({
title: __("Error"),
description: formatError(__("Failed to create finding"), error as GraphQLError),
variant: "error",
});
},
});
};
return (
<Dialog
ref={dialogRef}
trigger={children}
title={<Breadcrumb items={[__("Findings"), __("Create")]} />}
className="max-w-2xl"
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded className="space-y-4">
<Controller
control={control}
name="kind"
render={({ field }) => (
<Field label={__("Kind")} required>
<Select
variant="editor"
placeholder={__("Select kind")}
onValueChange={field.onChange}
value={field.value}
className="w-full"
>
{kindOptions.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
{formState.errors.kind && (
<p className="text-sm text-red-500 mt-1">{formState.errors.kind.message}</p>
)}
</Field>
)}
/>
<div className="space-y-2">
<Label htmlFor="description">{__("Description")}</Label>
<Textarea
id="description"
{...register("description")}
placeholder={__("Brief description of the finding...")}
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field
label={__("Source")}
{...register("source")}
placeholder={__("Enter source")}
error={formState.errors.source?.message}
/>
<PeopleSelectField
organizationId={organizationId}
control={control}
name="ownerId"
label={__("Owner")}
error={formState.errors.ownerId?.message}
optional
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field label={__("Status")}>
<Controller
control={control}
name="status"
render={({ field }) => (
<Select
variant="editor"
placeholder={__("Select status")}
onValueChange={field.onChange}
value={field.value}
className="w-full"
>
{statusOptions.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
)}
/>
{formState.errors.status && (
<p className="text-sm text-red-500 mt-1">{formState.errors.status.message}</p>
)}
</Field>
<Controller
control={control}
name="priority"
render={({ field }) => (
<div>
<Label>
{__("Priority")}
{" "}
*
</Label>
<Select
value={field.value}
onValueChange={field.onChange}
>
{priorityOptions.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
{formState.errors.priority?.message && (
<div className="text-red-500 text-sm mt-1">
{formState.errors.priority.message}
</div>
)}
</div>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="identifiedOn">{__("Date Identified")}</Label>
<Input
id="identifiedOn"
type="date"
{...register("identifiedOn")}
/>
{formState.errors.identifiedOn && (
<p className="text-sm text-red-500">{formState.errors.identifiedOn.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="dueDate">{__("Due Date")}</Label>
<Input
id="dueDate"
type="date"
{...register("dueDate")}
/>
{formState.errors.dueDate && (
<p className="text-sm text-red-500">{formState.errors.dueDate.message}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="rootCause">{__("Root Cause")}</Label>
<Textarea
id="rootCause"
{...register("rootCause")}
placeholder={__("Detailed analysis of the root cause...")}
rows={3}
/>
{formState.errors.rootCause && (
<p className="text-sm text-red-500">{formState.errors.rootCause.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="correctiveAction">{__("Corrective Action")}</Label>
<Textarea
id="correctiveAction"
{...register("correctiveAction")}
placeholder={__("Proposed corrective actions...")}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="effectivenessCheck">{__("Effectiveness Check")}</Label>
<Textarea
id="effectivenessCheck"
{...register("effectivenessCheck")}
placeholder={__("Assessment of corrective action effectiveness...")}
rows={2}
/>
</div>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Creating...") : __("Create Finding")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -1,328 +0,0 @@
import {
formatDate,
getStatusLabel,
getStatusVariant,
promisifyMutation,
sprintf,
} from "@probo/helpers";
import { usePageTitle } from "@probo/hooks";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Badge,
Button,
Card,
DropdownItem,
IconPlusLarge,
IconTrashCan,
PageHeader,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
useConfirm,
} from "@probo/ui";
import {
ConnectionHandler,
graphql,
type PreloadedQuery,
useMutation,
usePaginationFragment,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import type {
NonconformitiesPageFragment$data,
NonconformitiesPageFragment$key,
} from "#/__generated__/core/NonconformitiesPageFragment.graphql";
import type { NonconformitiesPageRefetchQuery } from "#/__generated__/core/NonconformitiesPageRefetchQuery.graphql";
import type { NonconformityGraphDeleteMutation } from "#/__generated__/core/NonconformityGraphDeleteMutation.graphql";
import type { NonconformityGraphListQuery } from "#/__generated__/core/NonconformityGraphListQuery.graphql";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import {
deleteNonconformityMutation,
NonconformitiesConnectionKey,
nonconformitiesQuery,
} from "../../../hooks/graph/NonconformityGraph";
import { CreateNonconformityDialog } from "./dialogs/CreateNonconformityDialog";
type Nonconformity
= NonconformitiesPageFragment$data["nonconformities"]["edges"][number]["node"];
interface NonconformitiesPageProps {
queryRef: PreloadedQuery<NonconformityGraphListQuery>;
}
const nonconformitiesPageFragment = graphql`
fragment NonconformitiesPageFragment on Organization
@refetchable(queryName: "NonconformitiesPageRefetchQuery")
@argumentDefinitions(
first: { type: "Int", defaultValue: 10 }
after: { type: "CursorKey" }
snapshotId: { type: "ID", defaultValue: null }
) {
id
nonconformities(
first: $first
after: $after
filter: { snapshotId: $snapshotId }
)
@connection(
key: "NonconformitiesPage_nonconformities"
filters: ["filter"]
) {
edges {
node {
id
referenceId
snapshotId
description
status
dueDate
audit {
id
name
framework {
id
name
}
}
owner {
id
fullName
}
canUpdate: permission(action: "core:nonconformity:update")
canDelete: permission(action: "core:nonconformity:delete")
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
`;
export default function NonconformitiesPage({
queryRef,
}: NonconformitiesPageProps) {
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
usePageTitle(__("Nonconformities"));
const organization = usePreloadedQuery(nonconformitiesQuery, queryRef);
const {
data: nonconformitiesData,
loadNext,
hasNext,
} = usePaginationFragment<NonconformitiesPageRefetchQuery, NonconformitiesPageFragment$key>(
nonconformitiesPageFragment,
organization.node as NonconformitiesPageFragment$key,
);
const connectionId = ConnectionHandler.getConnectionID(
organizationId,
NonconformitiesConnectionKey,
{ filter: { snapshotId: snapshotId || null } },
);
const nonconformities: Nonconformity[]
= nonconformitiesData?.nonconformities?.edges?.map(edge => edge.node) ?? [];
const hasAnyAction
= !isSnapshotMode
&& nonconformities.some(({ canDelete, canUpdate }) => canDelete || canUpdate);
return (
<div className="space-y-6">
{isSnapshotMode && <SnapshotBanner snapshotId={snapshotId!} />}
<PageHeader
title={__("Nonconformities")}
description={__("Manage your organization's non conformities.")}
>
{!isSnapshotMode && organization.node.canCreateNonconformity && (
<CreateNonconformityDialog
organizationId={organizationId}
connection={connectionId}
>
<Button icon={IconPlusLarge}>{__("Add nonconformity")}</Button>
</CreateNonconformityDialog>
)}
</PageHeader>
{nonconformities.length === 0
? (
<Card padded>
<div className="text-center py-12">
<h3 className="text-lg font-semibold mb-2">
{__("No nonconformities yet")}
</h3>
<p className="text-txt-tertiary mb-4">
{__("Create your first nonconformity to get started.")}
</p>
</div>
</Card>
)
: (
<Card>
<Table>
<Thead>
<Tr>
<Th>{__("Reference ID")}</Th>
<Th>{__("Description")}</Th>
<Th>{__("Status")}</Th>
<Th>{__("Audit")}</Th>
<Th>{__("Owner")}</Th>
<Th>{__("Due Date")}</Th>
{hasAnyAction && <Th>{__("Actions")}</Th>}
</Tr>
</Thead>
<Tbody>
{nonconformities.map(nonconformity => (
<NonconformityRow
key={nonconformity.id}
nonconformity={nonconformity}
connectionId={connectionId}
isSnapshotMode={isSnapshotMode}
snapshotId={snapshotId}
hasAnyAction={hasAnyAction}
/>
))}
</Tbody>
</Table>
{hasNext && (
<div className="p-4 border-t">
<Button
variant="secondary"
onClick={() => loadNext(10)}
disabled={!hasNext}
>
{__("Load more")}
</Button>
</div>
)}
</Card>
)}
</div>
);
}
function NonconformityRow({
nonconformity,
connectionId,
isSnapshotMode,
snapshotId,
hasAnyAction,
}: {
nonconformity: Nonconformity;
connectionId: string;
isSnapshotMode: boolean;
snapshotId?: string;
hasAnyAction: boolean;
}) {
const organizationId = useOrganizationId();
const { __ } = useTranslate();
const confirm = useConfirm();
const [deleteNonconformity] = useMutation<NonconformityGraphDeleteMutation>(deleteNonconformityMutation);
const nonconformityDetailUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${snapshotId}/nonconformities/${nonconformity.id}`
: `/organizations/${organizationId}/nonconformities/${nonconformity.id}`;
const handleDeleteNonconformity = (nonconformity: Nonconformity) => {
if (!connectionId) return;
confirm(
() => {
return promisifyMutation(deleteNonconformity)({
variables: {
input: {
nonconformityId: nonconformity.id,
},
connections: [connectionId],
},
});
},
{
message: sprintf(
__(
"This will permanently delete the nonconformity %s. This action cannot be undone.",
),
nonconformity.referenceId,
),
},
);
};
return (
<Tr to={nonconformityDetailUrl}>
<Td>
<span className="font-mono text-sm">{nonconformity.referenceId}</span>
</Td>
<Td>
<div className="min-w-0">
<p className="whitespace-pre-wrap break-words">
{nonconformity.description || __("No description")}
</p>
</div>
</Td>
<Td>
<Badge variant={getStatusVariant(nonconformity.status)}>
{getStatusLabel(nonconformity.status)}
</Badge>
</Td>
<Td>
{nonconformity.audit
? (
nonconformity.audit.name
? (
`${nonconformity.audit.framework?.name} - ${nonconformity.audit.name}`
)
: (
nonconformity.audit.framework?.name
)
)
: (
<span className="text-txt-tertiary">{__("No audit")}</span>
)}
</Td>
<Td>{nonconformity.owner.fullName}</Td>
<Td>
{nonconformity.dueDate
? (
<time dateTime={nonconformity.dueDate}>
{formatDate(nonconformity.dueDate)}
</time>
)
: (
<span className="text-txt-tertiary">{__("No due date")}</span>
)}
</Td>
{hasAnyAction && (
<Td noLink width={50} className="text-end">
<ActionDropdown>
{nonconformity.canDelete && (
<DropdownItem
icon={IconTrashCan}
variant="danger"
onSelect={() => handleDeleteNonconformity(nonconformity)}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
</Td>
)}
</Tr>
);
}

View File

@@ -1,309 +0,0 @@
import {
formatDatetime,
formatError,
getStatusLabel,
getStatusOptions,
getStatusVariant,
type GraphQLError,
validateSnapshotConsistency,
} from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
ActionDropdown,
Badge,
Breadcrumb,
Button,
Card,
DropdownItem,
Field,
IconTrashCan,
Input,
Option,
Textarea,
useToast,
} from "@probo/ui";
import {
ConnectionHandler,
type PreloadedQuery,
usePreloadedQuery,
} from "react-relay";
import { useParams } from "react-router";
import { z } from "zod";
import type { NonconformityGraphNodeQuery } from "#/__generated__/core/NonconformityGraphNodeQuery.graphql";
import { AuditSelectField } from "#/components/form/AuditSelectField";
import { ControlledField } from "#/components/form/ControlledField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { SnapshotBanner } from "#/components/SnapshotBanner";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useOrganizationId } from "#/hooks/useOrganizationId";
import {
NonconformitiesConnectionKey,
nonconformityNodeQuery,
useDeleteNonconformity,
useUpdateNonconformity,
} from "../../../hooks/graph/NonconformityGraph";
const updateNonconformitySchema = z.object({
referenceId: z.string().min(1, "Reference ID is required"),
description: z.string().optional(),
dateIdentified: z.string().optional(),
dueDate: z.string().optional(),
rootCause: z.string().min(1, "Root cause is required"),
correctiveAction: z.string().optional(),
effectivenessCheck: z.string().optional(),
status: z.enum(["OPEN", "IN_PROGRESS", "CLOSED"]),
ownerId: z.string().min(1, "Owner is required"),
auditId: z.string().optional(),
});
type Props = {
queryRef: PreloadedQuery<NonconformityGraphNodeQuery>;
};
export default function NonconformityDetailsPage(props: Props) {
const { node: nonconformity }
= usePreloadedQuery<NonconformityGraphNodeQuery>(
nonconformityNodeQuery,
props.queryRef,
);
const { __ } = useTranslate();
const organizationId = useOrganizationId();
const { snapshotId } = useParams<{ snapshotId?: string }>();
const isSnapshotMode = Boolean(snapshotId);
validateSnapshotConsistency(nonconformity, snapshotId);
const deleteNonconformity = useDeleteNonconformity(
{ id: nonconformity.id!, referenceId: nonconformity.referenceId! },
ConnectionHandler.getConnectionID(
organizationId,
NonconformitiesConnectionKey,
{ filter: { snapshotId: snapshotId || null } },
),
);
const { control, formState, handleSubmit, register, reset }
= useFormWithSchema(updateNonconformitySchema, {
defaultValues: {
referenceId: nonconformity.referenceId || "",
description: nonconformity.description || "",
dateIdentified: nonconformity.dateIdentified?.split("T")[0] || "",
dueDate: nonconformity.dueDate?.split("T")[0] || "",
rootCause: nonconformity.rootCause || "",
correctiveAction: nonconformity.correctiveAction || "",
effectivenessCheck: nonconformity.effectivenessCheck || "",
status: nonconformity.status || "OPEN",
ownerId: nonconformity.owner?.id || "",
auditId: nonconformity.audit?.id || "",
},
});
const updateNonconformity = useUpdateNonconformity();
const { toast } = useToast();
const onSubmit = handleSubmit(async (formData) => {
if (!nonconformity.id) return;
try {
await updateNonconformity({
id: nonconformity.id,
referenceId: formData.referenceId,
description: formData.description,
dateIdentified: formatDatetime(formData.dateIdentified) ?? null,
dueDate: formatDatetime(formData.dueDate) ?? null,
rootCause: formData.rootCause,
correctiveAction: formData.correctiveAction,
effectivenessCheck: formData.effectivenessCheck,
status: formData.status,
ownerId: formData.ownerId,
auditId: formData.auditId,
});
reset(formData);
toast({
title: __("Success"),
description: __("Nonconformity updated successfully"),
variant: "success",
});
} catch (error) {
toast({
title: __("Error"),
description: formatError(
__("Failed to update nonconformity"),
error as GraphQLError,
),
variant: "error",
});
}
});
const statusOptions = getStatusOptions(__);
const breadcrumbNonconformitiesUrl = isSnapshotMode
? `/organizations/${organizationId}/snapshots/${snapshotId}/nonconformities`
: `/organizations/${organizationId}/nonconformities`;
return (
<div className="space-y-6">
{isSnapshotMode && <SnapshotBanner snapshotId={snapshotId!} />}
<Breadcrumb
items={[
{
label: __("Nonconformities"),
to: breadcrumbNonconformitiesUrl,
},
{
label: nonconformity.referenceId || __("Unknown Nonconformity"),
},
]}
/>
<div className="flex justify-between items-start">
<div className="flex items-center gap-4">
<div className="text-2xl font-semibold">
{nonconformity.referenceId}
</div>
<Badge variant={getStatusVariant(nonconformity.status || "OPEN")}>
{getStatusLabel(nonconformity.status || "OPEN")}
</Badge>
</div>
{!isSnapshotMode && (
<ActionDropdown variant="secondary">
{nonconformity.canDelete && (
<DropdownItem
variant="danger"
icon={IconTrashCan}
onClick={deleteNonconformity}
>
{__("Delete")}
</DropdownItem>
)}
</ActionDropdown>
)}
</div>
<div className="max-w-4xl">
<Card padded>
<form onSubmit={e => void onSubmit(e)} className="space-y-6">
<Field
label={__("Reference ID")}
required
error={formState.errors.referenceId?.message}
>
<Input
{...register("referenceId")}
placeholder={__("Enter reference ID")}
disabled={isSnapshotMode}
/>
</Field>
<AuditSelectField
organizationId={organizationId}
control={control}
name="auditId"
label={__("Audit")}
error={formState.errors.auditId?.message}
disabled={isSnapshotMode}
/>
<Field label={__("Description")}>
<Textarea
{...register("description")}
placeholder={__("Enter description")}
rows={3}
disabled={isSnapshotMode}
/>
</Field>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<ControlledField
control={control}
name="status"
type="select"
label={__("Status")}
required
disabled={isSnapshotMode}
>
{statusOptions.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</ControlledField>
<PeopleSelectField
organizationId={organizationId}
control={control}
name="ownerId"
label={__("Owner")}
error={formState.errors.ownerId?.message}
required
disabled={isSnapshotMode}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Field label={__("Date Identified")}>
<Input
{...register("dateIdentified")}
type="date"
disabled={isSnapshotMode}
/>
</Field>
<Field label={__("Due Date")}>
<Input
{...register("dueDate")}
type="date"
disabled={isSnapshotMode}
/>
</Field>
</div>
<Field
label={__("Root Cause")}
required
error={formState.errors.rootCause?.message}
>
<Textarea
{...register("rootCause")}
placeholder={__("Enter root cause")}
rows={3}
disabled={isSnapshotMode}
/>
</Field>
<Field label={__("Corrective Action")}>
<Textarea
{...register("correctiveAction")}
placeholder={__("Enter corrective action")}
rows={3}
disabled={isSnapshotMode}
/>
</Field>
<Field label={__("Effectiveness Check")}>
<Textarea
{...register("effectivenessCheck")}
placeholder={__("Enter effectiveness check details")}
rows={3}
disabled={isSnapshotMode}
/>
</Field>
<div className="flex justify-end">
{formState.isDirty
&& !isSnapshotMode
&& nonconformity.canUpdate && (
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Updating...") : __("Update")}
</Button>
)}
</div>
</form>
</Card>
</div>
</div>
);
}

View File

@@ -1,253 +0,0 @@
import { formatError, type GraphQLError } from "@probo/helpers";
import { formatDatetime, getStatusOptions } from "@probo/helpers";
import { useTranslate } from "@probo/i18n";
import {
Breadcrumb,
Button,
Dialog,
DialogContent,
DialogFooter,
Field,
Input,
Label,
Option,
Select,
Textarea,
useDialogRef,
useToast,
} from "@probo/ui";
import { type ReactNode } from "react";
import { Controller } from "react-hook-form";
import { z } from "zod";
import { AuditSelectField } from "#/components/form/AuditSelectField";
import { PeopleSelectField } from "#/components/form/PeopleSelectField";
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
import { useCreateNonconformity } from "../../../../hooks/graph/NonconformityGraph";
const schema = z.object({
referenceId: z.string().min(1, "Reference ID is required"),
description: z.string().optional(),
auditId: z.string().optional(),
dateIdentified: z.string().optional(),
rootCause: z.string().min(1, "Root cause is required"),
correctiveAction: z.string().optional(),
ownerId: z.string().min(1, "Owner is required"),
dueDate: z.string().optional(),
status: z.enum(["OPEN", "IN_PROGRESS", "CLOSED"]),
effectivenessCheck: z.string().optional(),
});
type FormData = z.infer<typeof schema>;
interface CreateNonconformityDialogProps {
children: ReactNode;
connection?: string;
organizationId: string;
}
export function CreateNonconformityDialog({
children,
organizationId,
connection,
}: CreateNonconformityDialogProps) {
const { __ } = useTranslate();
const { toast } = useToast();
const dialogRef = useDialogRef();
const createNonconformity = useCreateNonconformity(connection || "");
const statusOptions = getStatusOptions(__);
const { register, handleSubmit, formState, reset, control } = useFormWithSchema(schema, {
defaultValues: {
referenceId: "",
description: "",
auditId: "",
dateIdentified: "",
rootCause: "",
correctiveAction: "",
ownerId: "",
dueDate: "",
status: "OPEN" as const,
effectivenessCheck: "",
},
});
const onSubmit = async (formData: FormData) => {
try {
await createNonconformity({
organizationId,
referenceId: formData.referenceId,
description: formData.description || undefined,
auditId: formData.auditId || undefined,
dateIdentified: formatDatetime(formData.dateIdentified),
rootCause: formData.rootCause,
correctiveAction: formData.correctiveAction || undefined,
ownerId: formData.ownerId,
dueDate: formatDatetime(formData.dueDate),
status: formData.status,
effectivenessCheck: formData.effectivenessCheck || undefined,
});
toast({
title: __("Success"),
description: __("Nonconformity created successfully"),
variant: "success",
});
reset();
dialogRef.current?.close();
} catch (error) {
toast({
title: __("Error"),
description: formatError(__("Failed to create nonconformity"), error as GraphQLError),
variant: "error",
});
}
};
return (
<Dialog
ref={dialogRef}
trigger={children}
title={<Breadcrumb items={[__("Nonconformities"), __("Create")]} />}
className="max-w-2xl"
>
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
<DialogContent padded className="space-y-4">
<Field
label={__("Reference ID")}
{...register("referenceId")}
placeholder="NC-001"
error={formState.errors.referenceId?.message}
required
/>
<AuditSelectField
organizationId={organizationId}
control={control}
name="auditId"
label={__("Audit")}
error={formState.errors.auditId?.message}
/>
<div className="space-y-2">
<Label htmlFor="description">{__("Description")}</Label>
<Textarea
id="description"
{...register("description")}
placeholder={__("Brief description of the nonconformity...")}
rows={2}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<Field label={__("Status")}>
<Controller
control={control}
name="status"
render={({ field }) => (
<Select
variant="editor"
placeholder={__("Select status")}
onValueChange={field.onChange}
value={field.value}
className="w-full"
>
{statusOptions.map(option => (
<Option key={option.value} value={option.value}>
{option.label}
</Option>
))}
</Select>
)}
/>
{formState.errors.status && (
<p className="text-sm text-red-500 mt-1">{formState.errors.status.message}</p>
)}
</Field>
<PeopleSelectField
organizationId={organizationId}
control={control}
name="ownerId"
label={__("Owner")}
error={formState.errors.ownerId?.message}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="dateIdentified">{__("Date Identified")}</Label>
<Input
id="dateIdentified"
type="date"
{...register("dateIdentified")}
/>
{formState.errors.dateIdentified && (
<p className="text-sm text-red-500">{formState.errors.dateIdentified.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="dueDate">{__("Due Date")}</Label>
<Input
id="dueDate"
type="date"
{...register("dueDate")}
/>
{formState.errors.dueDate && (
<p className="text-sm text-red-500">{formState.errors.dueDate.message}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="rootCause">
{__("Root Cause")}
{" "}
*
</Label>
<Textarea
id="rootCause"
{...register("rootCause")}
placeholder={__("Detailed analysis of the root cause...")}
rows={3}
/>
{formState.errors.rootCause && (
<p className="text-sm text-red-500">{formState.errors.rootCause.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="correctiveAction">{__("Corrective Action")}</Label>
<Textarea
id="correctiveAction"
{...register("correctiveAction")}
placeholder={__("Proposed corrective actions...")}
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="effectivenessCheck">{__("Effectiveness Check")}</Label>
<Textarea
id="effectivenessCheck"
{...register("effectivenessCheck")}
placeholder={__("Assessment of corrective action effectiveness...")}
rows={2}
/>
</div>
</DialogContent>
<DialogFooter>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? __("Creating...") : __("Create Nonconformity")}
</Button>
</DialogFooter>
</form>
</Dialog>
);
}

View File

@@ -19,13 +19,12 @@ import { compliancePageRoutes } from "./pages/organizations/compliance-page/rout
import { CurrentUser } from "./providers/CurrentUser";
import { assetRoutes } from "./routes/assetRoutes";
import { auditRoutes } from "./routes/auditRoutes";
import { continualImprovementRoutes } from "./routes/continualImprovementRoutes";
import { dataRoutes } from "./routes/dataRoutes";
import { documentsRoutes } from "./routes/documentsRoutes";
import { findingRoutes } from "./routes/findingRoutes";
import { frameworkRoutes } from "./routes/frameworkRoutes";
import { measureRoutes } from "./routes/measureRoutes";
import { meetingsRoutes } from "./routes/meetingsRoutes";
import { nonconformityRoutes } from "./routes/nonconformityRoutes";
import { obligationRoutes } from "./routes/obligationRoutes";
import { processingActivityRoutes } from "./routes/processingActivityRoutes";
import { rightsRequestRoutes } from "./routes/rightsRequestRoutes";
@@ -224,9 +223,8 @@ const routes = [
...dataRoutes,
...auditRoutes,
...meetingsRoutes,
...nonconformityRoutes,
...findingRoutes,
...obligationRoutes,
...continualImprovementRoutes,
...rightsRequestRoutes,
...processingActivityRoutes,
...statesOfApplicabilityRoutes,

View File

@@ -1,97 +0,0 @@
import { lazy } from "@probo/react-lazy";
import {
type AppRoute,
loaderFromQueryLoader,
withQueryRef,
} from "@probo/routes";
import { loadQuery } from "react-relay";
import type { ContinualImprovementGraphListQuery } from "#/__generated__/core/ContinualImprovementGraphListQuery.graphql";
import type { ContinualImprovementGraphNodeQuery } from "#/__generated__/core/ContinualImprovementGraphNodeQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { coreEnvironment } from "#/environments";
import {
continualImprovementNodeQuery,
continualImprovementsQuery,
} from "#/hooks/graph/ContinualImprovementGraph";
export const continualImprovementRoutes = [
{
path: "continual-improvements",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<ContinualImprovementGraphListQuery>(
coreEnvironment,
continualImprovementsQuery,
{
organizationId,
snapshotId: null,
},
),
),
Component: withQueryRef(
lazy(
() =>
import("#/pages/organizations/continualImprovements/ContinualImprovementsPage"),
),
),
},
{
path: "snapshots/:snapshotId/continual-improvements",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<ContinualImprovementGraphListQuery>(
coreEnvironment,
continualImprovementsQuery,
{
organizationId,
snapshotId,
},
),
),
Component: withQueryRef(
lazy(
() =>
import("#/pages/organizations/continualImprovements/ContinualImprovementsPage"),
),
),
},
{
path: "continual-improvements/:improvementId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ improvementId }) =>
loadQuery<ContinualImprovementGraphNodeQuery>(
coreEnvironment,
continualImprovementNodeQuery,
{
continualImprovementId: improvementId,
},
),
),
Component: withQueryRef(
lazy(
() =>
import("#/pages/organizations/continualImprovements/ContinualImprovementDetailsPage"),
),
),
},
{
path: "snapshots/:snapshotId/continual-improvements/:improvementId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ improvementId }) =>
loadQuery<ContinualImprovementGraphNodeQuery>(
coreEnvironment,
continualImprovementNodeQuery,
{
continualImprovementId: improvementId,
},
),
),
Component: withQueryRef(
lazy(
() =>
import("#/pages/organizations/continualImprovements/ContinualImprovementDetailsPage"),
),
),
},
] satisfies AppRoute[];

View File

@@ -0,0 +1,39 @@
import { lazy } from "@probo/react-lazy";
import type { AppRoute } from "@probo/routes";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
export const findingRoutes = [
{
path: "findings",
Fallback: PageSkeleton,
Component: lazy(
() =>
import("#/pages/organizations/findings/FindingsPageLoader"),
),
},
{
path: "snapshots/:snapshotId/findings",
Fallback: PageSkeleton,
Component: lazy(
() =>
import("#/pages/organizations/findings/FindingsPageLoader"),
),
},
{
path: "findings/:findingId",
Fallback: PageSkeleton,
Component: lazy(
() =>
import("#/pages/organizations/findings/FindingDetailsPageLoader"),
),
},
{
path: "snapshots/:snapshotId/findings/:findingId",
Fallback: PageSkeleton,
Component: lazy(
() =>
import("#/pages/organizations/findings/FindingDetailsPageLoader"),
),
},
] satisfies AppRoute[];

View File

@@ -1,98 +0,0 @@
import { lazy } from "@probo/react-lazy";
import {
type AppRoute,
loaderFromQueryLoader,
withQueryRef,
} from "@probo/routes";
import { loadQuery } from "react-relay";
import type { NonconformityGraphListQuery } from "#/__generated__/core/NonconformityGraphListQuery.graphql";
import type { NonconformityGraphNodeQuery } from "#/__generated__/core/NonconformityGraphNodeQuery.graphql";
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
import { coreEnvironment } from "#/environments";
import {
nonconformitiesQuery,
nonconformityNodeQuery,
} from "../hooks/graph/NonconformityGraph";
export const nonconformityRoutes = [
{
path: "nonconformities",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId }) =>
loadQuery<NonconformityGraphListQuery>(
coreEnvironment,
nonconformitiesQuery,
{
organizationId,
snapshotId: null,
},
),
),
Component: withQueryRef(
lazy(
() =>
import("../pages/organizations/nonconformities/NonconformitiesPage"),
),
),
},
{
path: "snapshots/:snapshotId/nonconformities",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ organizationId, snapshotId }) =>
loadQuery<NonconformityGraphListQuery>(
coreEnvironment,
nonconformitiesQuery,
{
organizationId,
snapshotId,
},
),
),
Component: withQueryRef(
lazy(
() =>
import("../pages/organizations/nonconformities/NonconformitiesPage"),
),
),
},
{
path: "nonconformities/:nonconformityId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ nonconformityId }) =>
loadQuery<NonconformityGraphNodeQuery>(
coreEnvironment,
nonconformityNodeQuery,
{
nonconformityId,
},
),
),
Component: withQueryRef(
lazy(
() =>
import("../pages/organizations/nonconformities/NonconformityDetailsPage"),
),
),
},
{
path: "snapshots/:snapshotId/nonconformities/:nonconformityId",
Fallback: PageSkeleton,
loader: loaderFromQueryLoader(({ nonconformityId }) =>
loadQuery<NonconformityGraphNodeQuery>(
coreEnvironment,
nonconformityNodeQuery,
{
nonconformityId: nonconformityId,
},
),
),
Component: withQueryRef(
lazy(
() =>
import("../pages/organizations/nonconformities/NonconformityDetailsPage"),
),
),
},
] satisfies AppRoute[];