Refactor third parties frontend to page arborescence
Mirror the risks refactor (c78a713): colocate routes.ts, split the
detail layout query so each child route owns its Loader + Page, rename
tabs/*Tab to resource folders with *Page, move dialogs into
_components/, and extract ThirdPartyRow with its own fragment.
Remove outlet context data passing and deprecated
loaderFromQueryLoader. Delete the monolithic ThirdPartyGraph hook,
colocating each GraphQL operation with its consumer: the create
mutation in CreateThirdPartyDialog (now useMutation + useToast) and
the third-party list queries in ThirdPartiesCell and
ThirdPartiesMultiSelectField (now useQueryLoader + usePreloadedQuery).
Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
@@ -15,16 +15,38 @@
|
||||
import { faviconUrl } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Avatar, Badge, Button, Field, IconCrossLargeX, Option, Select } from "@probo/ui";
|
||||
import { type ComponentProps, Suspense, useState } from "react";
|
||||
import { type ComponentProps, Suspense, useEffect, useState } from "react";
|
||||
import { type Control, Controller, type FieldValues, type Path } from "react-hook-form";
|
||||
import { type PreloadedQuery, usePreloadedQuery, useQueryLoader } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import { useThirdParties } from "#/hooks/graph/ThirdPartyGraph";
|
||||
import type { ThirdPartiesMultiSelectFieldQuery } from "#/__generated__/core/ThirdPartiesMultiSelectFieldQuery.graphql";
|
||||
|
||||
const thirdPartiesQuery = graphql`
|
||||
query ThirdPartiesMultiSelectFieldQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) {
|
||||
... on Organization {
|
||||
thirdParties(
|
||||
first: 100
|
||||
orderBy: { direction: ASC, field: NAME }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
websiteUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type ThirdParty = {
|
||||
id: string;
|
||||
name: string;
|
||||
websiteUrl: string | null | undefined;
|
||||
level?: number;
|
||||
};
|
||||
|
||||
type Props<T extends FieldValues = FieldValues> = {
|
||||
@@ -42,29 +64,47 @@ export function ThirdPartiesMultiSelectField<T extends FieldValues = FieldValues
|
||||
selectedThirdParties = [],
|
||||
...props
|
||||
}: Props<T>) {
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<ThirdPartiesMultiSelectFieldQuery>(thirdPartiesQuery);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuery({ organizationId }, { fetchPolicy: "network-only" });
|
||||
}, [loadQuery, organizationId]);
|
||||
|
||||
const loadingState = (
|
||||
<Select variant="editor" disabled placeholder="Loading..." />
|
||||
);
|
||||
|
||||
return (
|
||||
<Field {...props}>
|
||||
<Suspense
|
||||
fallback={<Select variant="editor" disabled placeholder="Loading..." />}
|
||||
>
|
||||
<ThirdPartiesMultiSelectWithQuery
|
||||
organizationId={organizationId}
|
||||
control={control}
|
||||
name={props.name}
|
||||
disabled={props.disabled}
|
||||
selectedThirdParties={selectedThirdParties}
|
||||
/>
|
||||
</Suspense>
|
||||
{queryRef
|
||||
? (
|
||||
<Suspense fallback={loadingState}>
|
||||
<ThirdPartiesMultiSelectWithQuery
|
||||
queryRef={queryRef}
|
||||
control={control}
|
||||
name={props.name}
|
||||
disabled={props.disabled}
|
||||
selectedThirdParties={selectedThirdParties}
|
||||
/>
|
||||
</Suspense>
|
||||
)
|
||||
: (
|
||||
loadingState
|
||||
)}
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function ThirdPartiesMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
props: Pick<Props<T>, "organizationId" | "control" | "name" | "disabled" | "selectedThirdParties">,
|
||||
props: Pick<Props<T>, "control" | "name" | "disabled" | "selectedThirdParties"> & {
|
||||
queryRef: PreloadedQuery<ThirdPartiesMultiSelectFieldQuery>;
|
||||
},
|
||||
) {
|
||||
const { __ } = useTranslate();
|
||||
const { name, organizationId, control, selectedThirdParties = [] } = props;
|
||||
const thirdParties = useThirdParties(organizationId);
|
||||
const { name, control, selectedThirdParties = [] } = props;
|
||||
const data = usePreloadedQuery<ThirdPartiesMultiSelectFieldQuery>(thirdPartiesQuery, props.queryRef);
|
||||
const thirdParties = data.organization?.thirdParties?.edges.map(edge => edge.node) ?? [];
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const allThirdParties: ThirdParty[] = [...thirdParties];
|
||||
|
||||
@@ -14,10 +14,31 @@
|
||||
|
||||
import { faviconUrl } from "@probo/helpers";
|
||||
import { Avatar, Badge, IconCrossLargeX } from "@probo/ui";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { ThirdPartyGraphSelectQuery } from "#/__generated__/core/ThirdPartyGraphSelectQuery.graphql";
|
||||
import type { ThirdPartiesCellQuery } from "#/__generated__/core/ThirdPartiesCellQuery.graphql";
|
||||
import { GraphQLCell } from "#/components/table/GraphQLCell";
|
||||
import { thirdPartiesSelectQuery } from "#/hooks/graph/ThirdPartyGraph";
|
||||
|
||||
const thirdPartiesCellQuery = graphql`
|
||||
query ThirdPartiesCellQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) {
|
||||
... on Organization {
|
||||
thirdParties(
|
||||
first: 100
|
||||
orderBy: { direction: ASC, field: NAME }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
websiteUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type ThirdParty = {
|
||||
id: string;
|
||||
@@ -35,15 +56,19 @@ const empty = [] as ThirdParty[];
|
||||
|
||||
export function ThirdPartiesCell(props: Props) {
|
||||
return (
|
||||
<GraphQLCell<ThirdPartyGraphSelectQuery, ThirdParty>
|
||||
<GraphQLCell<ThirdPartiesCellQuery, ThirdParty>
|
||||
multiple
|
||||
name={props.name}
|
||||
query={thirdPartiesSelectQuery}
|
||||
query={thirdPartiesCellQuery}
|
||||
variables={{
|
||||
organizationId: props.organizationId,
|
||||
}}
|
||||
items={data =>
|
||||
data.organization?.thirdParties?.edges?.map(edge => edge.node) ?? []}
|
||||
data.organization?.thirdParties?.edges?.map(edge => ({
|
||||
id: edge.node.id,
|
||||
name: edge.node.name,
|
||||
websiteUrl: edge.node.websiteUrl,
|
||||
})) ?? []}
|
||||
itemRenderer={({ item, onRemove }) => (
|
||||
<ThirdPartyBadge thirdParty={item} onRemove={onRemove} />
|
||||
)}
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { promisifyMutation, sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { useConfirm } from "@probo/ui";
|
||||
import { useMemo } from "react";
|
||||
import { useLazyLoadQuery, useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { ThirdPartyGraphCreateMutation } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
|
||||
import type { ThirdPartyGraphDeleteMutation } from "#/__generated__/core/ThirdPartyGraphDeleteMutation.graphql";
|
||||
import type { ThirdPartyGraphSelectQuery } from "#/__generated__/core/ThirdPartyGraphSelectQuery.graphql";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
/* eslint-disable relay/unused-fields, relay/must-colocate-fragment-spreads */
|
||||
|
||||
const createThirdPartyMutation = graphql`
|
||||
mutation ThirdPartyGraphCreateMutation(
|
||||
$input: CreateThirdPartyInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
name
|
||||
description
|
||||
websiteUrl
|
||||
createdAt
|
||||
updatedAt
|
||||
canUpdate: permission(action: "core:thirdParty:update")
|
||||
canDelete: permission(action: "core:thirdParty:delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function useCreateThirdPartyMutation() {
|
||||
const { __ } = useTranslate();
|
||||
return useMutationWithToasts<ThirdPartyGraphCreateMutation>(createThirdPartyMutation, {
|
||||
successMessage: __("Third party created successfully"),
|
||||
errorMessage: __("Failed to create third party"),
|
||||
});
|
||||
}
|
||||
|
||||
const deleteThirdPartyMutation = graphql`
|
||||
mutation ThirdPartyGraphDeleteMutation(
|
||||
$input: DeleteThirdPartyInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteThirdParty(input: $input) {
|
||||
deletedThirdPartyId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const useDeleteThirdParty = (
|
||||
thirdParty: { id?: string; name?: string },
|
||||
connectionId: string,
|
||||
) => {
|
||||
const [mutate] = useMutation<ThirdPartyGraphDeleteMutation>(deleteThirdPartyMutation);
|
||||
const confirm = useConfirm();
|
||||
const { __ } = useTranslate();
|
||||
|
||||
return () => {
|
||||
if (!thirdParty.id || !thirdParty.name) {
|
||||
return alert(__("Failed to delete third party: missing id or name"));
|
||||
}
|
||||
confirm(
|
||||
() =>
|
||||
promisifyMutation(mutate)({
|
||||
variables: {
|
||||
input: {
|
||||
thirdPartyId: thirdParty.id!,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
}),
|
||||
{
|
||||
message: sprintf(
|
||||
__(
|
||||
"This will permanently delete thirdParty \"%s\". This action cannot be undone.",
|
||||
),
|
||||
thirdParty.name,
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const thirdPartyConnectionKey = "ThirdPartiesPage_thirdParties";
|
||||
|
||||
export const thirdPartiesQuery = graphql`
|
||||
query ThirdPartyGraphListQuery($organizationId: ID!) {
|
||||
node(id: $organizationId) {
|
||||
... on Organization {
|
||||
id
|
||||
canCreateThirdParty: permission(action: "core:thirdParty:create")
|
||||
canPublishThirdParty: permission(action: "core:thirdParty:publish")
|
||||
thirdPartiesDocument {
|
||||
id
|
||||
currentPublishedMajor
|
||||
currentPublishedMinor
|
||||
defaultApprovers {
|
||||
id
|
||||
}
|
||||
}
|
||||
...ThirdPartyGraphPaginatedFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const paginatedThirdPartiesFragment = graphql`
|
||||
fragment ThirdPartyGraphPaginatedFragment on Organization
|
||||
@refetchable(queryName: "ThirdPartiesListQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: { type: "ThirdPartyOrder", defaultValue: null }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
filter: { type: "ThirdPartyFilter", defaultValue: { level: 1 } }
|
||||
) {
|
||||
thirdParties(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
filter: $filter
|
||||
) @connection(key: "ThirdPartiesListQuery_thirdParties", filters: ["filter"]) {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
websiteUrl
|
||||
level
|
||||
updatedAt
|
||||
riskAssessments(
|
||||
first: 1
|
||||
orderBy: { direction: DESC, field: CREATED_AT }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
createdAt
|
||||
expiresAt
|
||||
dataSensitivity
|
||||
businessImpact
|
||||
}
|
||||
}
|
||||
}
|
||||
canUpdate: permission(action: "core:thirdParty:update")
|
||||
canDelete: permission(action: "core:thirdParty:delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const thirdPartyNodeQuery = graphql`
|
||||
query ThirdPartyGraphNodeQuery($thirdPartyId: ID!) {
|
||||
node(id: $thirdPartyId) {
|
||||
id
|
||||
... on ThirdParty {
|
||||
name
|
||||
websiteUrl
|
||||
level
|
||||
ancestors {
|
||||
id
|
||||
name
|
||||
}
|
||||
vettingStatus
|
||||
canVet: permission(action: "core:thirdParty:vet")
|
||||
canUpdate: permission(action: "core:thirdParty:update")
|
||||
canDelete: permission(action: "core:thirdParty:delete")
|
||||
canUploadComplianceReport: permission(
|
||||
action: "core:thirdParty-compliance-report:upload"
|
||||
)
|
||||
canCreateRiskAssessment: permission(
|
||||
action: "core:thirdParty-risk-assessment:create"
|
||||
)
|
||||
canCreateContact: permission(action: "core:thirdParty-contact:create")
|
||||
canCreateService: permission(action: "core:thirdParty-service:create")
|
||||
canUploadBAA: permission(
|
||||
action: "core:thirdParty-business-associate-agreement:upload"
|
||||
)
|
||||
canUploadDPA: permission(
|
||||
action: "core:thirdParty-data-privacy-agreement:upload"
|
||||
)
|
||||
measuresInfos: measures(first: 0) {
|
||||
totalCount
|
||||
}
|
||||
...useThirdPartyFormFragment
|
||||
...ThirdPartyComplianceTabFragment
|
||||
...ThirdPartyContactsTabFragment
|
||||
...ThirdPartyServicesTabFragment
|
||||
...ThirdPartyRiskAssessmentTabFragment
|
||||
...ThirdPartyOverviewTabBusinessAssociateAgreementFragment
|
||||
...ThirdPartyOverviewTabDataPrivacyAgreementFragment
|
||||
...ThirdPartyMeasuresPageFragment
|
||||
}
|
||||
}
|
||||
viewer {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const thirdPartiesSelectQuery = graphql`
|
||||
query ThirdPartyGraphSelectQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) {
|
||||
... on Organization {
|
||||
thirdParties(
|
||||
first: 100
|
||||
orderBy: { direction: ASC, field: NAME }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
websiteUrl
|
||||
level
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function useThirdParties(organizationId: string) {
|
||||
const data = useLazyLoadQuery<ThirdPartyGraphSelectQuery>(
|
||||
thirdPartiesSelectQuery,
|
||||
{
|
||||
organizationId: organizationId,
|
||||
},
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
return useMemo(() => {
|
||||
return data.organization?.thirdParties?.edges.map(edge => edge.node) ?? [];
|
||||
}, [data]);
|
||||
}
|
||||
@@ -12,83 +12,131 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { faviconUrl, formatDate } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
Avatar,
|
||||
Button,
|
||||
DropdownItem,
|
||||
IconPageTextLine,
|
||||
IconPlusLarge,
|
||||
IconTrashCan,
|
||||
IconUpload,
|
||||
PageHeader,
|
||||
RiskBadge,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@probo/ui";
|
||||
import {
|
||||
graphql,
|
||||
type PreloadedQuery,
|
||||
usePaginationFragment,
|
||||
usePreloadedQuery,
|
||||
} from "react-relay";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import type { ThirdPartyGraphListQuery } from "#/__generated__/core/ThirdPartyGraphListQuery.graphql";
|
||||
import type {
|
||||
ThirdPartyGraphPaginatedFragment$data,
|
||||
ThirdPartyGraphPaginatedFragment$key,
|
||||
} from "#/__generated__/core/ThirdPartyGraphPaginatedFragment.graphql";
|
||||
import type { ThirdPartiesPageFragment$key } from "#/__generated__/core/ThirdPartiesPageFragment.graphql";
|
||||
import type { ThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartiesPageQuery.graphql";
|
||||
import type { ThirdPartiesPageRefetchQuery } from "#/__generated__/core/ThirdPartiesPageRefetchQuery.graphql";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
import {
|
||||
paginatedThirdPartiesFragment,
|
||||
thirdPartiesQuery,
|
||||
useDeleteThirdParty,
|
||||
} from "#/hooks/graph/ThirdPartyGraph";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import type { NodeOf } from "#/types";
|
||||
|
||||
import { CreateThirdPartyDialog } from "./dialogs/CreateThirdPartyDialog";
|
||||
import { PublishThirdPartyListDialog } from "./dialogs/PublishThirdPartyListDialog";
|
||||
import { CreateThirdPartyDialog } from "./_components/CreateThirdPartyDialog";
|
||||
import { PublishThirdPartyListDialog } from "./_components/PublishThirdPartyListDialog";
|
||||
import { ThirdPartyRow } from "./_components/ThirdPartyRow";
|
||||
|
||||
type ThirdParty = NodeOf<ThirdPartyGraphPaginatedFragment$data["thirdParties"]>;
|
||||
export const thirdPartiesPageQuery = graphql`
|
||||
query ThirdPartiesPageQuery($organizationId: ID!) {
|
||||
organization: node(id: $organizationId) {
|
||||
id
|
||||
...ThirdPartiesPageFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function thirdPartyDisplayName(tp: ThirdParty): string {
|
||||
// Sub-third-parties are persisted with their fully-qualified name
|
||||
// (e.g. "aws (Probo/Level2/Level3)"), so the stored name is shown as-is.
|
||||
return tp.name;
|
||||
const thirdPartiesFragment = graphql`
|
||||
fragment ThirdPartiesPageFragment on Organization
|
||||
@refetchable(queryName: "ThirdPartiesPageRefetchQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: { type: "ThirdPartyOrder", defaultValue: null }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
filter: { type: "ThirdPartyFilter", defaultValue: { level: 1 } }
|
||||
) {
|
||||
canCreateThirdParty: permission(action: "core:thirdParty:create")
|
||||
canPublishThirdParty: permission(action: "core:thirdParty:publish")
|
||||
thirdPartiesDocument {
|
||||
id
|
||||
defaultApprovers {
|
||||
id
|
||||
}
|
||||
}
|
||||
thirdParties(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
filter: $filter
|
||||
) @connection(key: "ThirdPartiesPage_thirdParties", filters: ["filter"]) {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
canDelete: permission(action: "core:thirdParty:delete")
|
||||
...ThirdPartyRow_thirdParty
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ThirdPartiesConnectionKey = "ThirdPartiesPage_thirdParties";
|
||||
|
||||
// Must match the `filter` default of `ThirdPartiesPageFragment` above — the
|
||||
// connection is keyed on this filter (`@connection(filters: ["filter"])`), so
|
||||
// deriving its id elsewhere requires the same value.
|
||||
export const ThirdPartiesConnectionFilter = { level: 1 };
|
||||
|
||||
interface ThirdPartiesPageProps {
|
||||
queryRef: PreloadedQuery<ThirdPartiesPageQuery>;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<ThirdPartyGraphListQuery>;
|
||||
};
|
||||
|
||||
export default function ThirdPartiesPage(props: Props) {
|
||||
export default function ThirdPartiesPage(props: ThirdPartiesPageProps) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const data = usePreloadedQuery<ThirdPartyGraphListQuery>(thirdPartiesQuery, props.queryRef);
|
||||
// eslint-disable-next-line relay/generated-typescript-types
|
||||
const pagination = usePaginationFragment(
|
||||
paginatedThirdPartiesFragment,
|
||||
data.node as ThirdPartyGraphPaginatedFragment$key,
|
||||
);
|
||||
const queryData = usePreloadedQuery<ThirdPartiesPageQuery>(thirdPartiesPageQuery, props.queryRef);
|
||||
const { data: fragmentData, ...pagination } = usePaginationFragment<
|
||||
ThirdPartiesPageRefetchQuery,
|
||||
ThirdPartiesPageFragment$key
|
||||
>(thirdPartiesFragment, queryData.organization);
|
||||
|
||||
const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node);
|
||||
const connectionId = pagination.data.thirdParties.__id;
|
||||
const refetch = ({
|
||||
order,
|
||||
}: {
|
||||
order: { direction: string; field: string };
|
||||
}) => {
|
||||
pagination.refetch(
|
||||
{
|
||||
order: {
|
||||
direction: order.direction as "ASC" | "DESC",
|
||||
field: order.field as "NAME" | "CREATED_AT" | "UPDATED_AT",
|
||||
},
|
||||
},
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
};
|
||||
|
||||
const thirdParties = fragmentData.thirdParties?.edges.map(edge => edge.node) ?? [];
|
||||
const connectionId = fragmentData.thirdParties.__id;
|
||||
|
||||
usePageTitle(__("Third parties"));
|
||||
|
||||
const hasAnyAction
|
||||
= thirdParties.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
|
||||
const hasAnyAction = thirdParties.some(({ canDelete }) => canDelete);
|
||||
|
||||
const thirdPartiesDocument = data.node?.thirdPartiesDocument;
|
||||
const thirdPartiesDocument = fragmentData.thirdPartiesDocument;
|
||||
const defaultApproverIds
|
||||
= thirdPartiesDocument?.defaultApprovers?.map(a => a.id) ?? [];
|
||||
|
||||
@@ -112,7 +160,7 @@ export default function ThirdPartiesPage(props: Props) {
|
||||
{__("Document")}
|
||||
</Button>
|
||||
)}
|
||||
{data.node.canPublishThirdParty && (
|
||||
{fragmentData.canPublishThirdParty && (
|
||||
<PublishThirdPartyListDialog
|
||||
organizationId={organizationId}
|
||||
defaultApproverIds={defaultApproverIds}
|
||||
@@ -125,7 +173,7 @@ export default function ThirdPartiesPage(props: Props) {
|
||||
</Button>
|
||||
</PublishThirdPartyListDialog>
|
||||
)}
|
||||
{data.node.canCreateThirdParty && (
|
||||
{fragmentData.canCreateThirdParty && (
|
||||
<CreateThirdPartyDialog
|
||||
connection={connectionId}
|
||||
organizationId={organizationId}
|
||||
@@ -135,7 +183,7 @@ export default function ThirdPartiesPage(props: Props) {
|
||||
)}
|
||||
</div>
|
||||
</PageHeader>
|
||||
<SortableTable {...pagination}>
|
||||
<SortableTable {...pagination} refetch={refetch}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<SortableTh field="NAME">{__("Third party")}</SortableTh>
|
||||
@@ -146,11 +194,10 @@ export default function ThirdPartiesPage(props: Props) {
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{thirdParties?.map(thirdParty => (
|
||||
{thirdParties.map(thirdParty => (
|
||||
<ThirdPartyRow
|
||||
key={thirdParty.id}
|
||||
thirdParty={thirdParty}
|
||||
organizationId={organizationId}
|
||||
thirdPartyKey={thirdParty}
|
||||
connectionId={connectionId}
|
||||
hasAnyAction={hasAnyAction}
|
||||
/>
|
||||
@@ -160,61 +207,3 @@ export default function ThirdPartiesPage(props: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThirdPartyRow({
|
||||
thirdParty,
|
||||
organizationId,
|
||||
connectionId,
|
||||
hasAnyAction,
|
||||
}: {
|
||||
thirdParty: ThirdParty;
|
||||
organizationId: string;
|
||||
connectionId: string;
|
||||
hasAnyAction: boolean;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const latestAssessment = thirdParty.riskAssessments?.edges[0]?.node;
|
||||
const deleteThirdParty = useDeleteThirdParty(thirdParty, connectionId);
|
||||
const displayName = thirdPartyDisplayName(thirdParty);
|
||||
|
||||
const thirdPartyUrl = `/organizations/${organizationId}/third-parties/${thirdParty.id}/overview`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr to={thirdPartyUrl}>
|
||||
<Td>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Avatar name={thirdParty.name} src={faviconUrl(thirdParty.websiteUrl)} />
|
||||
<div>{displayName}</div>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{latestAssessment?.createdAt
|
||||
? formatDate(latestAssessment.createdAt)
|
||||
: __("Not assessed")}
|
||||
</Td>
|
||||
<Td>
|
||||
<RiskBadge level={latestAssessment?.dataSensitivity ?? "NONE"} />
|
||||
</Td>
|
||||
<Td>
|
||||
<RiskBadge level={latestAssessment?.businessImpact ?? "NONE"} />
|
||||
</Td>
|
||||
{hasAnyAction && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
{thirdParty.canDelete && (
|
||||
<DropdownItem
|
||||
onClick={deleteThirdParty}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
|
||||
import type { ThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartiesPageQuery.graphql";
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import ThirdPartiesPage, { thirdPartiesPageQuery } from "./ThirdPartiesPage";
|
||||
|
||||
export default function ThirdPartiesPageLoader() {
|
||||
const organizationId = useOrganizationId();
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<ThirdPartiesPageQuery>(thirdPartiesPageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuery({ organizationId });
|
||||
}, [loadQuery, organizationId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<ThirdPartiesPage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,8 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { faviconUrl } from "@probo/helpers";
|
||||
import { faviconUrl, formatError, sprintf } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
@@ -25,59 +26,113 @@ import {
|
||||
TabBadge,
|
||||
TabLink,
|
||||
Tabs,
|
||||
useConfirm,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { useEffect, useRef } from "react";
|
||||
import {
|
||||
ConnectionHandler,
|
||||
graphql,
|
||||
type PreloadedQuery,
|
||||
useFragment,
|
||||
useMutation,
|
||||
usePreloadedQuery,
|
||||
useRelayEnvironment,
|
||||
} from "react-relay";
|
||||
import { Link, Outlet } from "react-router";
|
||||
import { fetchQuery } from "relay-runtime";
|
||||
import { Link, Outlet, useNavigate, useParams } from "react-router";
|
||||
import { ConnectionHandler, fetchQuery } from "relay-runtime";
|
||||
|
||||
import type { ThirdPartyComplianceTabFragment$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment.graphql";
|
||||
import type { ThirdPartyGraphNodeQuery } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
|
||||
import {
|
||||
thirdPartyConnectionKey,
|
||||
thirdPartyNodeQuery,
|
||||
useDeleteThirdParty,
|
||||
} from "#/hooks/graph/ThirdPartyGraph";
|
||||
import type { ThirdPartyDetailLayoutDeleteMutation } from "#/__generated__/core/ThirdPartyDetailLayoutDeleteMutation.graphql";
|
||||
import type { ThirdPartyDetailLayoutQuery } from "#/__generated__/core/ThirdPartyDetailLayoutQuery.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { VettingDialog } from "./dialogs/VettingDialog";
|
||||
import { measuresFragment } from "./measures/ThirdPartyMeasuresPage";
|
||||
import { complianceReportsFragment } from "./tabs/ThirdPartyComplianceTab";
|
||||
import { VettingDialog } from "./_components/VettingDialog";
|
||||
import { ThirdPartiesConnectionFilter, ThirdPartiesConnectionKey } from "./ThirdPartiesPage";
|
||||
|
||||
void measuresFragment;
|
||||
export const thirdPartyDetailLayoutQuery = graphql`
|
||||
query ThirdPartyDetailLayoutQuery($thirdPartyId: ID!) {
|
||||
node(id: $thirdPartyId) {
|
||||
__typename
|
||||
... on ThirdParty {
|
||||
id
|
||||
name
|
||||
websiteUrl
|
||||
level
|
||||
ancestors {
|
||||
id
|
||||
name
|
||||
}
|
||||
vettingStatus
|
||||
canVet: permission(action: "core:thirdParty:vet")
|
||||
canDelete: permission(action: "core:thirdParty:delete")
|
||||
complianceReportsInfo: complianceReports(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
measuresInfo: measures(first: 0) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
queryRef: PreloadedQuery<ThirdPartyGraphNodeQuery>;
|
||||
};
|
||||
const deleteThirdPartyMutation = graphql`
|
||||
mutation ThirdPartyDetailLayoutDeleteMutation(
|
||||
$input: DeleteThirdPartyInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteThirdParty(input: $input) {
|
||||
deletedThirdPartyId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function ThirdPartyDetailPage(props: Props) {
|
||||
interface ThirdPartyDetailLayoutProps {
|
||||
queryRef: PreloadedQuery<ThirdPartyDetailLayoutQuery>;
|
||||
}
|
||||
|
||||
export default function ThirdPartyDetailLayout(props: ThirdPartyDetailLayoutProps) {
|
||||
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
|
||||
const environment = useRelayEnvironment();
|
||||
const { node: thirdParty } = usePreloadedQuery<ThirdPartyGraphNodeQuery>(thirdPartyNodeQuery, props.queryRef);
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const thirdPartyIdRef = useRef(thirdParty.id);
|
||||
const navigate = useNavigate();
|
||||
const { __ } = useTranslate();
|
||||
const confirm = useConfirm();
|
||||
const { toast } = useToast();
|
||||
|
||||
if (!thirdPartyId) {
|
||||
throw new Error("Cannot load third party detail layout without thirdPartyId parameter");
|
||||
}
|
||||
|
||||
const data = usePreloadedQuery<ThirdPartyDetailLayoutQuery>(thirdPartyDetailLayoutQuery, props.queryRef);
|
||||
if (data.node?.__typename !== "ThirdParty") {
|
||||
throw new Error("Third party not found");
|
||||
}
|
||||
const thirdParty = data.node;
|
||||
|
||||
const thirdPartyIdRef = useRef(thirdParty.id);
|
||||
useEffect(() => {
|
||||
thirdPartyIdRef.current = thirdParty.id;
|
||||
}, [thirdParty.id]);
|
||||
|
||||
const isVetting = thirdParty.vettingStatus === "PENDING" || thirdParty.vettingStatus === "PROCESSING";
|
||||
const isVetting = thirdParty.vettingStatus === "PENDING"
|
||||
|| thirdParty.vettingStatus === "PROCESSING";
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVetting) return;
|
||||
if (!isVetting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
if (document.hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetchQuery<ThirdPartyGraphNodeQuery>(
|
||||
fetchQuery(
|
||||
environment,
|
||||
thirdPartyNodeQuery,
|
||||
thirdPartyDetailLayoutQuery,
|
||||
{ thirdPartyId: thirdPartyIdRef.current },
|
||||
{ fetchPolicy: "network-only" },
|
||||
).subscribe({});
|
||||
@@ -86,26 +141,67 @@ export default function ThirdPartyDetailPage(props: Props) {
|
||||
return () => clearInterval(interval);
|
||||
}, [isVetting, environment]);
|
||||
|
||||
const deleteThirdParty = useDeleteThirdParty(
|
||||
thirdParty,
|
||||
ConnectionHandler.getConnectionID(organizationId, thirdPartyConnectionKey),
|
||||
const [deleteThirdParty] = useMutation<ThirdPartyDetailLayoutDeleteMutation>(
|
||||
deleteThirdPartyMutation,
|
||||
);
|
||||
|
||||
usePageTitle(thirdParty.name ?? __("Third party"));
|
||||
|
||||
const onDelete = () => {
|
||||
if (!thirdParty.name) {
|
||||
return;
|
||||
}
|
||||
const connectionId = ConnectionHandler.getConnectionID(
|
||||
organizationId,
|
||||
ThirdPartiesConnectionKey,
|
||||
{ filter: ThirdPartiesConnectionFilter },
|
||||
);
|
||||
confirm(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
void deleteThirdParty({
|
||||
variables: {
|
||||
input: { thirdPartyId: thirdParty.id },
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted() {
|
||||
void navigate(`/organizations/${organizationId}/third-parties`);
|
||||
resolve();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to delete third party"),
|
||||
error,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
}),
|
||||
{
|
||||
message: sprintf(
|
||||
__(
|
||||
"This will permanently delete the third party \"%s\". This action cannot be undone.",
|
||||
),
|
||||
thirdParty.name,
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const logo = faviconUrl(thirdParty.websiteUrl);
|
||||
const reportsCount = useFragment(
|
||||
complianceReportsFragment,
|
||||
thirdParty as ThirdPartyComplianceTabFragment$key,
|
||||
).complianceReports.edges.length;
|
||||
const measuresCount = thirdParty.measuresInfos?.totalCount ?? 0;
|
||||
const reportsCount = thirdParty.complianceReportsInfo?.edges.length ?? 0;
|
||||
const measuresCount = thirdParty.measuresInfo?.totalCount ?? 0;
|
||||
const isVettingFailed = thirdParty.vettingStatus === "FAILED";
|
||||
const ancestors = thirdParty.ancestors ?? [];
|
||||
|
||||
const thirdPartiesUrl = `/organizations/${organizationId}/third-parties`;
|
||||
|
||||
const baseThirdPartyUrl
|
||||
= `/organizations/${organizationId}/third-parties/${thirdParty.id}`;
|
||||
|
||||
const isVettingFailed = thirdParty.vettingStatus === "FAILED";
|
||||
|
||||
const ancestors = thirdParty.ancestors ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{isVetting && (
|
||||
@@ -181,7 +277,7 @@ export default function ThirdPartyDetailPage(props: Props) {
|
||||
<DropdownItem
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
onClick={deleteThirdParty}
|
||||
onClick={onDelete}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
@@ -211,7 +307,7 @@ export default function ThirdPartyDetailPage(props: Props) {
|
||||
</TabLink>
|
||||
</Tabs>
|
||||
|
||||
<Outlet context={{ thirdParty }} />
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { ThirdPartyDetailLayoutQuery } from "#/__generated__/core/ThirdPartyDetailLayoutQuery.graphql";
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
|
||||
import ThirdPartyDetailLayout, { thirdPartyDetailLayoutQuery } from "./ThirdPartyDetailLayout";
|
||||
|
||||
export default function ThirdPartyDetailLayoutLoader() {
|
||||
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<ThirdPartyDetailLayoutQuery>(thirdPartyDetailLayoutQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (thirdPartyId) {
|
||||
loadQuery({ thirdPartyId });
|
||||
}
|
||||
}, [loadQuery, thirdPartyId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <PageSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<ThirdPartyDetailLayout queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -27,9 +27,11 @@ import { useMutation, useQueryLoader } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { useDebounceCallback } from "usehooks-ts";
|
||||
|
||||
import type { AddChildThirdPartyDialogCreateMutation } from "#/__generated__/core/AddChildThirdPartyDialogCreateMutation.graphql";
|
||||
import type {
|
||||
AddChildThirdPartyDialogCreateMutation,
|
||||
CreateThirdPartyInput,
|
||||
} from "#/__generated__/core/AddChildThirdPartyDialogCreateMutation.graphql";
|
||||
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
|
||||
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
|
||||
|
||||
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
|
||||
|
||||
@@ -22,7 +22,7 @@ import type {
|
||||
CommonThirdPartyCombobox_commonThirdParty$key,
|
||||
} from "#/__generated__/core/CommonThirdPartyCombobox_commonThirdParty.graphql";
|
||||
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
|
||||
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
|
||||
import type { CreateThirdPartyInput } from "#/__generated__/core/CreateThirdPartyDialogCreateMutation.graphql";
|
||||
|
||||
export type CommonThirdPartyRef
|
||||
= CommonThirdPartyCombobox_commonThirdParty$data;
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { cleanFormData } from "@probo/helpers";
|
||||
import { cleanFormData, formatError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -22,13 +22,15 @@ import {
|
||||
DialogFooter,
|
||||
Field,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode } from "react";
|
||||
import { useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CreateContactDialogMutation } from "#/__generated__/core/CreateContactDialogMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
@@ -46,7 +48,7 @@ const createContactMutation = graphql`
|
||||
node {
|
||||
canUpdate: permission(action: "core:thirdParty-contact:update")
|
||||
canDelete: permission(action: "core:thirdParty-contact:delete")
|
||||
...ThirdPartyContactsTabFragment_contact
|
||||
...ThirdPartyContactRow_contact
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,18 +95,15 @@ export function CreateContactDialog({
|
||||
},
|
||||
},
|
||||
);
|
||||
const [createContact, isLoading] = useMutationWithToasts(
|
||||
const { toast } = useToast();
|
||||
const [createContact, isCreating] = useMutation<CreateContactDialogMutation>(
|
||||
createContactMutation,
|
||||
{
|
||||
successMessage: __("Contact created successfully."),
|
||||
errorMessage: __("Failed to create contact"),
|
||||
},
|
||||
);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
const cleanData = cleanFormData(data);
|
||||
|
||||
await createContact({
|
||||
createContact({
|
||||
variables: {
|
||||
input: {
|
||||
thirdPartyId,
|
||||
@@ -112,10 +111,30 @@ export function CreateContactDialog({
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onSuccess: () => {
|
||||
onCompleted(_response, errors) {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to create contact"), errors),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Contact created successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
reset();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to create contact"), error),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -161,7 +180,7 @@ export function CreateContactDialog({
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
<Button type="submit" disabled={isCreating}>
|
||||
{__("Create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -12,6 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -23,14 +24,16 @@ import {
|
||||
ImpactOptions,
|
||||
SentitivityOptions,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode } from "react";
|
||||
import { useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CreateRiskAssessmentDialogMutation } from "#/__generated__/core/CreateRiskAssessmentDialogMutation.graphql";
|
||||
import { ControlledField } from "#/components/form/ControlledField";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
@@ -46,7 +49,7 @@ const createRiskAssessmentMutation = graphql`
|
||||
createThirdPartyRiskAssessment(input: $input) {
|
||||
thirdPartyRiskAssessmentEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
...ThirdPartyRiskAssessmentTabFragment_assessment
|
||||
...ThirdPartyRiskAssessmentRow_assessment
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,18 +79,16 @@ export function CreateRiskAssessmentDialog({
|
||||
businessImpact: "LOW",
|
||||
},
|
||||
});
|
||||
const [createRiskAssessment, isLoading] = useMutationWithToasts(
|
||||
createRiskAssessmentMutation,
|
||||
{
|
||||
successMessage: __("Risk Assessment created successfully."),
|
||||
errorMessage: __("Failed to create Risk Assessment"),
|
||||
},
|
||||
);
|
||||
const { toast } = useToast();
|
||||
const [createRiskAssessment, isCreating]
|
||||
= useMutation<CreateRiskAssessmentDialogMutation>(
|
||||
createRiskAssessmentMutation,
|
||||
);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
const nextYear = new Date();
|
||||
nextYear.setFullYear(nextYear.getFullYear() + 1);
|
||||
await createRiskAssessment({
|
||||
createRiskAssessment({
|
||||
variables: {
|
||||
input: {
|
||||
...data,
|
||||
@@ -97,10 +98,36 @@ export function CreateRiskAssessmentDialog({
|
||||
},
|
||||
connections: [connection],
|
||||
},
|
||||
onSuccess: () => {
|
||||
onCompleted(_response, errors) {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create Risk Assessment"),
|
||||
errors,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Risk Assessment created successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
reset();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to create Risk Assessment"),
|
||||
error,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -146,7 +173,7 @@ export function CreateRiskAssessmentDialog({
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
<Button type="submit" disabled={isCreating}>
|
||||
{__("Create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { cleanFormData } from "@probo/helpers";
|
||||
import { cleanFormData, formatError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -22,13 +22,15 @@ import {
|
||||
DialogFooter,
|
||||
Field,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode } from "react";
|
||||
import { useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { CreateServiceDialogMutation } from "#/__generated__/core/CreateServiceDialogMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
@@ -44,7 +46,7 @@ const createServiceMutation = graphql`
|
||||
createThirdPartyService(input: $input) {
|
||||
thirdPartyServiceEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
...ThirdPartyServicesTabFragment_service
|
||||
...ThirdPartyServiceRow_service
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,18 +74,15 @@ export function CreateServiceDialog({
|
||||
},
|
||||
},
|
||||
);
|
||||
const [createService, isLoading] = useMutationWithToasts(
|
||||
const { toast } = useToast();
|
||||
const [createService, isCreating] = useMutation<CreateServiceDialogMutation>(
|
||||
createServiceMutation,
|
||||
{
|
||||
successMessage: __("Service created successfully."),
|
||||
errorMessage: __("Failed to create service"),
|
||||
},
|
||||
);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
const cleanData = cleanFormData(data);
|
||||
|
||||
await createService({
|
||||
createService({
|
||||
variables: {
|
||||
input: {
|
||||
thirdPartyId,
|
||||
@@ -91,10 +90,30 @@ export function CreateServiceDialog({
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onSuccess: () => {
|
||||
onCompleted(_response, errors) {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to create service"), errors),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Service created successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
reset();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to create service"), error),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -128,7 +147,7 @@ export function CreateServiceDialog({
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
<Button type="submit" disabled={isCreating}>
|
||||
{__("Create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -12,6 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Combobox,
|
||||
@@ -21,17 +22,38 @@ import {
|
||||
DialogFooter,
|
||||
IconPlusLarge,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode, Suspense, useCallback, useState } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useMutation, useQueryLoader } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { useDebounceCallback } from "usehooks-ts";
|
||||
|
||||
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
|
||||
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
|
||||
import { useCreateThirdPartyMutation } from "#/hooks/graph/ThirdPartyGraph";
|
||||
import type {
|
||||
CreateThirdPartyDialogCreateMutation,
|
||||
CreateThirdPartyInput,
|
||||
} from "#/__generated__/core/CreateThirdPartyDialogCreateMutation.graphql";
|
||||
|
||||
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
|
||||
|
||||
const createThirdPartyMutation = graphql`
|
||||
mutation CreateThirdPartyDialogCreateMutation(
|
||||
$input: CreateThirdPartyInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
createThirdParty(input: $input) {
|
||||
thirdPartyEdge @prependEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
canDelete: permission(action: "core:thirdParty:delete")
|
||||
...ThirdPartyRow_thirdParty
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
organizationId: string;
|
||||
@@ -44,7 +66,8 @@ export function CreateThirdPartyDialog({
|
||||
connection,
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const [createThirdParty] = useCreateThirdPartyMutation();
|
||||
const { toast } = useToast();
|
||||
const [createThirdParty] = useMutation<CreateThirdPartyDialogCreateMutation>(createThirdPartyMutation);
|
||||
const dialogRef = useDialogRef();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [queryRef, loadQuery]
|
||||
@@ -62,14 +85,34 @@ export function CreateThirdPartyDialog({
|
||||
...thirdParty,
|
||||
organizationId,
|
||||
};
|
||||
void createThirdParty({
|
||||
createThirdParty({
|
||||
variables: {
|
||||
input,
|
||||
connections: [connection],
|
||||
},
|
||||
onSuccess: () => {
|
||||
onCompleted(_response, errors) {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to create third party"), errors),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Third party created successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to create third party"), error),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -100,12 +143,12 @@ export function CreateThirdPartyDialog({
|
||||
<CommonThirdPartyCombobox
|
||||
queryRef={queryRef}
|
||||
excludeNames={new Set()}
|
||||
onSelect={thirdPartyRef => void onSelect(thirdPartyRef)}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{searchQuery.trim().length >= 2 && (
|
||||
<ComboboxItem onClick={() => void onSelect(searchQuery.trim())}>
|
||||
<ComboboxItem onClick={() => onSelect(searchQuery.trim())}>
|
||||
<IconPlusLarge size={20} />
|
||||
{__("Create a new third party")}
|
||||
{" "}
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { formatError, sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
@@ -21,10 +21,11 @@ import {
|
||||
DialogFooter,
|
||||
Spinner,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { graphql } from "react-relay";
|
||||
import { graphql, useMutation } from "react-relay";
|
||||
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import type { DeleteBusinessAssociateAgreementDialogMutation } from "#/__generated__/core/DeleteBusinessAssociateAgreementDialogMutation.graphql";
|
||||
|
||||
const deleteBusinessAssociateAgreementMutation = graphql`
|
||||
mutation DeleteBusinessAssociateAgreementDialogMutation(
|
||||
@@ -52,22 +53,50 @@ export function DeleteBusinessAssociateAgreementDialog({
|
||||
const { __ } = useTranslate();
|
||||
const ref = useDialogRef();
|
||||
|
||||
const [mutate, isDeleting] = useMutationWithToasts(deleteBusinessAssociateAgreementMutation, {
|
||||
successMessage: __("Business Associate Agreement deleted successfully"),
|
||||
errorMessage: __("Failed to delete Business Associate Agreement"),
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const [deleteAgreement, isDeleting]
|
||||
= useMutation<DeleteBusinessAssociateAgreementDialogMutation>(
|
||||
deleteBusinessAssociateAgreementMutation,
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
await mutate({
|
||||
const handleDelete = () => {
|
||||
deleteAgreement({
|
||||
variables: {
|
||||
input: {
|
||||
thirdPartyId,
|
||||
},
|
||||
},
|
||||
onCompleted(_response, errors) {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to delete Business Associate Agreement"),
|
||||
errors,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Business Associate Agreement deleted successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
onSuccess?.();
|
||||
ref.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to delete Business Associate Agreement"),
|
||||
error,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
onSuccess?.();
|
||||
ref.current?.close();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -92,7 +121,7 @@ export function DeleteBusinessAssociateAgreementDialog({
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => void handleDelete()}
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
icon={isDeleting ? Spinner : undefined}
|
||||
>
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { formatError, sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
@@ -21,10 +21,11 @@ import {
|
||||
DialogFooter,
|
||||
Spinner,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { graphql } from "react-relay";
|
||||
import { graphql, useMutation } from "react-relay";
|
||||
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import type { DeleteDataPrivacyAgreementDialogMutation } from "#/__generated__/core/DeleteDataPrivacyAgreementDialogMutation.graphql";
|
||||
|
||||
const deleteDataPrivacyAgreementMutation = graphql`
|
||||
mutation DeleteDataPrivacyAgreementDialogMutation(
|
||||
@@ -52,22 +53,50 @@ export function DeleteDataPrivacyAgreementDialog({
|
||||
const { __ } = useTranslate();
|
||||
const ref = useDialogRef();
|
||||
|
||||
const [mutate, isDeleting] = useMutationWithToasts(deleteDataPrivacyAgreementMutation, {
|
||||
successMessage: __("Data Privacy Agreement deleted successfully"),
|
||||
errorMessage: __("Failed to delete Data Privacy Agreement"),
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const [deleteAgreement, isDeleting]
|
||||
= useMutation<DeleteDataPrivacyAgreementDialogMutation>(
|
||||
deleteDataPrivacyAgreementMutation,
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
await mutate({
|
||||
const handleDelete = () => {
|
||||
deleteAgreement({
|
||||
variables: {
|
||||
input: {
|
||||
thirdPartyId,
|
||||
},
|
||||
},
|
||||
onCompleted(_response, errors) {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to delete Data Privacy Agreement"),
|
||||
errors,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Data Privacy Agreement deleted successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
onSuccess?.();
|
||||
ref.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to delete Data Privacy Agreement"),
|
||||
error,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
onSuccess?.();
|
||||
ref.current?.close();
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -92,7 +121,7 @@ export function DeleteDataPrivacyAgreementDialog({
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => void handleDelete()}
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
icon={isDeleting ? Spinner : undefined}
|
||||
>
|
||||
@@ -12,6 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
@@ -22,12 +23,13 @@ import {
|
||||
Input,
|
||||
Spinner,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { graphql } from "react-relay";
|
||||
import { graphql, useMutation } from "react-relay";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { EditBusinessAssociateAgreementDialogMutation } from "#/__generated__/core/EditBusinessAssociateAgreementDialogMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
const updateBusinessAssociateAgreementMutation = graphql`
|
||||
mutation EditBusinessAssociateAgreementDialogMutation(
|
||||
@@ -79,7 +81,6 @@ export function EditBusinessAssociateAgreementDialog({
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
reset,
|
||||
} = useFormWithSchema(schema, {
|
||||
defaultValues: {
|
||||
@@ -88,18 +89,19 @@ export function EditBusinessAssociateAgreementDialog({
|
||||
},
|
||||
});
|
||||
|
||||
const [mutate] = useMutationWithToasts(updateBusinessAssociateAgreementMutation, {
|
||||
successMessage: __("Business Associate Agreement updated successfully"),
|
||||
errorMessage: __("Failed to update Business Associate Agreement"),
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const [updateAgreement, isUpdating]
|
||||
= useMutation<EditBusinessAssociateAgreementDialogMutation>(
|
||||
updateBusinessAssociateAgreementMutation,
|
||||
);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
const formatDatetime = (dateString?: string) => {
|
||||
if (!dateString) return null;
|
||||
return `${dateString}T00:00:00Z`;
|
||||
};
|
||||
|
||||
await mutate({
|
||||
updateAgreement({
|
||||
variables: {
|
||||
input: {
|
||||
thirdPartyId,
|
||||
@@ -107,10 +109,37 @@ export function EditBusinessAssociateAgreementDialog({
|
||||
validUntil: formatDatetime(data.validUntil),
|
||||
},
|
||||
},
|
||||
onCompleted(_response, errors) {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to update Business Associate Agreement"),
|
||||
errors,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Business Associate Agreement updated successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
onSuccess?.();
|
||||
ref.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to update Business Associate Agreement"),
|
||||
error,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
onSuccess?.();
|
||||
ref.current?.close();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -140,8 +169,8 @@ export function EditBusinessAssociateAgreementDialog({
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
icon={isSubmitting ? Spinner : undefined}
|
||||
disabled={isUpdating}
|
||||
icon={isUpdating ? Spinner : undefined}
|
||||
>
|
||||
{__("Update")}
|
||||
</Button>
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { cleanFormData } from "@probo/helpers";
|
||||
import { cleanFormData, formatError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -22,26 +22,37 @@ import {
|
||||
DialogFooter,
|
||||
Field,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { useEffect } from "react";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { graphql, useFragment, useMutation } from "react-relay";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ThirdPartyContactsTabFragment_contact$data } from "#/__generated__/core/ThirdPartyContactsTabFragment_contact.graphql";
|
||||
import type { EditContactDialog_contact$key } from "#/__generated__/core/EditContactDialog_contact.graphql";
|
||||
import type { EditContactDialogUpdateMutation } from "#/__generated__/core/EditContactDialogUpdateMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
type Props = {
|
||||
contactId: string;
|
||||
contact: ThirdPartyContactsTabFragment_contact$data;
|
||||
contactKey: EditContactDialog_contact$key;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const editContactDialogFragment = graphql`
|
||||
fragment EditContactDialog_contact on ThirdPartyContact {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
phone
|
||||
role
|
||||
}
|
||||
`;
|
||||
|
||||
const updateContactMutation = graphql`
|
||||
mutation EditContactDialogUpdateMutation($input: UpdateThirdPartyContactInput!) {
|
||||
updateThirdPartyContact(input: $input) {
|
||||
thirdPartyContact {
|
||||
...ThirdPartyContactsTabFragment_contact
|
||||
...ThirdPartyContactRow_contact
|
||||
...EditContactDialog_contact
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,8 +60,10 @@ const updateContactMutation = graphql`
|
||||
|
||||
const phoneRegex = /^\+[0-9]{8,15}$/;
|
||||
|
||||
export function EditContactDialog({ contactId, contact, onClose }: Props) {
|
||||
export function EditContactDialog({ contactKey, onClose }: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const contact = useFragment(editContactDialogFragment, contactKey);
|
||||
|
||||
const schema = z.object({
|
||||
fullName: z.string().optional(),
|
||||
@@ -81,27 +94,42 @@ export function EditContactDialog({ contactId, contact, onClose }: Props) {
|
||||
},
|
||||
});
|
||||
|
||||
const [updateContact, isLoading] = useMutationWithToasts(
|
||||
updateContactMutation,
|
||||
{
|
||||
successMessage: __("Contact updated successfully."),
|
||||
errorMessage: __("Failed to update contact"),
|
||||
},
|
||||
);
|
||||
const [updateContact, isUpdating]
|
||||
= useMutation<EditContactDialogUpdateMutation>(updateContactMutation);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
const cleanData = cleanFormData(data);
|
||||
|
||||
await updateContact({
|
||||
updateContact({
|
||||
variables: {
|
||||
input: {
|
||||
id: contactId,
|
||||
id: contact.id,
|
||||
...cleanData,
|
||||
},
|
||||
},
|
||||
onSuccess: () => {
|
||||
onCompleted(_response, errors) {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to update contact"), errors),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Contact updated successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to update contact"), error),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -151,7 +179,7 @@ export function EditContactDialog({ contactId, contact, onClose }: Props) {
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
<Button type="submit" disabled={isUpdating}>
|
||||
{__("Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -12,6 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
@@ -22,12 +23,13 @@ import {
|
||||
Input,
|
||||
Spinner,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { graphql } from "react-relay";
|
||||
import { graphql, useMutation } from "react-relay";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { EditDataPrivacyAgreementDialogMutation } from "#/__generated__/core/EditDataPrivacyAgreementDialogMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
const updateDataPrivacyAgreementMutation = graphql`
|
||||
mutation EditDataPrivacyAgreementDialogMutation(
|
||||
@@ -79,7 +81,6 @@ export function EditDataPrivacyAgreementDialog({
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
reset,
|
||||
} = useFormWithSchema(schema, {
|
||||
defaultValues: {
|
||||
@@ -88,18 +89,19 @@ export function EditDataPrivacyAgreementDialog({
|
||||
},
|
||||
});
|
||||
|
||||
const [mutate] = useMutationWithToasts(updateDataPrivacyAgreementMutation, {
|
||||
successMessage: __("Data Privacy Agreement updated successfully"),
|
||||
errorMessage: __("Failed to update Data Privacy Agreement"),
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const [updateAgreement, isUpdating]
|
||||
= useMutation<EditDataPrivacyAgreementDialogMutation>(
|
||||
updateDataPrivacyAgreementMutation,
|
||||
);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
const formatDatetime = (dateString?: string) => {
|
||||
if (!dateString) return null;
|
||||
return `${dateString}T00:00:00Z`;
|
||||
};
|
||||
|
||||
await mutate({
|
||||
updateAgreement({
|
||||
variables: {
|
||||
input: {
|
||||
thirdPartyId,
|
||||
@@ -107,10 +109,37 @@ export function EditDataPrivacyAgreementDialog({
|
||||
validUntil: formatDatetime(data.validUntil),
|
||||
},
|
||||
},
|
||||
onCompleted(_response, errors) {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to update Data Privacy Agreement"),
|
||||
errors,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Data Privacy Agreement updated successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
onSuccess?.();
|
||||
ref.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to update Data Privacy Agreement"),
|
||||
error,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
onSuccess?.();
|
||||
ref.current?.close();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -140,8 +169,8 @@ export function EditDataPrivacyAgreementDialog({
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
icon={isSubmitting ? Spinner : undefined}
|
||||
disabled={isUpdating}
|
||||
icon={isUpdating ? Spinner : undefined}
|
||||
>
|
||||
{__("Update")}
|
||||
</Button>
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { cleanFormData } from "@probo/helpers";
|
||||
import { cleanFormData, formatError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -22,35 +22,44 @@ import {
|
||||
DialogFooter,
|
||||
Field,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { useEffect } from "react";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { graphql, useFragment, useMutation } from "react-relay";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { EditServiceDialog_service$key } from "#/__generated__/core/EditServiceDialog_service.graphql";
|
||||
import type { EditServiceDialogUpdateMutation } from "#/__generated__/core/EditServiceDialogUpdateMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
type Props = {
|
||||
serviceId: string;
|
||||
service: {
|
||||
name: string;
|
||||
description?: string | null;
|
||||
};
|
||||
serviceKey: EditServiceDialog_service$key;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const editServiceDialogFragment = graphql`
|
||||
fragment EditServiceDialog_service on ThirdPartyService {
|
||||
id
|
||||
name
|
||||
description
|
||||
}
|
||||
`;
|
||||
|
||||
const updateServiceMutation = graphql`
|
||||
mutation EditServiceDialogUpdateMutation($input: UpdateThirdPartyServiceInput!) {
|
||||
updateThirdPartyService(input: $input) {
|
||||
thirdPartyService {
|
||||
...ThirdPartyServicesTabFragment_service
|
||||
...ThirdPartyServiceRow_service
|
||||
...EditServiceDialog_service
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function EditServiceDialog({ serviceId, service, onClose }: Props) {
|
||||
export function EditServiceDialog({ serviceKey, onClose }: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const service = useFragment(editServiceDialogFragment, serviceKey);
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, __("Service name is required")),
|
||||
@@ -67,27 +76,42 @@ export function EditServiceDialog({ serviceId, service, onClose }: Props) {
|
||||
},
|
||||
);
|
||||
|
||||
const [updateService, isLoading] = useMutationWithToasts(
|
||||
updateServiceMutation,
|
||||
{
|
||||
successMessage: __("Service updated successfully."),
|
||||
errorMessage: __("Failed to update service"),
|
||||
},
|
||||
);
|
||||
const [updateService, isUpdating]
|
||||
= useMutation<EditServiceDialogUpdateMutation>(updateServiceMutation);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
const cleanData = cleanFormData(data);
|
||||
|
||||
await updateService({
|
||||
updateService({
|
||||
variables: {
|
||||
input: {
|
||||
id: serviceId,
|
||||
id: service.id,
|
||||
...cleanData,
|
||||
},
|
||||
},
|
||||
onSuccess: () => {
|
||||
onCompleted(_response, errors) {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to update service"), errors),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Service updated successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to update service"), error),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -125,7 +149,7 @@ export function EditServiceDialog({ serviceId, service, onClose }: Props) {
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
<Button type="submit" disabled={isUpdating}>
|
||||
{__("Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { faviconUrl, formatDate, formatError, sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
Avatar,
|
||||
DropdownItem,
|
||||
IconTrashCan,
|
||||
RiskBadge,
|
||||
Td,
|
||||
Tr,
|
||||
useConfirm,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { graphql, useFragment, useMutation } from "react-relay";
|
||||
|
||||
import type { ThirdPartyRow_thirdParty$key } from "#/__generated__/core/ThirdPartyRow_thirdParty.graphql";
|
||||
import type { ThirdPartyRowDeleteMutation } from "#/__generated__/core/ThirdPartyRowDeleteMutation.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
const thirdPartyRowFragment = graphql`
|
||||
fragment ThirdPartyRow_thirdParty on ThirdParty {
|
||||
id
|
||||
name
|
||||
websiteUrl
|
||||
riskAssessments(
|
||||
first: 1
|
||||
orderBy: { direction: DESC, field: CREATED_AT }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
createdAt
|
||||
dataSensitivity
|
||||
businessImpact
|
||||
}
|
||||
}
|
||||
}
|
||||
canDelete: permission(action: "core:thirdParty:delete")
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteThirdPartyMutation = graphql`
|
||||
mutation ThirdPartyRowDeleteMutation(
|
||||
$input: DeleteThirdPartyInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteThirdParty(input: $input) {
|
||||
deletedThirdPartyId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface ThirdPartyRowProps {
|
||||
thirdPartyKey: ThirdPartyRow_thirdParty$key;
|
||||
connectionId: string;
|
||||
hasAnyAction: boolean;
|
||||
}
|
||||
|
||||
export function ThirdPartyRow(props: ThirdPartyRowProps) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const thirdParty = useFragment(thirdPartyRowFragment, props.thirdPartyKey);
|
||||
const [deleteThirdParty] = useMutation<ThirdPartyRowDeleteMutation>(
|
||||
deleteThirdPartyMutation,
|
||||
);
|
||||
const confirm = useConfirm();
|
||||
const { toast } = useToast();
|
||||
|
||||
const latestAssessment = thirdParty.riskAssessments?.edges[0]?.node;
|
||||
const thirdPartyUrl = `/organizations/${organizationId}/third-parties/${thirdParty.id}/overview`;
|
||||
|
||||
const onDelete = () => {
|
||||
confirm(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
void deleteThirdParty({
|
||||
variables: {
|
||||
input: { thirdPartyId: thirdParty.id },
|
||||
connections: [props.connectionId],
|
||||
},
|
||||
onCompleted() {
|
||||
resolve();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to delete third party"),
|
||||
error,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
}),
|
||||
{
|
||||
message: sprintf(
|
||||
__(
|
||||
"This will permanently delete the third party \"%s\". This action cannot be undone.",
|
||||
),
|
||||
thirdParty.name || __("Unnamed third party"),
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr to={thirdPartyUrl}>
|
||||
<Td>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Avatar name={thirdParty.name} src={faviconUrl(thirdParty.websiteUrl)} />
|
||||
<div>{thirdParty.name}</div>
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
{latestAssessment?.createdAt
|
||||
? formatDate(latestAssessment.createdAt)
|
||||
: __("Not assessed")}
|
||||
</Td>
|
||||
<Td>
|
||||
<RiskBadge level={latestAssessment?.dataSensitivity ?? "NONE"} />
|
||||
</Td>
|
||||
<Td>
|
||||
<RiskBadge level={latestAssessment?.businessImpact ?? "NONE"} />
|
||||
</Td>
|
||||
{props.hasAnyAction && (
|
||||
<Td noLink width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
{thirdParty.canDelete && (
|
||||
<DropdownItem
|
||||
onClick={onDelete}
|
||||
variant="danger"
|
||||
icon={IconTrashCan}
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
@@ -23,13 +24,14 @@ import {
|
||||
Input,
|
||||
Spinner,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { useState } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
import { graphql, useMutation } from "react-relay";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { UploadBusinessAssociateAgreementDialogMutation } from "#/__generated__/core/UploadBusinessAssociateAgreementDialogMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
const uploadBusinessAssociateAgreementMutation = graphql`
|
||||
mutation UploadBusinessAssociateAgreementDialogMutation(
|
||||
@@ -74,7 +76,7 @@ export function UploadBusinessAssociateAgreementDialog({
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors },
|
||||
reset,
|
||||
setValue,
|
||||
} = useFormWithSchema(schema, {
|
||||
@@ -85,10 +87,11 @@ export function UploadBusinessAssociateAgreementDialog({
|
||||
},
|
||||
});
|
||||
|
||||
const [mutate] = useMutationWithToasts(uploadBusinessAssociateAgreementMutation, {
|
||||
successMessage: __("Business Associate Agreement uploaded successfully"),
|
||||
errorMessage: __("Failed to upload Business Associate Agreement"),
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const [uploadAgreement, isUploading]
|
||||
= useMutation<UploadBusinessAssociateAgreementDialogMutation>(
|
||||
uploadBusinessAssociateAgreementMutation,
|
||||
);
|
||||
|
||||
const handleDrop = (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
@@ -98,7 +101,7 @@ export function UploadBusinessAssociateAgreementDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
if (!uploadedFile) {
|
||||
return;
|
||||
}
|
||||
@@ -108,7 +111,7 @@ export function UploadBusinessAssociateAgreementDialog({
|
||||
return `${dateString}T00:00:00Z`;
|
||||
};
|
||||
|
||||
await mutate({
|
||||
uploadAgreement({
|
||||
variables: {
|
||||
input: {
|
||||
thirdPartyId,
|
||||
@@ -121,12 +124,39 @@ export function UploadBusinessAssociateAgreementDialog({
|
||||
uploadables: {
|
||||
"input.file": uploadedFile,
|
||||
},
|
||||
onCompleted(_response, errors) {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to upload Business Associate Agreement"),
|
||||
errors,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Business Associate Agreement uploaded successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
reset();
|
||||
setUploadedFile(null);
|
||||
onSuccess?.();
|
||||
ref.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to upload Business Associate Agreement"),
|
||||
error,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
reset();
|
||||
setUploadedFile(null);
|
||||
onSuccess?.();
|
||||
ref.current?.close();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -146,7 +176,7 @@ export function UploadBusinessAssociateAgreementDialog({
|
||||
<DialogContent padded className="space-y-4">
|
||||
<Dropzone
|
||||
description={__("Only PDF files up to 10MB are allowed")}
|
||||
isUploading={isSubmitting}
|
||||
isUploading={isUploading}
|
||||
onDrop={handleDrop}
|
||||
accept={{
|
||||
"application/pdf": [".pdf"],
|
||||
@@ -186,8 +216,8 @@ export function UploadBusinessAssociateAgreementDialog({
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !uploadedFile}
|
||||
icon={isSubmitting ? Spinner : undefined}
|
||||
disabled={isUploading || !uploadedFile}
|
||||
icon={isUploading ? Spinner : undefined}
|
||||
>
|
||||
{__("Upload")}
|
||||
</Button>
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatDatetime, todayAsDateInput } from "@probo/helpers";
|
||||
import { formatDatetime, formatError, todayAsDateInput } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
@@ -24,13 +24,14 @@ import {
|
||||
Input,
|
||||
Spinner,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { useState } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
import { graphql, useMutation } from "react-relay";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { UploadComplianceReportDialogMutation } from "#/__generated__/core/UploadComplianceReportDialogMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
const uploadComplianceReportMutation = graphql`
|
||||
mutation UploadComplianceReportDialogMutation(
|
||||
@@ -82,7 +83,6 @@ export function UploadComplianceReportDialog({
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
reset,
|
||||
} = useFormWithSchema(schema, {
|
||||
defaultValues: {
|
||||
@@ -91,10 +91,11 @@ export function UploadComplianceReportDialog({
|
||||
},
|
||||
});
|
||||
|
||||
const [mutate] = useMutationWithToasts(uploadComplianceReportMutation, {
|
||||
successMessage: __("Compliance report uploaded successfully"),
|
||||
errorMessage: __("Failed to upload compliance report"),
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const [uploadComplianceReport, isUploading]
|
||||
= useMutation<UploadComplianceReportDialogMutation>(
|
||||
uploadComplianceReportMutation,
|
||||
);
|
||||
|
||||
const handleDrop = (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
@@ -102,12 +103,12 @@ export function UploadComplianceReportDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
if (!uploadedFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mutate({
|
||||
uploadComplianceReport({
|
||||
variables: {
|
||||
connections: [connectionId],
|
||||
input: {
|
||||
@@ -121,12 +122,39 @@ export function UploadComplianceReportDialog({
|
||||
uploadables: {
|
||||
"input.file": uploadedFile,
|
||||
},
|
||||
onCompleted(_response, errors) {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to upload compliance report"),
|
||||
errors,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Compliance report uploaded successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
reset();
|
||||
setUploadedFile(null);
|
||||
onSuccess?.();
|
||||
ref.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to upload compliance report"),
|
||||
error,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
reset();
|
||||
setUploadedFile(null);
|
||||
onSuccess?.();
|
||||
ref.current?.close();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -146,7 +174,7 @@ export function UploadComplianceReportDialog({
|
||||
<DialogContent padded className="space-y-4">
|
||||
<Dropzone
|
||||
description={__("Only PDF files up to 10MB are allowed")}
|
||||
isUploading={isSubmitting}
|
||||
isUploading={isUploading}
|
||||
onDrop={handleDrop}
|
||||
accept={{
|
||||
"application/pdf": [".pdf"],
|
||||
@@ -177,8 +205,8 @@ export function UploadComplianceReportDialog({
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !uploadedFile}
|
||||
icon={isSubmitting ? Spinner : undefined}
|
||||
disabled={isUploading || !uploadedFile}
|
||||
icon={isUploading ? Spinner : undefined}
|
||||
>
|
||||
{__("Upload")}
|
||||
</Button>
|
||||
@@ -12,6 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
@@ -23,13 +24,14 @@ import {
|
||||
Input,
|
||||
Spinner,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { useState } from "react";
|
||||
import { graphql } from "react-relay";
|
||||
import { graphql, useMutation } from "react-relay";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { UploadDataPrivacyAgreementDialogMutation } from "#/__generated__/core/UploadDataPrivacyAgreementDialogMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
const uploadDataPrivacyAgreementMutation = graphql`
|
||||
mutation UploadDataPrivacyAgreementDialogMutation(
|
||||
@@ -74,7 +76,7 @@ export function UploadDataPrivacyAgreementDialog({
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
formState: { errors },
|
||||
reset,
|
||||
setValue,
|
||||
} = useFormWithSchema(schema, {
|
||||
@@ -85,10 +87,11 @@ export function UploadDataPrivacyAgreementDialog({
|
||||
},
|
||||
});
|
||||
|
||||
const [mutate] = useMutationWithToasts(uploadDataPrivacyAgreementMutation, {
|
||||
successMessage: __("Data Privacy Agreement uploaded successfully"),
|
||||
errorMessage: __("Failed to upload Data Privacy Agreement"),
|
||||
});
|
||||
const { toast } = useToast();
|
||||
const [uploadAgreement, isUploading]
|
||||
= useMutation<UploadDataPrivacyAgreementDialogMutation>(
|
||||
uploadDataPrivacyAgreementMutation,
|
||||
);
|
||||
|
||||
const handleDrop = (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
@@ -98,7 +101,7 @@ export function UploadDataPrivacyAgreementDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
if (!uploadedFile) {
|
||||
return;
|
||||
}
|
||||
@@ -108,7 +111,7 @@ export function UploadDataPrivacyAgreementDialog({
|
||||
return `${dateString}T00:00:00Z`;
|
||||
};
|
||||
|
||||
await mutate({
|
||||
uploadAgreement({
|
||||
variables: {
|
||||
input: {
|
||||
thirdPartyId,
|
||||
@@ -121,12 +124,39 @@ export function UploadDataPrivacyAgreementDialog({
|
||||
uploadables: {
|
||||
"input.file": uploadedFile,
|
||||
},
|
||||
onCompleted(_response, errors) {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to upload Data Privacy Agreement"),
|
||||
errors,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Data Privacy Agreement uploaded successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
reset();
|
||||
setUploadedFile(null);
|
||||
onSuccess?.();
|
||||
ref.current?.close();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to upload Data Privacy Agreement"),
|
||||
error,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
reset();
|
||||
setUploadedFile(null);
|
||||
onSuccess?.();
|
||||
ref.current?.close();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -146,7 +176,7 @@ export function UploadDataPrivacyAgreementDialog({
|
||||
<DialogContent padded className="space-y-4">
|
||||
<Dropzone
|
||||
description={__("Only PDF files up to 10MB are allowed")}
|
||||
isUploading={isSubmitting}
|
||||
isUploading={isUploading}
|
||||
onDrop={handleDrop}
|
||||
accept={{
|
||||
"application/pdf": [".pdf"],
|
||||
@@ -186,8 +216,8 @@ export function UploadDataPrivacyAgreementDialog({
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !uploadedFile}
|
||||
icon={isSubmitting ? Spinner : undefined}
|
||||
disabled={isUploading || !uploadedFile}
|
||||
icon={isUploading ? Spinner : undefined}
|
||||
>
|
||||
{__("Upload")}
|
||||
</Button>
|
||||
@@ -44,8 +44,8 @@ const vetMutation = graphql`
|
||||
websiteUrl
|
||||
vettingStatus
|
||||
...useThirdPartyFormFragment
|
||||
...ThirdPartyComplianceTabFragment
|
||||
...ThirdPartyRiskAssessmentTabFragment
|
||||
...ThirdPartyCompliancePageFragment
|
||||
...ThirdPartyRiskAssessmentPageFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
certifications,
|
||||
objectEntries,
|
||||
} from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Badge,
|
||||
@@ -30,21 +31,42 @@ import {
|
||||
import { clsx } from "clsx";
|
||||
import { useState } from "react";
|
||||
import { Controller } from "react-hook-form";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
|
||||
import type { ThirdPartyCertificationsPageQuery } from "#/__generated__/core/ThirdPartyCertificationsPageQuery.graphql";
|
||||
import { useThirdPartyForm } from "#/hooks/forms/useThirdPartyForm";
|
||||
|
||||
/**
|
||||
* ThirdParty certifications tab
|
||||
*/
|
||||
export default function ThirdPartyCertificationsTab() {
|
||||
const { thirdParty } = useOutletContext<{
|
||||
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
|
||||
}>();
|
||||
export const thirdPartyCertificationsPageQuery = graphql`
|
||||
query ThirdPartyCertificationsPageQuery($thirdPartyId: ID!) {
|
||||
node(id: $thirdPartyId) {
|
||||
__typename
|
||||
... on ThirdParty {
|
||||
name
|
||||
canUpdate: permission(action: "core:thirdParty:update")
|
||||
...useThirdPartyFormFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface ThirdPartyCertificationsPageProps {
|
||||
queryRef: PreloadedQuery<ThirdPartyCertificationsPageQuery>;
|
||||
}
|
||||
|
||||
export default function ThirdPartyCertificationsPage(
|
||||
props: ThirdPartyCertificationsPageProps,
|
||||
) {
|
||||
const data = usePreloadedQuery<ThirdPartyCertificationsPageQuery>(thirdPartyCertificationsPageQuery, props.queryRef);
|
||||
if (data.node?.__typename !== "ThirdParty") {
|
||||
throw new Error("Third party not found");
|
||||
}
|
||||
const thirdParty = data.node;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
const { control, handleSubmit } = useThirdPartyForm(thirdParty);
|
||||
|
||||
usePageTitle(thirdParty.name + " - " + __("Certifications"));
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4"
|
||||
@@ -74,15 +96,12 @@ export default function ThirdPartyCertificationsTab() {
|
||||
);
|
||||
}
|
||||
|
||||
type CertificationsProps = {
|
||||
interface CertificationsProps {
|
||||
value: string[];
|
||||
onValueChange: (value: string[]) => void;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List all certifications badges
|
||||
*/
|
||||
function Certifications(props: CertificationsProps) {
|
||||
const categorizedCertifications = Object.values(certifications).flat();
|
||||
const { __ } = useTranslate();
|
||||
@@ -92,7 +111,7 @@ function Certifications(props: CertificationsProps) {
|
||||
([key, value]) =>
|
||||
[key, value.filter(c => props.value.includes(c))] as const,
|
||||
)
|
||||
.filter(([, certifications]) => certifications.length > 0);
|
||||
.filter(([, certs]) => certs.length > 0);
|
||||
categories.push([
|
||||
"custom",
|
||||
props.value.filter(c => !categorizedCertifications.includes(c)),
|
||||
@@ -110,13 +129,13 @@ function Certifications(props: CertificationsProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{categories.map(([key, certifications]) => (
|
||||
{categories.map(([key, certs]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<div className="text-sm font-medium text-txt-secondary">
|
||||
{certificationCategoryLabel(__, key)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{certifications.map(certification => (
|
||||
{certs.map(certification => (
|
||||
<Badge asChild size="md" key={certification}>
|
||||
{props.readOnly
|
||||
? (
|
||||
@@ -155,9 +174,6 @@ function Certifications(props: CertificationsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Input to add a new certification
|
||||
*/
|
||||
function CertificationInput({
|
||||
certifications,
|
||||
onAdd,
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { ThirdPartyCertificationsPageQuery } from "#/__generated__/core/ThirdPartyCertificationsPageQuery.graphql";
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
|
||||
import ThirdPartyCertificationsPage, { thirdPartyCertificationsPageQuery } from "./ThirdPartyCertificationsPage";
|
||||
|
||||
export default function ThirdPartyCertificationsPageLoader() {
|
||||
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<ThirdPartyCertificationsPageQuery>(thirdPartyCertificationsPageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (thirdPartyId) {
|
||||
loadQuery({ thirdPartyId });
|
||||
}
|
||||
}, [loadQuery, thirdPartyId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <LinkCardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LinkCardSkeleton />}>
|
||||
<ThirdPartyCertificationsPage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
IconPlusLarge,
|
||||
PageHeader,
|
||||
Tbody,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@probo/ui";
|
||||
import type { ComponentProps } from "react";
|
||||
import { graphql, type PreloadedQuery, usePreloadedQuery, useRefetchableFragment } from "react-relay";
|
||||
|
||||
import type { ThirdPartyCompliancePageFragment$key } from "#/__generated__/core/ThirdPartyCompliancePageFragment.graphql";
|
||||
import type { ThirdPartyCompliancePageQuery } from "#/__generated__/core/ThirdPartyCompliancePageQuery.graphql";
|
||||
import type { ThirdPartyCompliancePageRefetchQuery } from "#/__generated__/core/ThirdPartyCompliancePageRefetchQuery.graphql";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
|
||||
import { UploadComplianceReportDialog } from "../_components/UploadComplianceReportDialog";
|
||||
|
||||
import { ThirdPartyComplianceReportRow } from "./_components/ThirdPartyComplianceReportRow";
|
||||
|
||||
const complianceReportsFragment = graphql`
|
||||
fragment ThirdPartyCompliancePageFragment on ThirdParty
|
||||
@refetchable(queryName: "ThirdPartyCompliancePageRefetchQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: { type: "ThirdPartyComplianceReportOrder", defaultValue: null }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
) {
|
||||
id
|
||||
name
|
||||
canUploadComplianceReport: permission(
|
||||
action: "core:thirdParty-compliance-report:upload"
|
||||
)
|
||||
complianceReports(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "ThirdPartyCompliancePage_complianceReports") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...ThirdPartyComplianceReportRow_report
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const thirdPartyCompliancePageQuery = graphql`
|
||||
query ThirdPartyCompliancePageQuery($thirdPartyId: ID!) {
|
||||
node(id: $thirdPartyId) {
|
||||
__typename
|
||||
... on ThirdParty {
|
||||
...ThirdPartyCompliancePageFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface ThirdPartyCompliancePageProps {
|
||||
queryRef: PreloadedQuery<ThirdPartyCompliancePageQuery>;
|
||||
}
|
||||
|
||||
export default function ThirdPartyCompliancePage(props: ThirdPartyCompliancePageProps) {
|
||||
const queryData = usePreloadedQuery<ThirdPartyCompliancePageQuery>(thirdPartyCompliancePageQuery, props.queryRef);
|
||||
if (queryData.node?.__typename !== "ThirdParty") {
|
||||
throw new Error("Third party not found");
|
||||
}
|
||||
|
||||
const [data, refetch] = useRefetchableFragment<
|
||||
ThirdPartyCompliancePageRefetchQuery,
|
||||
ThirdPartyCompliancePageFragment$key
|
||||
>(complianceReportsFragment, queryData.node);
|
||||
|
||||
const connectionId = data.complianceReports.__id;
|
||||
const reports = data.complianceReports.edges.map(edge => edge.node);
|
||||
const { __ } = useTranslate();
|
||||
|
||||
usePageTitle(data.name + " - " + __("Compliance reports"));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={__("Compliance reports")}
|
||||
description={__("Track third party compliance certifications and reports.")}
|
||||
>
|
||||
{data.canUploadComplianceReport && (
|
||||
<UploadComplianceReportDialog
|
||||
thirdPartyId={data.id}
|
||||
connectionId={connectionId}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>{__("Add report")}</Button>
|
||||
</UploadComplianceReportDialog>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<SortableTable
|
||||
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
|
||||
>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{__("Report name")}</Th>
|
||||
<SortableTh field="REPORT_DATE">{__("Report date")}</SortableTh>
|
||||
<Th>{__("Valid until")}</Th>
|
||||
<Th>{__("File size")}</Th>
|
||||
{reports.length > 0 && <Th>{__("Actions")}</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{reports.map(report => (
|
||||
<ThirdPartyComplianceReportRow
|
||||
key={report.id}
|
||||
reportKey={report}
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</SortableTable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { ThirdPartyCompliancePageQuery } from "#/__generated__/core/ThirdPartyCompliancePageQuery.graphql";
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
|
||||
import ThirdPartyCompliancePage, { thirdPartyCompliancePageQuery } from "./ThirdPartyCompliancePage";
|
||||
|
||||
export default function ThirdPartyCompliancePageLoader() {
|
||||
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<ThirdPartyCompliancePageQuery>(thirdPartyCompliancePageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (thirdPartyId) {
|
||||
loadQuery({ thirdPartyId });
|
||||
}
|
||||
}, [loadQuery, thirdPartyId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <LinkCardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LinkCardSkeleton />}>
|
||||
<ThirdPartyCompliancePage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { downloadFile, fileSize, formatDate, formatError, sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
DropdownItem,
|
||||
IconArrowDown,
|
||||
IconTrashCan,
|
||||
Td,
|
||||
Tr,
|
||||
useConfirm,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { graphql, useFragment, useMutation } from "react-relay";
|
||||
|
||||
import type { ThirdPartyComplianceReportRow_report$key } from "#/__generated__/core/ThirdPartyComplianceReportRow_report.graphql";
|
||||
import type { ThirdPartyComplianceReportRowDeleteMutation } from "#/__generated__/core/ThirdPartyComplianceReportRowDeleteMutation.graphql";
|
||||
|
||||
const complianceReportRowFragment = graphql`
|
||||
fragment ThirdPartyComplianceReportRow_report on ThirdPartyComplianceReport {
|
||||
id
|
||||
reportDate
|
||||
validUntil
|
||||
reportName
|
||||
file {
|
||||
fileName
|
||||
size
|
||||
downloadUrl
|
||||
}
|
||||
canDelete: permission(action: "core:thirdParty-compliance-report:delete")
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteReportMutation = graphql`
|
||||
mutation ThirdPartyComplianceReportRowDeleteMutation(
|
||||
$input: DeleteThirdPartyComplianceReportInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteThirdPartyComplianceReport(input: $input) {
|
||||
deletedThirdPartyComplianceReportId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface ThirdPartyComplianceReportRowProps {
|
||||
reportKey: ThirdPartyComplianceReportRow_report$key;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
export function ThirdPartyComplianceReportRow(
|
||||
props: ThirdPartyComplianceReportRowProps,
|
||||
) {
|
||||
const { __ } = useTranslate();
|
||||
const report = useFragment(complianceReportRowFragment, props.reportKey);
|
||||
const confirm = useConfirm();
|
||||
const { toast } = useToast();
|
||||
const [deleteReport] = useMutation<ThirdPartyComplianceReportRowDeleteMutation>(
|
||||
deleteReportMutation,
|
||||
);
|
||||
|
||||
const handleDelete = () => {
|
||||
confirm(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
void deleteReport({
|
||||
variables: {
|
||||
connections: [props.connectionId],
|
||||
input: { reportId: report.id },
|
||||
},
|
||||
onCompleted(_response, errors) {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to delete report"),
|
||||
errors,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to delete report"),
|
||||
error,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
}),
|
||||
{
|
||||
message: sprintf(
|
||||
__(
|
||||
"This will permanently delete the report \"%s\". This action cannot be undone.",
|
||||
),
|
||||
report.reportName,
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td>{report.reportName}</Td>
|
||||
<Td>{formatDate(report.reportDate)}</Td>
|
||||
<Td>{formatDate(report.validUntil)}</Td>
|
||||
<Td>{fileSize(__, report.file?.size ?? 0)}</Td>
|
||||
<Td width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
{report.file?.downloadUrl && (
|
||||
<DropdownItem
|
||||
icon={IconArrowDown}
|
||||
onClick={() =>
|
||||
downloadFile(
|
||||
report.file!.downloadUrl,
|
||||
report.file!.fileName,
|
||||
)}
|
||||
>
|
||||
{__("Download")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{report.canDelete && (
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
variant="danger"
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
IconPlusLarge,
|
||||
PageHeader,
|
||||
Tbody,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@probo/ui";
|
||||
import { useState } from "react";
|
||||
import { graphql, type PreloadedQuery, usePaginationFragment, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { ThirdPartyContactsPageFragment$key } from "#/__generated__/core/ThirdPartyContactsPageFragment.graphql";
|
||||
import type { ThirdPartyContactsPageQuery } from "#/__generated__/core/ThirdPartyContactsPageQuery.graphql";
|
||||
import type { ThirdPartyContactsPageRefetchQuery } from "#/__generated__/core/ThirdPartyContactsPageRefetchQuery.graphql";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
|
||||
import { CreateContactDialog } from "../_components/CreateContactDialog";
|
||||
import { EditContactDialog } from "../_components/EditContactDialog";
|
||||
|
||||
import { ThirdPartyContactRow } from "./_components/ThirdPartyContactRow";
|
||||
|
||||
const thirdPartyContactsFragment = graphql`
|
||||
fragment ThirdPartyContactsPageFragment on ThirdParty
|
||||
@refetchable(queryName: "ThirdPartyContactsPageRefetchQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: { type: "ThirdPartyContactOrder", defaultValue: null }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
) {
|
||||
id
|
||||
name
|
||||
canCreateContact: permission(action: "core:thirdParty-contact:create")
|
||||
contacts(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "ThirdPartyContactsPage_contacts") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
canUpdate: permission(action: "core:thirdParty-contact:update")
|
||||
canDelete: permission(action: "core:thirdParty-contact:delete")
|
||||
...ThirdPartyContactRow_contact
|
||||
...EditContactDialog_contact
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const thirdPartyContactsPageQuery = graphql`
|
||||
query ThirdPartyContactsPageQuery($thirdPartyId: ID!) {
|
||||
node(id: $thirdPartyId) {
|
||||
__typename
|
||||
... on ThirdParty {
|
||||
...ThirdPartyContactsPageFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface ThirdPartyContactsPageProps {
|
||||
queryRef: PreloadedQuery<ThirdPartyContactsPageQuery>;
|
||||
}
|
||||
|
||||
export default function ThirdPartyContactsPage(props: ThirdPartyContactsPageProps) {
|
||||
const { __ } = useTranslate();
|
||||
const queryData = usePreloadedQuery<ThirdPartyContactsPageQuery>(thirdPartyContactsPageQuery, props.queryRef);
|
||||
if (queryData.node?.__typename !== "ThirdParty") {
|
||||
throw new Error("Third party not found");
|
||||
}
|
||||
|
||||
const { data, ...pagination } = usePaginationFragment<
|
||||
ThirdPartyContactsPageRefetchQuery,
|
||||
ThirdPartyContactsPageFragment$key
|
||||
>(thirdPartyContactsFragment, queryData.node);
|
||||
|
||||
const refetch = ({
|
||||
order,
|
||||
}: {
|
||||
order: { direction: string; field: string };
|
||||
}) => {
|
||||
pagination.refetch(
|
||||
{
|
||||
order: {
|
||||
direction: order.direction as "ASC" | "DESC",
|
||||
field: order.field as "FULL_NAME" | "EMAIL" | "CREATED_AT",
|
||||
},
|
||||
},
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
};
|
||||
|
||||
const connectionId = data.contacts.__id;
|
||||
const contacts = data.contacts.edges.map(edge => edge.node);
|
||||
const [editingContact, setEditingContact]
|
||||
= useState<(typeof contacts)[number] | null>(null);
|
||||
const hasAnyAction = contacts.some(
|
||||
contact => contact.canUpdate || contact.canDelete,
|
||||
);
|
||||
|
||||
usePageTitle(sprintf(__("%s - Contacts"), data.name));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={__("Contacts")}
|
||||
description={__("Manage third party contacts and their information.")}
|
||||
>
|
||||
{data.canCreateContact && (
|
||||
<CreateContactDialog thirdPartyId={data.id} connectionId={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add contact")}</Button>
|
||||
</CreateContactDialog>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<SortableTable {...pagination} refetch={refetch}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<SortableTh field="FULL_NAME">{__("Name")}</SortableTh>
|
||||
<SortableTh field="EMAIL">{__("Email")}</SortableTh>
|
||||
<Th>{__("Phone")}</Th>
|
||||
<Th>{__("Role")}</Th>
|
||||
{hasAnyAction && <Th>{__("Actions")}</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{contacts.map(contact => (
|
||||
<ThirdPartyContactRow
|
||||
key={contact.id}
|
||||
contactKey={contact}
|
||||
connectionId={connectionId}
|
||||
onEdit={() => setEditingContact(contact)}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</SortableTable>
|
||||
|
||||
{editingContact && editingContact.canUpdate && (
|
||||
<EditContactDialog
|
||||
contactKey={editingContact}
|
||||
onClose={() => setEditingContact(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { ThirdPartyContactsPageQuery } from "#/__generated__/core/ThirdPartyContactsPageQuery.graphql";
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
|
||||
import ThirdPartyContactsPage, { thirdPartyContactsPageQuery } from "./ThirdPartyContactsPage";
|
||||
|
||||
export default function ThirdPartyContactsPageLoader() {
|
||||
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<ThirdPartyContactsPageQuery>(thirdPartyContactsPageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (thirdPartyId) {
|
||||
loadQuery({ thirdPartyId });
|
||||
}
|
||||
}, [loadQuery, thirdPartyId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <LinkCardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LinkCardSkeleton />}>
|
||||
<ThirdPartyContactsPage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
DropdownItem,
|
||||
IconPencil,
|
||||
IconTrashCan,
|
||||
Td,
|
||||
Tr,
|
||||
useConfirm,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { graphql, useFragment, useMutation } from "react-relay";
|
||||
|
||||
import type { ThirdPartyContactRow_contact$key } from "#/__generated__/core/ThirdPartyContactRow_contact.graphql";
|
||||
import type { ThirdPartyContactRowDeleteMutation } from "#/__generated__/core/ThirdPartyContactRowDeleteMutation.graphql";
|
||||
|
||||
const contactRowFragment = graphql`
|
||||
fragment ThirdPartyContactRow_contact on ThirdPartyContact {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
phone
|
||||
role
|
||||
canUpdate: permission(action: "core:thirdParty-contact:update")
|
||||
canDelete: permission(action: "core:thirdParty-contact:delete")
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteContactMutation = graphql`
|
||||
mutation ThirdPartyContactRowDeleteMutation(
|
||||
$input: DeleteThirdPartyContactInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteThirdPartyContact(input: $input) {
|
||||
deletedThirdPartyContactId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface ThirdPartyContactRowProps {
|
||||
contactKey: ThirdPartyContactRow_contact$key;
|
||||
connectionId: string;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
export function ThirdPartyContactRow(props: ThirdPartyContactRowProps) {
|
||||
const { __ } = useTranslate();
|
||||
const contact = useFragment(contactRowFragment, props.contactKey);
|
||||
const confirm = useConfirm();
|
||||
const { toast } = useToast();
|
||||
const [deleteContact] = useMutation<ThirdPartyContactRowDeleteMutation>(
|
||||
deleteContactMutation,
|
||||
);
|
||||
const hasAnyAction = contact.canUpdate || contact.canDelete;
|
||||
|
||||
const handleDelete = () => {
|
||||
confirm(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
void deleteContact({
|
||||
variables: {
|
||||
connections: [props.connectionId],
|
||||
input: { thirdPartyContactId: contact.id },
|
||||
},
|
||||
onCompleted() {
|
||||
resolve();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to delete contact"),
|
||||
error,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
}),
|
||||
{
|
||||
message: sprintf(
|
||||
__(
|
||||
"This will permanently delete the contact \"%s\". This action cannot be undone.",
|
||||
),
|
||||
contact.fullName || contact.email || __("Unnamed contact"),
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td>{contact.fullName || __("—")}</Td>
|
||||
<Td>
|
||||
{contact.email
|
||||
? (
|
||||
<a
|
||||
href={`mailto:${contact.email}`}
|
||||
className="text-primary-600 hover:text-primary-800"
|
||||
>
|
||||
{contact.email}
|
||||
</a>
|
||||
)
|
||||
: (
|
||||
__("—")
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{contact.phone
|
||||
? (
|
||||
<a
|
||||
href={`tel:${contact.phone}`}
|
||||
className="text-primary-600 hover:text-primary-800"
|
||||
>
|
||||
{contact.phone}
|
||||
</a>
|
||||
)
|
||||
: (
|
||||
__("—")
|
||||
)}
|
||||
</Td>
|
||||
<Td>{contact.role || __("—")}</Td>
|
||||
{hasAnyAction && (
|
||||
<Td width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
{contact.canUpdate && (
|
||||
<DropdownItem
|
||||
icon={IconPencil}
|
||||
onClick={() => props.onEdit()}
|
||||
>
|
||||
{__("Edit")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{contact.canDelete && (
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
variant="danger"
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
@@ -12,28 +12,32 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
import { useOutletContext, useParams } from "react-router";
|
||||
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { ThirdPartyMeasuresPageFragment$key } from "#/__generated__/core/ThirdPartyMeasuresPageFragment.graphql";
|
||||
import type { ThirdPartyMeasuresPageQuery } from "#/__generated__/core/ThirdPartyMeasuresPageQuery.graphql";
|
||||
import { LinkedMeasuresCard } from "#/components/measures/LinkedMeasuresCard";
|
||||
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
|
||||
|
||||
export const measuresFragment = graphql`
|
||||
fragment ThirdPartyMeasuresPageFragment on ThirdParty {
|
||||
id
|
||||
canCreateMeasureThirdPartyMapping: permission(
|
||||
action: "core:measure:create-third-party-mapping"
|
||||
)
|
||||
canDeleteMeasureThirdPartyMapping: permission(
|
||||
action: "core:measure:delete-third-party-mapping"
|
||||
)
|
||||
measures(first: 100) @connection(key: "ThirdPartyMeasuresPage_measures") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...LinkedMeasuresCardFragment
|
||||
export const thirdPartyMeasuresPageQuery = graphql`
|
||||
query ThirdPartyMeasuresPageQuery($thirdPartyId: ID!) {
|
||||
node(id: $thirdPartyId) {
|
||||
__typename
|
||||
... on ThirdParty {
|
||||
id
|
||||
canCreateMeasureThirdPartyMapping: permission(
|
||||
action: "core:measure:create-third-party-mapping"
|
||||
)
|
||||
canDeleteMeasureThirdPartyMapping: permission(
|
||||
action: "core:measure:delete-third-party-mapping"
|
||||
)
|
||||
measures(first: 100) @connection(key: "ThirdPartyMeasuresPage_measures") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...LinkedMeasuresCardFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,24 +71,26 @@ const detachMeasureMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
export default function ThirdPartyMeasuresPage() {
|
||||
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
|
||||
if (!thirdPartyId) {
|
||||
throw new Error("Missing :thirdPartyId param in route");
|
||||
}
|
||||
const { thirdParty } = useOutletContext<{
|
||||
thirdParty: ThirdPartyMeasuresPageFragment$key;
|
||||
}>();
|
||||
const data = useFragment(measuresFragment, thirdParty);
|
||||
const connectionId = data.measures.__id;
|
||||
const measures = data.measures?.edges?.map(edge => edge.node) ?? [];
|
||||
interface ThirdPartyMeasuresPageProps {
|
||||
queryRef: PreloadedQuery<ThirdPartyMeasuresPageQuery>;
|
||||
}
|
||||
|
||||
const canLink = data.canCreateMeasureThirdPartyMapping;
|
||||
const canUnlink = data.canDeleteMeasureThirdPartyMapping;
|
||||
export default function ThirdPartyMeasuresPage(props: ThirdPartyMeasuresPageProps) {
|
||||
const data = usePreloadedQuery<ThirdPartyMeasuresPageQuery>(thirdPartyMeasuresPageQuery, props.queryRef);
|
||||
if (data.node?.__typename !== "ThirdParty") {
|
||||
throw new Error("Third party not found");
|
||||
}
|
||||
const thirdParty = data.node;
|
||||
|
||||
const connectionId = thirdParty.measures.__id;
|
||||
const measures = thirdParty.measures.edges.map(edge => edge.node);
|
||||
|
||||
const canLink = thirdParty.canCreateMeasureThirdPartyMapping;
|
||||
const canUnlink = thirdParty.canDeleteMeasureThirdPartyMapping;
|
||||
const readOnly = !canLink && !canUnlink;
|
||||
|
||||
const incrementOptions = {
|
||||
id: data.id,
|
||||
id: thirdParty.id,
|
||||
node: "measures(first:0)",
|
||||
};
|
||||
const [detachMeasure, isDetaching] = useMutationWithIncrement(
|
||||
@@ -109,7 +115,7 @@ export default function ThirdPartyMeasuresPage() {
|
||||
measures={measures}
|
||||
onAttach={attachMeasure}
|
||||
onDetach={detachMeasure}
|
||||
params={{ thirdPartyId: data.id }}
|
||||
params={{ thirdPartyId: thirdParty.id }}
|
||||
connectionId={connectionId}
|
||||
readOnly={readOnly}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { ThirdPartyMeasuresPageQuery } from "#/__generated__/core/ThirdPartyMeasuresPageQuery.graphql";
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
|
||||
import ThirdPartyMeasuresPage, { thirdPartyMeasuresPageQuery } from "./ThirdPartyMeasuresPage";
|
||||
|
||||
export default function ThirdPartyMeasuresPageLoader() {
|
||||
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<ThirdPartyMeasuresPageQuery>(thirdPartyMeasuresPageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (thirdPartyId) {
|
||||
loadQuery({ thirdPartyId });
|
||||
}
|
||||
}, [loadQuery, thirdPartyId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <LinkCardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LinkCardSkeleton />}>
|
||||
<ThirdPartyMeasuresPage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -26,12 +26,11 @@ import {
|
||||
Option,
|
||||
} from "@probo/ui";
|
||||
import { useMemo } from "react";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { graphql, type PreloadedQuery, useFragment, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
|
||||
import type { ThirdPartyOverviewTabBusinessAssociateAgreementFragment$key } from "#/__generated__/core/ThirdPartyOverviewTabBusinessAssociateAgreementFragment.graphql";
|
||||
import type { ThirdPartyOverviewTabDataPrivacyAgreementFragment$key } from "#/__generated__/core/ThirdPartyOverviewTabDataPrivacyAgreementFragment.graphql";
|
||||
import type { ThirdPartyOverviewPageBusinessAssociateAgreementFragment$key } from "#/__generated__/core/ThirdPartyOverviewPageBusinessAssociateAgreementFragment.graphql";
|
||||
import type { ThirdPartyOverviewPageDataPrivacyAgreementFragment$key } from "#/__generated__/core/ThirdPartyOverviewPageDataPrivacyAgreementFragment.graphql";
|
||||
import type { ThirdPartyOverviewPageQuery } from "#/__generated__/core/ThirdPartyOverviewPageQuery.graphql";
|
||||
import type { ThirdPartyCategory } from "#/__generated__/core/useThirdPartyFormFragment.graphql";
|
||||
import { ControlledField } from "#/components/form/ControlledField";
|
||||
import { CountriesField } from "#/components/form/CountriesField";
|
||||
@@ -39,15 +38,15 @@ import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
||||
import { useThirdPartyForm } from "#/hooks/forms/useThirdPartyForm";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { DeleteBusinessAssociateAgreementDialog } from "../dialogs/DeleteBusinessAssociateAgreementDialog";
|
||||
import { DeleteDataPrivacyAgreementDialog } from "../dialogs/DeleteDataPrivacyAgreementDialog";
|
||||
import { EditBusinessAssociateAgreementDialog } from "../dialogs/EditBusinessAssociateAgreementDialog";
|
||||
import { EditDataPrivacyAgreementDialog } from "../dialogs/EditDataPrivacyAgreementDialog";
|
||||
import { UploadBusinessAssociateAgreementDialog } from "../dialogs/UploadBusinessAssociateAgreementDialog";
|
||||
import { UploadDataPrivacyAgreementDialog } from "../dialogs/UploadDataPrivacyAgreementDialog";
|
||||
import { DeleteBusinessAssociateAgreementDialog } from "../_components/DeleteBusinessAssociateAgreementDialog";
|
||||
import { DeleteDataPrivacyAgreementDialog } from "../_components/DeleteDataPrivacyAgreementDialog";
|
||||
import { EditBusinessAssociateAgreementDialog } from "../_components/EditBusinessAssociateAgreementDialog";
|
||||
import { EditDataPrivacyAgreementDialog } from "../_components/EditDataPrivacyAgreementDialog";
|
||||
import { UploadBusinessAssociateAgreementDialog } from "../_components/UploadBusinessAssociateAgreementDialog";
|
||||
import { UploadDataPrivacyAgreementDialog } from "../_components/UploadDataPrivacyAgreementDialog";
|
||||
|
||||
const thirdPartyBusinessAssociateAgreementFragment = graphql`
|
||||
fragment ThirdPartyOverviewTabBusinessAssociateAgreementFragment on ThirdParty {
|
||||
fragment ThirdPartyOverviewPageBusinessAssociateAgreementFragment on ThirdParty {
|
||||
businessAssociateAgreement {
|
||||
id
|
||||
file {
|
||||
@@ -67,7 +66,7 @@ const thirdPartyBusinessAssociateAgreementFragment = graphql`
|
||||
`;
|
||||
|
||||
const thirdPartyDataPrivacyAgreementFragment = graphql`
|
||||
fragment ThirdPartyOverviewTabDataPrivacyAgreementFragment on ThirdParty {
|
||||
fragment ThirdPartyOverviewPageDataPrivacyAgreementFragment on ThirdParty {
|
||||
dataPrivacyAgreement {
|
||||
id
|
||||
file {
|
||||
@@ -82,10 +81,38 @@ const thirdPartyDataPrivacyAgreementFragment = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
export default function ThirdPartyOverviewTab() {
|
||||
const { thirdParty } = useOutletContext<{
|
||||
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
|
||||
}>();
|
||||
export const thirdPartyOverviewPageQuery = graphql`
|
||||
query ThirdPartyOverviewPageQuery($thirdPartyId: ID!) {
|
||||
node(id: $thirdPartyId) {
|
||||
__typename
|
||||
... on ThirdParty {
|
||||
id
|
||||
name
|
||||
canUpdate: permission(action: "core:thirdParty:update")
|
||||
canUploadBAA: permission(
|
||||
action: "core:thirdParty-business-associate-agreement:upload"
|
||||
)
|
||||
canUploadDPA: permission(
|
||||
action: "core:thirdParty-data-privacy-agreement:upload"
|
||||
)
|
||||
...useThirdPartyFormFragment
|
||||
...ThirdPartyOverviewPageBusinessAssociateAgreementFragment
|
||||
...ThirdPartyOverviewPageDataPrivacyAgreementFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface ThirdPartyOverviewPageProps {
|
||||
queryRef: PreloadedQuery<ThirdPartyOverviewPageQuery>;
|
||||
}
|
||||
|
||||
export default function ThirdPartyOverviewPage(props: ThirdPartyOverviewPageProps) {
|
||||
const data = usePreloadedQuery<ThirdPartyOverviewPageQuery>(thirdPartyOverviewPageQuery, props.queryRef);
|
||||
if (data.node?.__typename !== "ThirdParty") {
|
||||
throw new Error("Third party not found");
|
||||
}
|
||||
const thirdParty = data.node;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
const thirdPartyCategories: { value: ThirdPartyCategory; label: string }[] = [
|
||||
@@ -125,14 +152,14 @@ export default function ThirdPartyOverviewTab() {
|
||||
} = useThirdPartyForm(thirdParty);
|
||||
|
||||
const thirdPartyWithBAA
|
||||
= useFragment<ThirdPartyOverviewTabBusinessAssociateAgreementFragment$key>(
|
||||
= useFragment<ThirdPartyOverviewPageBusinessAssociateAgreementFragment$key>(
|
||||
thirdPartyBusinessAssociateAgreementFragment,
|
||||
thirdParty,
|
||||
);
|
||||
const businessAssociateAgreement = thirdPartyWithBAA.businessAssociateAgreement;
|
||||
|
||||
const thirdPartyWithDPA
|
||||
= useFragment<ThirdPartyOverviewTabDataPrivacyAgreementFragment$key>(
|
||||
= useFragment<ThirdPartyOverviewPageDataPrivacyAgreementFragment$key>(
|
||||
thirdPartyDataPrivacyAgreementFragment,
|
||||
thirdParty,
|
||||
);
|
||||
@@ -169,7 +196,6 @@ export default function ThirdPartyOverviewTab() {
|
||||
: e => void handleSubmit(e)}
|
||||
className="space-y-12"
|
||||
>
|
||||
{/* ThirdParty Details */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-base font-medium">{__("Third party details")}</h2>
|
||||
<Card className="space-y-4" padded>
|
||||
@@ -237,7 +263,6 @@ export default function ThirdPartyOverviewTab() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Ownership */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-base font-medium">{__("Ownership details")}</h2>
|
||||
<Card className="space-y-4" padded>
|
||||
@@ -262,7 +287,6 @@ export default function ThirdPartyOverviewTab() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Links */}
|
||||
<div className="space-y-4 mb-4">
|
||||
<h2 className="text-base font-medium">{__("Links")}</h2>
|
||||
<Card className="divide-y divide-border-low">
|
||||
@@ -292,7 +316,6 @@ export default function ThirdPartyOverviewTab() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Data agreements */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-base font-medium">{__("Data agreements")}</h2>
|
||||
<Card className="space-y-4" padded>
|
||||
@@ -446,7 +469,6 @@ export default function ThirdPartyOverviewTab() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex justify-end">
|
||||
{thirdParty.canUpdate && (
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { ThirdPartyOverviewPageQuery } from "#/__generated__/core/ThirdPartyOverviewPageQuery.graphql";
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
|
||||
import ThirdPartyOverviewPage, { thirdPartyOverviewPageQuery } from "./ThirdPartyOverviewPage";
|
||||
|
||||
export default function ThirdPartyOverviewPageLoader() {
|
||||
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<ThirdPartyOverviewPageQuery>(thirdPartyOverviewPageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (thirdPartyId) {
|
||||
loadQuery({ thirdPartyId });
|
||||
}
|
||||
}, [loadQuery, thirdPartyId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <LinkCardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LinkCardSkeleton />}>
|
||||
<ThirdPartyOverviewPage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
IconPlusLarge,
|
||||
Tbody,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
TrButton,
|
||||
} from "@probo/ui";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useState } from "react";
|
||||
import { graphql, type PreloadedQuery, usePreloadedQuery, useRefetchableFragment } from "react-relay";
|
||||
|
||||
import type { ThirdPartyRiskAssessmentPageFragment$key } from "#/__generated__/core/ThirdPartyRiskAssessmentPageFragment.graphql";
|
||||
import type { ThirdPartyRiskAssessmentPageQuery } from "#/__generated__/core/ThirdPartyRiskAssessmentPageQuery.graphql";
|
||||
import type { ThirdPartyRiskAssessmentPageRefetchQuery } from "#/__generated__/core/ThirdPartyRiskAssessmentPageRefetchQuery.graphql";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
|
||||
import { CreateRiskAssessmentDialog } from "../_components/CreateRiskAssessmentDialog";
|
||||
|
||||
import { ThirdPartyRiskAssessmentRow } from "./_components/ThirdPartyRiskAssessmentRow";
|
||||
|
||||
const riskAssessmentsFragment = graphql`
|
||||
fragment ThirdPartyRiskAssessmentPageFragment on ThirdParty
|
||||
@refetchable(queryName: "ThirdPartyRiskAssessmentPageRefetchQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: { type: "ThirdPartyRiskAssessmentOrder", defaultValue: null }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
) {
|
||||
id
|
||||
name
|
||||
canCreateRiskAssessment: permission(
|
||||
action: "core:thirdParty-risk-assessment:create"
|
||||
)
|
||||
riskAssessments(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "ThirdPartyRiskAssessmentPage_riskAssessments") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...ThirdPartyRiskAssessmentRow_assessment
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const thirdPartyRiskAssessmentPageQuery = graphql`
|
||||
query ThirdPartyRiskAssessmentPageQuery($thirdPartyId: ID!) {
|
||||
node(id: $thirdPartyId) {
|
||||
__typename
|
||||
... on ThirdParty {
|
||||
...ThirdPartyRiskAssessmentPageFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface ThirdPartyRiskAssessmentPageProps {
|
||||
queryRef: PreloadedQuery<ThirdPartyRiskAssessmentPageQuery>;
|
||||
}
|
||||
|
||||
export default function ThirdPartyRiskAssessmentPage(
|
||||
props: ThirdPartyRiskAssessmentPageProps,
|
||||
) {
|
||||
const queryData = usePreloadedQuery<ThirdPartyRiskAssessmentPageQuery>(
|
||||
thirdPartyRiskAssessmentPageQuery,
|
||||
props.queryRef,
|
||||
);
|
||||
if (queryData.node?.__typename !== "ThirdParty") {
|
||||
throw new Error("Third party not found");
|
||||
}
|
||||
|
||||
const [data, refetch] = useRefetchableFragment<
|
||||
ThirdPartyRiskAssessmentPageRefetchQuery,
|
||||
ThirdPartyRiskAssessmentPageFragment$key
|
||||
>(riskAssessmentsFragment, queryData.node);
|
||||
|
||||
const assessments = data.riskAssessments.edges.map(edge => edge.node);
|
||||
const { __ } = useTranslate();
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
usePageTitle(data.name + " - " + __("Risk Assessments"));
|
||||
|
||||
if (assessments.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-sm py-6 text-txt-secondary flex flex-col items-center gap-2">
|
||||
{__("No risk assessments found")}
|
||||
{data.canCreateRiskAssessment && (
|
||||
<CreateRiskAssessmentDialog
|
||||
thirdPartyId={data.id}
|
||||
connection={data.riskAssessments.__id}
|
||||
>
|
||||
<Button icon={IconPlusLarge} variant="secondary">
|
||||
{__("Add Risk Assessment")}
|
||||
</Button>
|
||||
</CreateRiskAssessmentDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 relative">
|
||||
<div className="overflow-x-auto">
|
||||
<SortableTable
|
||||
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
|
||||
>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<SortableTh field="CREATED_AT">{__("Created At")}</SortableTh>
|
||||
<SortableTh field="EXPIRES_AT">{__("Expires")}</SortableTh>
|
||||
<Th>{__("Data sensitivity")}</Th>
|
||||
<Th>{__("Business impact")}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{data.canCreateRiskAssessment && (
|
||||
<CreateRiskAssessmentDialog
|
||||
thirdPartyId={data.id}
|
||||
connection={data.riskAssessments.__id}
|
||||
>
|
||||
<TrButton colspan={4} onClick={() => {}}>
|
||||
{__("Add Risk Assessment")}
|
||||
</TrButton>
|
||||
</CreateRiskAssessmentDialog>
|
||||
)}
|
||||
{assessments.map(assessment => (
|
||||
<ThirdPartyRiskAssessmentRow
|
||||
key={assessment.id}
|
||||
assessmentKey={assessment}
|
||||
isExpanded={expanded === assessment.id}
|
||||
onClick={() =>
|
||||
setExpanded(prev =>
|
||||
prev === assessment.id ? null : assessment.id,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</SortableTable>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { ThirdPartyRiskAssessmentPageQuery } from "#/__generated__/core/ThirdPartyRiskAssessmentPageQuery.graphql";
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
|
||||
import ThirdPartyRiskAssessmentPage, { thirdPartyRiskAssessmentPageQuery } from "./ThirdPartyRiskAssessmentPage";
|
||||
|
||||
export default function ThirdPartyRiskAssessmentPageLoader() {
|
||||
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<ThirdPartyRiskAssessmentPageQuery>(thirdPartyRiskAssessmentPageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (thirdPartyId) {
|
||||
loadQuery({ thirdPartyId });
|
||||
}
|
||||
}, [loadQuery, thirdPartyId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <LinkCardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LinkCardSkeleton />}>
|
||||
<ThirdPartyRiskAssessmentPage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatDate } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Badge,
|
||||
RiskBadge,
|
||||
Td,
|
||||
Tr,
|
||||
} from "@probo/ui";
|
||||
import { clsx } from "clsx";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import type { ThirdPartyRiskAssessmentRow_assessment$key } from "#/__generated__/core/ThirdPartyRiskAssessmentRow_assessment.graphql";
|
||||
|
||||
const riskAssessmentRowFragment = graphql`
|
||||
fragment ThirdPartyRiskAssessmentRow_assessment on ThirdPartyRiskAssessment {
|
||||
id
|
||||
createdAt
|
||||
expiresAt
|
||||
dataSensitivity
|
||||
businessImpact
|
||||
notes
|
||||
}
|
||||
`;
|
||||
|
||||
interface ThirdPartyRiskAssessmentRowProps {
|
||||
assessmentKey: ThirdPartyRiskAssessmentRow_assessment$key;
|
||||
isExpanded: boolean;
|
||||
onClick: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ThirdPartyRiskAssessmentRow(props: ThirdPartyRiskAssessmentRowProps) {
|
||||
const { __ } = useTranslate();
|
||||
const assessment = useFragment(riskAssessmentRowFragment, props.assessmentKey);
|
||||
const { relativeDateFormat } = useTranslate();
|
||||
const isExpired = new Date(assessment.expiresAt) < new Date();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr
|
||||
className={clsx(
|
||||
isExpired && "opacity-50",
|
||||
"cursor-pointer",
|
||||
props.isExpanded && "border-none",
|
||||
)}
|
||||
onClick={() => props.onClick(assessment.id)}
|
||||
>
|
||||
<Td>
|
||||
<span className="text-xs text-txt-secondary ml-1">
|
||||
{formatDate(assessment.createdAt)}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
{relativeDateFormat(assessment.expiresAt)}
|
||||
{isExpired && <Badge variant="neutral">{__("Expired")}</Badge>}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<RiskBadge level={assessment.dataSensitivity} />
|
||||
</Td>
|
||||
<Td>
|
||||
<RiskBadge level={assessment.businessImpact} />
|
||||
</Td>
|
||||
</Tr>
|
||||
{props.isExpanded && (
|
||||
<Tr className={clsx("border-none", isExpired && "opacity-50")}>
|
||||
<Td colSpan={4}>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
{__("Notes")}
|
||||
:
|
||||
</div>
|
||||
<p className="text-sm text-txt-secondary whitespace-pre-wrap">
|
||||
{assessment.notes}
|
||||
</p>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
84
apps/console/src/pages/organizations/third-parties/routes.ts
Normal file
84
apps/console/src/pages/organizations/third-parties/routes.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { lazy } from "@probo/react-lazy";
|
||||
import type { AppRoute } from "@probo/routes";
|
||||
import { Fragment } from "react";
|
||||
import { redirect } from "react-router";
|
||||
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
|
||||
export const thirdPartyRoutes = [
|
||||
{
|
||||
path: "third-parties",
|
||||
Fallback: PageSkeleton,
|
||||
Component: lazy(() => import("./ThirdPartiesPageLoader")),
|
||||
},
|
||||
{
|
||||
path: "third-parties/:thirdPartyId",
|
||||
Fallback: PageSkeleton,
|
||||
Component: lazy(() => import("./ThirdPartyDetailLayoutLoader")),
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
loader: () => {
|
||||
// eslint-disable-next-line
|
||||
throw redirect("overview");
|
||||
},
|
||||
Component: Fragment,
|
||||
},
|
||||
{
|
||||
path: "overview",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(() => import("./overview/ThirdPartyOverviewPageLoader")),
|
||||
},
|
||||
{
|
||||
path: "certifications",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(() => import("./certifications/ThirdPartyCertificationsPageLoader")),
|
||||
},
|
||||
{
|
||||
path: "compliance",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(() => import("./compliance/ThirdPartyCompliancePageLoader")),
|
||||
},
|
||||
{
|
||||
path: "risks",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(() => import("./risks/ThirdPartyRiskAssessmentPageLoader")),
|
||||
},
|
||||
{
|
||||
path: "contacts",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(() => import("./contacts/ThirdPartyContactsPageLoader")),
|
||||
},
|
||||
{
|
||||
path: "services",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(() => import("./services/ThirdPartyServicesPageLoader")),
|
||||
},
|
||||
{
|
||||
path: "third-parties",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(() => import("./third-parties/ThirdPartyThirdPartiesPageLoader")),
|
||||
},
|
||||
{
|
||||
path: "measures",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(() => import("./measures/ThirdPartyMeasuresPageLoader")),
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
IconPlusLarge,
|
||||
PageHeader,
|
||||
Tbody,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@probo/ui";
|
||||
import { useState } from "react";
|
||||
import { graphql, type PreloadedQuery, usePaginationFragment, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { ThirdPartyServicesPageFragment$key } from "#/__generated__/core/ThirdPartyServicesPageFragment.graphql";
|
||||
import type { ThirdPartyServicesPageQuery } from "#/__generated__/core/ThirdPartyServicesPageQuery.graphql";
|
||||
import type { ThirdPartyServicesPageRefetchQuery } from "#/__generated__/core/ThirdPartyServicesPageRefetchQuery.graphql";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
|
||||
import { CreateServiceDialog } from "../_components/CreateServiceDialog";
|
||||
import { EditServiceDialog } from "../_components/EditServiceDialog";
|
||||
|
||||
import { ThirdPartyServiceRow } from "./_components/ThirdPartyServiceRow";
|
||||
|
||||
const thirdPartyServicesFragment = graphql`
|
||||
fragment ThirdPartyServicesPageFragment on ThirdParty
|
||||
@refetchable(queryName: "ThirdPartyServicesPageRefetchQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: { type: "ThirdPartyServiceOrder", defaultValue: null }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
) {
|
||||
id
|
||||
name
|
||||
canCreateService: permission(action: "core:thirdParty-service:create")
|
||||
services(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "ThirdPartyServicesPage_services") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
canUpdate: permission(action: "core:thirdParty-service:update")
|
||||
canDelete: permission(action: "core:thirdParty-service:delete")
|
||||
...ThirdPartyServiceRow_service
|
||||
...EditServiceDialog_service
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const thirdPartyServicesPageQuery = graphql`
|
||||
query ThirdPartyServicesPageQuery($thirdPartyId: ID!) {
|
||||
node(id: $thirdPartyId) {
|
||||
__typename
|
||||
... on ThirdParty {
|
||||
...ThirdPartyServicesPageFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface ThirdPartyServicesPageProps {
|
||||
queryRef: PreloadedQuery<ThirdPartyServicesPageQuery>;
|
||||
}
|
||||
|
||||
export default function ThirdPartyServicesPage(props: ThirdPartyServicesPageProps) {
|
||||
const { __ } = useTranslate();
|
||||
const queryData = usePreloadedQuery<ThirdPartyServicesPageQuery>(thirdPartyServicesPageQuery, props.queryRef);
|
||||
if (queryData.node?.__typename !== "ThirdParty") {
|
||||
throw new Error("Third party not found");
|
||||
}
|
||||
|
||||
const { data, ...pagination } = usePaginationFragment<
|
||||
ThirdPartyServicesPageRefetchQuery,
|
||||
ThirdPartyServicesPageFragment$key
|
||||
>(thirdPartyServicesFragment, queryData.node);
|
||||
|
||||
const refetch = ({
|
||||
order,
|
||||
}: {
|
||||
order: { direction: string; field: string };
|
||||
}) => {
|
||||
pagination.refetch(
|
||||
{
|
||||
order: {
|
||||
direction: order.direction as "ASC" | "DESC",
|
||||
field: order.field as "NAME" | "CREATED_AT",
|
||||
},
|
||||
},
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
};
|
||||
|
||||
const connectionId = data.services.__id;
|
||||
const services = data.services.edges.map(edge => edge.node);
|
||||
const [editingService, setEditingService]
|
||||
= useState<(typeof services)[number] | null>(null);
|
||||
const hasAnyAction = services.some(
|
||||
service => service.canUpdate || service.canDelete,
|
||||
);
|
||||
|
||||
usePageTitle(data.name + " - " + __("Services"));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={__("Services")}
|
||||
description={__("Manage services provided by this third party.")}
|
||||
>
|
||||
{data.canCreateService && (
|
||||
<CreateServiceDialog thirdPartyId={data.id} connectionId={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add service")}</Button>
|
||||
</CreateServiceDialog>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<SortableTable {...pagination} refetch={refetch}>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<SortableTh field="NAME">{__("Name")}</SortableTh>
|
||||
<Th>{__("Description")}</Th>
|
||||
{hasAnyAction && <Th>{__("Actions")}</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{services.map(service => (
|
||||
<ThirdPartyServiceRow
|
||||
key={service.id}
|
||||
serviceKey={service}
|
||||
connectionId={connectionId}
|
||||
onEdit={() => setEditingService(service)}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</SortableTable>
|
||||
|
||||
{editingService && editingService.canUpdate && (
|
||||
<EditServiceDialog
|
||||
serviceKey={editingService}
|
||||
onClose={() => setEditingService(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { ThirdPartyServicesPageQuery } from "#/__generated__/core/ThirdPartyServicesPageQuery.graphql";
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
|
||||
import ThirdPartyServicesPage, { thirdPartyServicesPageQuery } from "./ThirdPartyServicesPage";
|
||||
|
||||
export default function ThirdPartyServicesPageLoader() {
|
||||
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<ThirdPartyServicesPageQuery>(thirdPartyServicesPageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
if (thirdPartyId) {
|
||||
loadQuery({ thirdPartyId });
|
||||
}
|
||||
}, [loadQuery, thirdPartyId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <LinkCardSkeleton />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<LinkCardSkeleton />}>
|
||||
<ThirdPartyServicesPage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
DropdownItem,
|
||||
IconPencil,
|
||||
IconTrashCan,
|
||||
Td,
|
||||
Tr,
|
||||
useConfirm,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { graphql, useFragment, useMutation } from "react-relay";
|
||||
|
||||
import type { ThirdPartyServiceRow_service$key } from "#/__generated__/core/ThirdPartyServiceRow_service.graphql";
|
||||
import type { ThirdPartyServiceRowDeleteMutation } from "#/__generated__/core/ThirdPartyServiceRowDeleteMutation.graphql";
|
||||
|
||||
const serviceRowFragment = graphql`
|
||||
fragment ThirdPartyServiceRow_service on ThirdPartyService {
|
||||
id
|
||||
name
|
||||
description
|
||||
canUpdate: permission(action: "core:thirdParty-service:update")
|
||||
canDelete: permission(action: "core:thirdParty-service:delete")
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteServiceMutation = graphql`
|
||||
mutation ThirdPartyServiceRowDeleteMutation(
|
||||
$input: DeleteThirdPartyServiceInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteThirdPartyService(input: $input) {
|
||||
deletedThirdPartyServiceId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface ThirdPartyServiceRowProps {
|
||||
serviceKey: ThirdPartyServiceRow_service$key;
|
||||
connectionId: string;
|
||||
onEdit: () => void;
|
||||
}
|
||||
|
||||
export function ThirdPartyServiceRow(props: ThirdPartyServiceRowProps) {
|
||||
const { __ } = useTranslate();
|
||||
const service = useFragment(serviceRowFragment, props.serviceKey);
|
||||
const confirm = useConfirm();
|
||||
const { toast } = useToast();
|
||||
const [deleteService] = useMutation<ThirdPartyServiceRowDeleteMutation>(
|
||||
deleteServiceMutation,
|
||||
);
|
||||
const hasAnyAction = service.canUpdate || service.canDelete;
|
||||
|
||||
const handleDelete = () => {
|
||||
confirm(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
void deleteService({
|
||||
variables: {
|
||||
connections: [props.connectionId],
|
||||
input: { thirdPartyServiceId: service.id },
|
||||
},
|
||||
onCompleted(_response, errors) {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to delete service"),
|
||||
errors,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
resolve();
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to delete service"),
|
||||
error,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
resolve();
|
||||
},
|
||||
});
|
||||
}),
|
||||
{
|
||||
message: sprintf(
|
||||
__(
|
||||
"This will permanently delete the service \"%s\". This action cannot be undone.",
|
||||
),
|
||||
service.name,
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td>{service.name}</Td>
|
||||
<Td>{service.description || __("—")}</Td>
|
||||
{hasAnyAction && (
|
||||
<Td width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
{service.canUpdate && (
|
||||
<DropdownItem
|
||||
icon={IconPencil}
|
||||
onClick={() => props.onEdit()}
|
||||
>
|
||||
{__("Edit")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{service.canDelete && (
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
variant="danger"
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { downloadFile, fileSize, formatDate, sprintf } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
Button,
|
||||
DropdownItem,
|
||||
IconArrowDown,
|
||||
IconPlusLarge,
|
||||
IconTrashCan,
|
||||
PageHeader,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useConfirm,
|
||||
} from "@probo/ui";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useFragment, useRefetchableFragment } from "react-relay";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { ComplianceReportListQuery } from "#/__generated__/core/ComplianceReportListQuery.graphql";
|
||||
import type { ThirdPartyComplianceTabFragment$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment.graphql";
|
||||
import type { ThirdPartyComplianceTabFragment_report$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment_report.graphql";
|
||||
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
import { UploadComplianceReportDialog } from "../dialogs/UploadComplianceReportDialog";
|
||||
|
||||
export const complianceReportsFragment = graphql`
|
||||
fragment ThirdPartyComplianceTabFragment on ThirdParty
|
||||
@refetchable(queryName: "ComplianceReportListQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: { type: "ThirdPartyComplianceReportOrder", defaultValue: null }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
) {
|
||||
complianceReports(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "ThirdPartyComplianceTabFragment_complianceReports") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
canDelete: permission(action: "core:thirdParty-compliance-report:delete")
|
||||
...ThirdPartyComplianceTabFragment_report
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const complianceReportFragment = graphql`
|
||||
fragment ThirdPartyComplianceTabFragment_report on ThirdPartyComplianceReport {
|
||||
id
|
||||
reportDate
|
||||
validUntil
|
||||
reportName
|
||||
file {
|
||||
fileName
|
||||
size
|
||||
downloadUrl
|
||||
}
|
||||
canDelete: permission(action: "core:thirdParty-compliance-report:delete")
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteReportMutation = graphql`
|
||||
mutation ThirdPartyComplianceTabDeleteReportMutation(
|
||||
$input: DeleteThirdPartyComplianceReportInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteThirdPartyComplianceReport(input: $input) {
|
||||
deletedThirdPartyComplianceReportId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function ThirdPartyComplianceTab() {
|
||||
const { thirdParty } = useOutletContext<{
|
||||
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
|
||||
}>();
|
||||
const [data, refetch] = useRefetchableFragment<
|
||||
ComplianceReportListQuery,
|
||||
ThirdPartyComplianceTabFragment$key
|
||||
>(complianceReportsFragment, thirdParty);
|
||||
const connectionId = data.complianceReports.__id;
|
||||
const reports = data.complianceReports.edges.map(edge => edge.node);
|
||||
const { __ } = useTranslate();
|
||||
usePageTitle(thirdParty.name + " - " + __("Compliance reports"));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={__("Compliance reports")}
|
||||
description={__("Track third party compliance certifications and reports.")}
|
||||
>
|
||||
{thirdParty.canUploadComplianceReport && (
|
||||
<UploadComplianceReportDialog
|
||||
thirdPartyId={thirdParty.id}
|
||||
connectionId={connectionId}
|
||||
>
|
||||
<Button icon={IconPlusLarge}>{__("Add report")}</Button>
|
||||
</UploadComplianceReportDialog>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<SortableTable
|
||||
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
|
||||
>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>{__("Report name")}</Th>
|
||||
<SortableTh field="REPORT_DATE">{__("Report date")}</SortableTh>
|
||||
<Th>{__("Valid until")}</Th>
|
||||
<Th>{__("File size")}</Th>
|
||||
{reports.length > 0 && <Th>{__("Actions")}</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{reports.map(report => (
|
||||
<ReportRow
|
||||
key={report.id}
|
||||
reportKey={report}
|
||||
connectionId={connectionId}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</SortableTable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ReportRowProps = {
|
||||
reportKey: ThirdPartyComplianceTabFragment_report$key;
|
||||
connectionId: string;
|
||||
};
|
||||
|
||||
function ReportRow(props: ReportRowProps) {
|
||||
const { __ } = useTranslate();
|
||||
const report = useFragment<ThirdPartyComplianceTabFragment_report$key>(
|
||||
complianceReportFragment,
|
||||
props.reportKey,
|
||||
);
|
||||
const confirm = useConfirm();
|
||||
const [deleteReport] = useMutationWithToasts(deleteReportMutation, {
|
||||
successMessage: __("Report deleted successfully"),
|
||||
errorMessage: __("Failed to delete report"),
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
confirm(
|
||||
() =>
|
||||
deleteReport({
|
||||
variables: {
|
||||
connections: [props.connectionId],
|
||||
input: {
|
||||
reportId: report.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
message: sprintf(
|
||||
__(
|
||||
"This will permanently delete the report \"%s\". This action cannot be undone.",
|
||||
),
|
||||
report.reportName,
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td>{report.reportName}</Td>
|
||||
<Td>{formatDate(report.reportDate)}</Td>
|
||||
<Td>{formatDate(report.validUntil)}</Td>
|
||||
<Td>{fileSize(__, report.file?.size ?? 0)}</Td>
|
||||
<Td width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
{report.file?.downloadUrl && (
|
||||
<DropdownItem
|
||||
icon={IconArrowDown}
|
||||
onClick={() =>
|
||||
downloadFile(
|
||||
report.file!.downloadUrl,
|
||||
report.file!.fileName,
|
||||
)}
|
||||
>
|
||||
{__("Download")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{report.canDelete && (
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
variant="danger"
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
Button,
|
||||
DropdownItem,
|
||||
IconPencil,
|
||||
IconPlusLarge,
|
||||
IconTrashCan,
|
||||
PageHeader,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useConfirm,
|
||||
} from "@probo/ui";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { useFragment, useRefetchableFragment } from "react-relay";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { ThirdPartyContactsListQuery } from "#/__generated__/core/ThirdPartyContactsListQuery.graphql";
|
||||
import type { ThirdPartyContactsTabFragment$key } from "#/__generated__/core/ThirdPartyContactsTabFragment.graphql";
|
||||
import type {
|
||||
ThirdPartyContactsTabFragment_contact$data,
|
||||
ThirdPartyContactsTabFragment_contact$key,
|
||||
} from "#/__generated__/core/ThirdPartyContactsTabFragment_contact.graphql";
|
||||
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
import { CreateContactDialog } from "../dialogs/CreateContactDialog";
|
||||
import { EditContactDialog } from "../dialogs/EditContactDialog";
|
||||
|
||||
export const thirdPartyContactsFragment = graphql`
|
||||
fragment ThirdPartyContactsTabFragment on ThirdParty
|
||||
@refetchable(queryName: "ThirdPartyContactsListQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: { type: "ThirdPartyContactOrder", defaultValue: null }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
) {
|
||||
contacts(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "ThirdPartyContactsTabFragment_contacts") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
canUpdate: permission(action: "core:thirdParty-contact:update")
|
||||
canDelete: permission(action: "core:thirdParty-contact:delete")
|
||||
...ThirdPartyContactsTabFragment_contact
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const contactFragment = graphql`
|
||||
fragment ThirdPartyContactsTabFragment_contact on ThirdPartyContact {
|
||||
id
|
||||
fullName
|
||||
email
|
||||
phone
|
||||
role
|
||||
canUpdate: permission(action: "core:thirdParty-contact:update")
|
||||
canDelete: permission(action: "core:thirdParty-contact:delete")
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteContactMutation = graphql`
|
||||
mutation ThirdPartyContactsTabDeleteContactMutation(
|
||||
$input: DeleteThirdPartyContactInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteThirdPartyContact(input: $input) {
|
||||
deletedThirdPartyContactId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function ThirdPartyContactsTab() {
|
||||
const { thirdParty } = useOutletContext<{
|
||||
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
|
||||
}>();
|
||||
const [data, refetch] = useRefetchableFragment<
|
||||
ThirdPartyContactsListQuery,
|
||||
ThirdPartyContactsTabFragment$key
|
||||
>(thirdPartyContactsFragment, thirdParty);
|
||||
const connectionId = data.contacts.__id;
|
||||
const contacts = data.contacts.edges.map(edge => edge.node);
|
||||
const { __ } = useTranslate();
|
||||
const [editingContact, setEditingContact]
|
||||
= useState<ThirdPartyContactsTabFragment_contact$data | null>(null);
|
||||
const hasAnyAction = contacts.some(
|
||||
({ canUpdate, canDelete }) => canUpdate || canDelete,
|
||||
);
|
||||
|
||||
usePageTitle(thirdParty.name + " - " + __("Contacts"));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={__("Contacts")}
|
||||
description={__("Manage third party contacts and their information.")}
|
||||
>
|
||||
{thirdParty.canCreateContact && (
|
||||
<CreateContactDialog thirdPartyId={thirdParty.id} connectionId={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add contact")}</Button>
|
||||
</CreateContactDialog>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<SortableTable
|
||||
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
|
||||
>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<SortableTh field="FULL_NAME">{__("Name")}</SortableTh>
|
||||
<SortableTh field="EMAIL">{__("Email")}</SortableTh>
|
||||
<Th>{__("Phone")}</Th>
|
||||
<Th>{__("Role")}</Th>
|
||||
{hasAnyAction && <Th>{__("Actions")}</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{contacts.map(contact => (
|
||||
<ContactRow
|
||||
key={contact.id}
|
||||
contactKey={contact}
|
||||
connectionId={connectionId}
|
||||
onEdit={setEditingContact}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</SortableTable>
|
||||
|
||||
{editingContact && editingContact.canUpdate && (
|
||||
<EditContactDialog
|
||||
contactId={editingContact.id}
|
||||
contact={editingContact}
|
||||
onClose={() => setEditingContact(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ContactRowProps = {
|
||||
contactKey: ThirdPartyContactsTabFragment_contact$key;
|
||||
connectionId: string;
|
||||
onEdit: (contact: ThirdPartyContactsTabFragment_contact$data) => void;
|
||||
};
|
||||
|
||||
function ContactRow(props: ContactRowProps) {
|
||||
const { __ } = useTranslate();
|
||||
const contact = useFragment<ThirdPartyContactsTabFragment_contact$key>(
|
||||
contactFragment,
|
||||
props.contactKey,
|
||||
);
|
||||
const confirm = useConfirm();
|
||||
const [deleteContact] = useMutationWithToasts(deleteContactMutation, {
|
||||
successMessage: __("Contact deleted successfully"),
|
||||
errorMessage: __("Failed to delete contact"),
|
||||
});
|
||||
const hasAnyAction = contact.canUpdate || contact.canDelete;
|
||||
|
||||
const handleDelete = () => {
|
||||
confirm(
|
||||
() =>
|
||||
deleteContact({
|
||||
variables: {
|
||||
connections: [props.connectionId],
|
||||
input: {
|
||||
thirdPartyContactId: contact.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
message: sprintf(
|
||||
__(
|
||||
"This will permanently delete the contact \"%s\". This action cannot be undone.",
|
||||
),
|
||||
contact.fullName || contact.email || __("Unnamed contact"),
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td>{contact.fullName || __("—")}</Td>
|
||||
<Td>
|
||||
{contact.email
|
||||
? (
|
||||
<a
|
||||
href={`mailto:${contact.email}`}
|
||||
className="text-primary-600 hover:text-primary-800"
|
||||
>
|
||||
{contact.email}
|
||||
</a>
|
||||
)
|
||||
: (
|
||||
__("—")
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{contact.phone
|
||||
? (
|
||||
<a
|
||||
href={`tel:${contact.phone}`}
|
||||
className="text-primary-600 hover:text-primary-800"
|
||||
>
|
||||
{contact.phone}
|
||||
</a>
|
||||
)
|
||||
: (
|
||||
__("—")
|
||||
)}
|
||||
</Td>
|
||||
<Td>{contact.role || __("—")}</Td>
|
||||
{hasAnyAction && (
|
||||
<Td width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
{contact.canUpdate && (
|
||||
<DropdownItem
|
||||
icon={IconPencil}
|
||||
onClick={() => props.onEdit(contact)}
|
||||
>
|
||||
{__("Edit")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{contact.canDelete && (
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
variant="danger"
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatDate } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
IconPlusLarge,
|
||||
RiskBadge,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
TrButton,
|
||||
} from "@probo/ui";
|
||||
import { clsx } from "clsx";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { useFragment, useRefetchableFragment } from "react-relay";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
|
||||
import type { ThirdPartyRiskAssessmentTabFragment$key } from "#/__generated__/core/ThirdPartyRiskAssessmentTabFragment.graphql";
|
||||
import type { ThirdPartyRiskAssessmentTabFragment_assessment$key } from "#/__generated__/core/ThirdPartyRiskAssessmentTabFragment_assessment.graphql";
|
||||
import type { ThirdPartyRiskAssessmentTabQuery } from "#/__generated__/core/ThirdPartyRiskAssessmentTabQuery.graphql";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
|
||||
import { CreateRiskAssessmentDialog } from "../dialogs/CreateRiskAssessmentDialog";
|
||||
|
||||
const riskAssessmentsFragment = graphql`
|
||||
fragment ThirdPartyRiskAssessmentTabFragment on ThirdParty
|
||||
@refetchable(queryName: "ThirdPartyRiskAssessmentTabQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: { type: "ThirdPartyRiskAssessmentOrder", defaultValue: null }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
) {
|
||||
id
|
||||
|
||||
riskAssessments(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "ThirdPartyRiskAssessmentTabFragment_riskAssessments") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...ThirdPartyRiskAssessmentTabFragment_assessment
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const riskAssessmentFragment = graphql`
|
||||
fragment ThirdPartyRiskAssessmentTabFragment_assessment on ThirdPartyRiskAssessment {
|
||||
id
|
||||
createdAt
|
||||
expiresAt
|
||||
dataSensitivity
|
||||
businessImpact
|
||||
notes
|
||||
}
|
||||
`;
|
||||
|
||||
export default function ThirdPartyRiskAssessmentTab() {
|
||||
const { thirdParty } = useOutletContext<{
|
||||
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
|
||||
}>();
|
||||
const [data, refetch] = useRefetchableFragment<
|
||||
ThirdPartyRiskAssessmentTabQuery,
|
||||
ThirdPartyRiskAssessmentTabFragment$key
|
||||
>(riskAssessmentsFragment, thirdParty);
|
||||
const assessments = data.riskAssessments.edges.map(edge => edge.node);
|
||||
const { __ } = useTranslate();
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
usePageTitle(thirdParty.name + " - " + __("Risk Assessments"));
|
||||
|
||||
if (assessments.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-sm py-6 text-txt-secondary flex flex-col items-center gap-2">
|
||||
{__("No risk assessments found")}
|
||||
{thirdParty.canCreateRiskAssessment && (
|
||||
<CreateRiskAssessmentDialog
|
||||
thirdPartyId={thirdParty.id}
|
||||
connection={data.riskAssessments.__id}
|
||||
>
|
||||
<Button icon={IconPlusLarge} variant="secondary">
|
||||
{__("Add Risk Assessment")}
|
||||
</Button>
|
||||
</CreateRiskAssessmentDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 relative">
|
||||
<div className="flex justify-end"></div>
|
||||
<div className="overflow-x-auto">
|
||||
<SortableTable
|
||||
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
|
||||
>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<SortableTh field="CREATED_AT">{__("Created At")}</SortableTh>
|
||||
<SortableTh field="EXPIRES_AT">{__("Expires")}</SortableTh>
|
||||
<Th>{__("Data sensitivity")}</Th>
|
||||
<Th>{__("Business impact")}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{thirdParty.canCreateRiskAssessment && (
|
||||
<CreateRiskAssessmentDialog
|
||||
thirdPartyId={thirdParty.id}
|
||||
connection={data.riskAssessments.__id}
|
||||
>
|
||||
<TrButton colspan={5} onClick={() => {}}>
|
||||
{__("Add Risk Assessment")}
|
||||
</TrButton>
|
||||
</CreateRiskAssessmentDialog>
|
||||
)}
|
||||
{assessments.map(assessment => (
|
||||
<AssessmentRow
|
||||
key={assessment.id}
|
||||
assessmentKey={assessment}
|
||||
isExpanded={expanded === assessment.id}
|
||||
onClick={() =>
|
||||
setExpanded(prev =>
|
||||
prev === assessment.id ? null : assessment.id,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</SortableTable>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type AssessmentRowProps = {
|
||||
assessmentKey: ThirdPartyRiskAssessmentTabFragment_assessment$key;
|
||||
onClick: (id: string) => void;
|
||||
isExpanded: boolean;
|
||||
};
|
||||
|
||||
function AssessmentRow(props: AssessmentRowProps) {
|
||||
const { __ } = useTranslate();
|
||||
const assessment
|
||||
= useFragment<ThirdPartyRiskAssessmentTabFragment_assessment$key>(
|
||||
riskAssessmentFragment,
|
||||
props.assessmentKey,
|
||||
);
|
||||
const { relativeDateFormat } = useTranslate();
|
||||
const isExpired = new Date(assessment.expiresAt) < new Date();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tr
|
||||
className={clsx(
|
||||
isExpired && "opacity-50",
|
||||
"cursor-pointer",
|
||||
props.isExpanded && "border-none",
|
||||
)}
|
||||
onClick={() => props.onClick(assessment.id)}
|
||||
>
|
||||
<Td>
|
||||
<span className="text-xs text-txt-secondary ml-1">
|
||||
{formatDate(assessment.createdAt)}
|
||||
</span>
|
||||
</Td>
|
||||
<Td>
|
||||
<div className="flex items-center gap-2">
|
||||
{relativeDateFormat(assessment.expiresAt)}
|
||||
{isExpired && <Badge variant="neutral">{__("Expired")}</Badge>}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<RiskBadge level={assessment.dataSensitivity} />
|
||||
</Td>
|
||||
<Td>
|
||||
<RiskBadge level={assessment.businessImpact} />
|
||||
</Td>
|
||||
</Tr>
|
||||
{props.isExpanded && (
|
||||
<Tr className={clsx("border-none", isExpired && "opacity-50")}>
|
||||
<Td colSpan={4}>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
{__("Notes")}
|
||||
:
|
||||
</div>
|
||||
<p className="text-sm text-txt-secondary whitespace-pre-wrap">
|
||||
{assessment.notes}
|
||||
</p>
|
||||
</div>
|
||||
</Td>
|
||||
</Tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,237 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
ActionDropdown,
|
||||
Button,
|
||||
DropdownItem,
|
||||
IconPencil,
|
||||
IconPlusLarge,
|
||||
IconTrashCan,
|
||||
PageHeader,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
useConfirm,
|
||||
} from "@probo/ui";
|
||||
import { type ComponentProps, useState } from "react";
|
||||
import { useFragment, useRefetchableFragment } from "react-relay";
|
||||
import { useOutletContext } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
|
||||
import type { ThirdPartyServicesListQuery } from "#/__generated__/core/ThirdPartyServicesListQuery.graphql";
|
||||
import type { ThirdPartyServicesTabFragment$key } from "#/__generated__/core/ThirdPartyServicesTabFragment.graphql";
|
||||
import type {
|
||||
ThirdPartyServicesTabFragment_service$data,
|
||||
ThirdPartyServicesTabFragment_service$key,
|
||||
} from "#/__generated__/core/ThirdPartyServicesTabFragment_service.graphql";
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
import { CreateServiceDialog } from "../dialogs/CreateServiceDialog";
|
||||
import { EditServiceDialog } from "../dialogs/EditServiceDialog";
|
||||
|
||||
export const thirdPartyServicesFragment = graphql`
|
||||
fragment ThirdPartyServicesTabFragment on ThirdParty
|
||||
@refetchable(queryName: "ThirdPartyServicesListQuery")
|
||||
@argumentDefinitions(
|
||||
first: { type: "Int", defaultValue: 50 }
|
||||
order: { type: "ThirdPartyServiceOrder", defaultValue: null }
|
||||
after: { type: "CursorKey", defaultValue: null }
|
||||
before: { type: "CursorKey", defaultValue: null }
|
||||
last: { type: "Int", defaultValue: null }
|
||||
) {
|
||||
services(
|
||||
first: $first
|
||||
after: $after
|
||||
last: $last
|
||||
before: $before
|
||||
orderBy: $order
|
||||
) @connection(key: "ThirdPartyServicesTabFragment_services") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
canUpdate: permission(action: "core:thirdParty-service:update")
|
||||
canDelete: permission(action: "core:thirdParty-service:delete")
|
||||
...ThirdPartyServicesTabFragment_service
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const serviceFragment = graphql`
|
||||
fragment ThirdPartyServicesTabFragment_service on ThirdPartyService {
|
||||
id
|
||||
name
|
||||
description
|
||||
canUpdate: permission(action: "core:thirdParty-service:update")
|
||||
canDelete: permission(action: "core:thirdParty-service:delete")
|
||||
}
|
||||
`;
|
||||
|
||||
const deleteServiceMutation = graphql`
|
||||
mutation ThirdPartyServicesTabDeleteServiceMutation(
|
||||
$input: DeleteThirdPartyServiceInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
deleteThirdPartyService(input: $input) {
|
||||
deletedThirdPartyServiceId @deleteEdge(connections: $connections)
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function ThirdPartyServicesTab() {
|
||||
const { thirdParty } = useOutletContext<{
|
||||
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
|
||||
}>();
|
||||
const [data, refetch] = useRefetchableFragment<
|
||||
ThirdPartyServicesListQuery,
|
||||
ThirdPartyServicesTabFragment$key
|
||||
>(thirdPartyServicesFragment, thirdParty);
|
||||
const connectionId = data.services.__id;
|
||||
const services = data.services.edges.map(edge => edge.node);
|
||||
const { __ } = useTranslate();
|
||||
const [editingService, setEditingService]
|
||||
= useState<ThirdPartyServicesTabFragment_service$data | null>(null);
|
||||
const hasAnyAction = services.some(
|
||||
({ canUpdate, canDelete }) => canUpdate || canDelete,
|
||||
);
|
||||
|
||||
usePageTitle(thirdParty.name + " - " + __("Services"));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title={__("Services")}
|
||||
description={__("Manage services provided by this third party.")}
|
||||
>
|
||||
{thirdParty.canCreateService && (
|
||||
<CreateServiceDialog thirdPartyId={thirdParty.id} connectionId={connectionId}>
|
||||
<Button icon={IconPlusLarge}>{__("Add service")}</Button>
|
||||
</CreateServiceDialog>
|
||||
)}
|
||||
</PageHeader>
|
||||
|
||||
<SortableTable
|
||||
refetch={refetch as ComponentProps<typeof SortableTable>["refetch"]}
|
||||
>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<SortableTh field="NAME">{__("Name")}</SortableTh>
|
||||
<Th>{__("Description")}</Th>
|
||||
{hasAnyAction && <Th>{__("Actions")}</Th>}
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{services.map(service => (
|
||||
<ServiceRow
|
||||
key={service.id}
|
||||
serviceKey={service}
|
||||
connectionId={connectionId}
|
||||
onEdit={setEditingService}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</SortableTable>
|
||||
|
||||
{editingService && editingService.canUpdate && (
|
||||
<EditServiceDialog
|
||||
serviceId={editingService.id}
|
||||
service={editingService}
|
||||
onClose={() => setEditingService(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ServiceRowProps = {
|
||||
serviceKey: ThirdPartyServicesTabFragment_service$key;
|
||||
connectionId: string;
|
||||
onEdit: (service: ThirdPartyServicesTabFragment_service$data) => void;
|
||||
};
|
||||
|
||||
function ServiceRow(props: ServiceRowProps) {
|
||||
const { __ } = useTranslate();
|
||||
const service = useFragment<ThirdPartyServicesTabFragment_service$key>(
|
||||
serviceFragment,
|
||||
props.serviceKey,
|
||||
);
|
||||
const confirm = useConfirm();
|
||||
const [deleteService] = useMutationWithToasts(deleteServiceMutation, {
|
||||
successMessage: __("Service deleted successfully"),
|
||||
errorMessage: __("Failed to delete service"),
|
||||
});
|
||||
const hasAnyAction = service.canUpdate || service.canDelete;
|
||||
|
||||
const handleDelete = () => {
|
||||
confirm(
|
||||
() =>
|
||||
deleteService({
|
||||
variables: {
|
||||
connections: [props.connectionId],
|
||||
input: {
|
||||
thirdPartyServiceId: service.id,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
message: sprintf(
|
||||
__(
|
||||
"This will permanently delete the service \"%s\". This action cannot be undone.",
|
||||
),
|
||||
service.name,
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Tr>
|
||||
<Td>{service.name}</Td>
|
||||
<Td>{service.description || __("—")}</Td>
|
||||
{hasAnyAction && (
|
||||
<Td width={50} className="text-end">
|
||||
<ActionDropdown>
|
||||
{service.canUpdate && (
|
||||
<DropdownItem
|
||||
icon={IconPencil}
|
||||
onClick={() => props.onEdit(service)}
|
||||
>
|
||||
{__("Edit")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{service.canDelete && (
|
||||
<DropdownItem
|
||||
icon={IconTrashCan}
|
||||
onClick={handleDelete}
|
||||
variant="danger"
|
||||
>
|
||||
{__("Delete")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
</ActionDropdown>
|
||||
</Td>
|
||||
)}
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
@@ -45,7 +45,7 @@ import type { ThirdPartyThirdPartiesPageQuery } from "#/__generated__/core/Third
|
||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { AddChildThirdPartyDialog } from "../dialogs/AddChildThirdPartyDialog";
|
||||
import { AddChildThirdPartyDialog } from "../_components/AddChildThirdPartyDialog";
|
||||
|
||||
// Keep in sync with coredata.MaxThirdPartyLevel on the backend.
|
||||
const MAX_THIRD_PARTY_LEVEL = 4;
|
||||
|
||||
@@ -33,6 +33,7 @@ import { compliancePageRoutes } from "./pages/organizations/compliance-page/rout
|
||||
import { cookieBannerRoutes } from "./pages/organizations/cookie-banners/routes";
|
||||
import { riskAssessmentRoutes } from "./pages/organizations/risk-assessments/routes";
|
||||
import { riskRoutes } from "./pages/organizations/risks/routes";
|
||||
import { thirdPartyRoutes } from "./pages/organizations/third-parties/routes";
|
||||
import { CurrentUser } from "./providers/CurrentUser";
|
||||
import { accessReviewRoutes } from "./routes/accessReviewRoutes";
|
||||
import { assetRoutes } from "./routes/assetRoutes";
|
||||
@@ -48,7 +49,6 @@ import { processingActivityRoutes } from "./routes/processingActivityRoutes";
|
||||
import { rightsRequestRoutes } from "./routes/rightsRequestRoutes";
|
||||
import { statementsOfApplicabilityRoutes } from "./routes/statementsOfApplicabilityRoutes";
|
||||
import { taskRoutes } from "./routes/taskRoutes";
|
||||
import { thirdPartyRoutes } from "./routes/thirdPartyRoutes";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
|
||||
//
|
||||
// Permission to use, copy, modify, and/or distribute this software for any
|
||||
// purpose with or without fee is hereby granted, provided that the above
|
||||
// copyright notice and this permission notice appear in all copies.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { lazy } from "@probo/react-lazy";
|
||||
import {
|
||||
type AppRoute,
|
||||
loaderFromQueryLoader,
|
||||
withQueryRef,
|
||||
} from "@probo/routes";
|
||||
import { loadQuery } from "react-relay";
|
||||
|
||||
import type { ThirdPartyGraphListQuery } from "#/__generated__/core/ThirdPartyGraphListQuery.graphql";
|
||||
import type { ThirdPartyGraphNodeQuery } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
import { coreEnvironment } from "#/environments";
|
||||
import { thirdPartiesQuery, thirdPartyNodeQuery } from "#/hooks/graph/ThirdPartyGraph";
|
||||
|
||||
export const thirdPartyRoutes = [
|
||||
{
|
||||
path: "third-parties",
|
||||
Fallback: PageSkeleton,
|
||||
loader: loaderFromQueryLoader(({ organizationId }) =>
|
||||
loadQuery<ThirdPartyGraphListQuery>(coreEnvironment, thirdPartiesQuery, {
|
||||
organizationId: organizationId,
|
||||
}),
|
||||
),
|
||||
Component: withQueryRef(
|
||||
lazy(() => import("#/pages/organizations/third-parties/ThirdPartiesPage")),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "third-parties/:thirdPartyId",
|
||||
Fallback: PageSkeleton,
|
||||
loader: loaderFromQueryLoader(({ thirdPartyId }) =>
|
||||
loadQuery<ThirdPartyGraphNodeQuery>(coreEnvironment, thirdPartyNodeQuery, {
|
||||
thirdPartyId: thirdPartyId,
|
||||
}),
|
||||
),
|
||||
Component: withQueryRef(
|
||||
lazy(() => import("../pages/organizations/third-parties/ThirdPartyDetailPage")),
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: "overview",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() => import("../pages/organizations/third-parties/tabs/ThirdPartyOverviewTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "certifications",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("../pages/organizations/third-parties/tabs/ThirdPartyCertificationsTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "compliance",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("../pages/organizations/third-parties/tabs/ThirdPartyComplianceTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "risks",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("../pages/organizations/third-parties/tabs/ThirdPartyRiskAssessmentTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "contacts",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() => import("../pages/organizations/third-parties/tabs/ThirdPartyContactsTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "services",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() => import("../pages/organizations/third-parties/tabs/ThirdPartyServicesTab"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "third-parties",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("../pages/organizations/third-parties/third-parties/ThirdPartyThirdPartiesPageLoader"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "measures",
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() => import("../pages/organizations/third-parties/measures/ThirdPartyMeasuresPage"),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
Reference in New Issue
Block a user