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:
@@ -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)}>
|
||||
|
||||
@@ -136,6 +136,7 @@ type RowProps = {
|
||||
name: string;
|
||||
category: string;
|
||||
id: string;
|
||||
description?: string | null;
|
||||
};
|
||||
linkedRisks: Set<string>;
|
||||
disabled?: boolean;
|
||||
|
||||
@@ -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 ?? "",
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
549
apps/console/src/pages/organizations/findings/FindingsPage.tsx
Normal file
549
apps/console/src/pages/organizations/findings/FindingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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[];
|
||||
39
apps/console/src/routes/findingRoutes.ts
Normal file
39
apps/console/src/routes/findingRoutes.ts
Normal 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[];
|
||||
@@ -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[];
|
||||
Reference in New Issue
Block a user