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 { faviconUrl } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import { Avatar, Badge, Button, Field, IconCrossLargeX, Option, Select } from "@probo/ui";
|
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 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 = {
|
type ThirdParty = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
websiteUrl: string | null | undefined;
|
websiteUrl: string | null | undefined;
|
||||||
level?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props<T extends FieldValues = FieldValues> = {
|
type Props<T extends FieldValues = FieldValues> = {
|
||||||
@@ -42,29 +64,47 @@ export function ThirdPartiesMultiSelectField<T extends FieldValues = FieldValues
|
|||||||
selectedThirdParties = [],
|
selectedThirdParties = [],
|
||||||
...props
|
...props
|
||||||
}: Props<T>) {
|
}: 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 (
|
return (
|
||||||
<Field {...props}>
|
<Field {...props}>
|
||||||
<Suspense
|
{queryRef
|
||||||
fallback={<Select variant="editor" disabled placeholder="Loading..." />}
|
? (
|
||||||
>
|
<Suspense fallback={loadingState}>
|
||||||
<ThirdPartiesMultiSelectWithQuery
|
<ThirdPartiesMultiSelectWithQuery
|
||||||
organizationId={organizationId}
|
queryRef={queryRef}
|
||||||
control={control}
|
control={control}
|
||||||
name={props.name}
|
name={props.name}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
selectedThirdParties={selectedThirdParties}
|
selectedThirdParties={selectedThirdParties}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
loadingState
|
||||||
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThirdPartiesMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
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 { __ } = useTranslate();
|
||||||
const { name, organizationId, control, selectedThirdParties = [] } = props;
|
const { name, control, selectedThirdParties = [] } = props;
|
||||||
const thirdParties = useThirdParties(organizationId);
|
const data = usePreloadedQuery<ThirdPartiesMultiSelectFieldQuery>(thirdPartiesQuery, props.queryRef);
|
||||||
|
const thirdParties = data.organization?.thirdParties?.edges.map(edge => edge.node) ?? [];
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const allThirdParties: ThirdParty[] = [...thirdParties];
|
const allThirdParties: ThirdParty[] = [...thirdParties];
|
||||||
|
|||||||
@@ -14,10 +14,31 @@
|
|||||||
|
|
||||||
import { faviconUrl } from "@probo/helpers";
|
import { faviconUrl } from "@probo/helpers";
|
||||||
import { Avatar, Badge, IconCrossLargeX } from "@probo/ui";
|
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 { 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 = {
|
type ThirdParty = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,15 +56,19 @@ const empty = [] as ThirdParty[];
|
|||||||
|
|
||||||
export function ThirdPartiesCell(props: Props) {
|
export function ThirdPartiesCell(props: Props) {
|
||||||
return (
|
return (
|
||||||
<GraphQLCell<ThirdPartyGraphSelectQuery, ThirdParty>
|
<GraphQLCell<ThirdPartiesCellQuery, ThirdParty>
|
||||||
multiple
|
multiple
|
||||||
name={props.name}
|
name={props.name}
|
||||||
query={thirdPartiesSelectQuery}
|
query={thirdPartiesCellQuery}
|
||||||
variables={{
|
variables={{
|
||||||
organizationId: props.organizationId,
|
organizationId: props.organizationId,
|
||||||
}}
|
}}
|
||||||
items={data =>
|
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 }) => (
|
itemRenderer={({ item, onRemove }) => (
|
||||||
<ThirdPartyBadge thirdParty={item} onRemove={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
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import { faviconUrl, formatDate } from "@probo/helpers";
|
|
||||||
import { usePageTitle } from "@probo/hooks";
|
import { usePageTitle } from "@probo/hooks";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
ActionDropdown,
|
|
||||||
Avatar,
|
|
||||||
Button,
|
Button,
|
||||||
DropdownItem,
|
|
||||||
IconPageTextLine,
|
IconPageTextLine,
|
||||||
IconPlusLarge,
|
IconPlusLarge,
|
||||||
IconTrashCan,
|
|
||||||
IconUpload,
|
IconUpload,
|
||||||
PageHeader,
|
PageHeader,
|
||||||
RiskBadge,
|
|
||||||
Tbody,
|
Tbody,
|
||||||
Td,
|
|
||||||
Th,
|
Th,
|
||||||
Thead,
|
Thead,
|
||||||
Tr,
|
Tr,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import {
|
import {
|
||||||
|
graphql,
|
||||||
type PreloadedQuery,
|
type PreloadedQuery,
|
||||||
usePaginationFragment,
|
usePaginationFragment,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
import type { ThirdPartyGraphListQuery } from "#/__generated__/core/ThirdPartyGraphListQuery.graphql";
|
import type { ThirdPartiesPageFragment$key } from "#/__generated__/core/ThirdPartiesPageFragment.graphql";
|
||||||
import type {
|
import type { ThirdPartiesPageQuery } from "#/__generated__/core/ThirdPartiesPageQuery.graphql";
|
||||||
ThirdPartyGraphPaginatedFragment$data,
|
import type { ThirdPartiesPageRefetchQuery } from "#/__generated__/core/ThirdPartiesPageRefetchQuery.graphql";
|
||||||
ThirdPartyGraphPaginatedFragment$key,
|
|
||||||
} from "#/__generated__/core/ThirdPartyGraphPaginatedFragment.graphql";
|
|
||||||
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
import { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||||
import {
|
|
||||||
paginatedThirdPartiesFragment,
|
|
||||||
thirdPartiesQuery,
|
|
||||||
useDeleteThirdParty,
|
|
||||||
} from "#/hooks/graph/ThirdPartyGraph";
|
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
import type { NodeOf } from "#/types";
|
|
||||||
|
|
||||||
import { CreateThirdPartyDialog } from "./dialogs/CreateThirdPartyDialog";
|
import { CreateThirdPartyDialog } from "./_components/CreateThirdPartyDialog";
|
||||||
import { PublishThirdPartyListDialog } from "./dialogs/PublishThirdPartyListDialog";
|
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 {
|
const thirdPartiesFragment = graphql`
|
||||||
// Sub-third-parties are persisted with their fully-qualified name
|
fragment ThirdPartiesPageFragment on Organization
|
||||||
// (e.g. "aws (Probo/Level2/Level3)"), so the stored name is shown as-is.
|
@refetchable(queryName: "ThirdPartiesPageRefetchQuery")
|
||||||
return tp.name;
|
@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 = {
|
export default function ThirdPartiesPage(props: ThirdPartiesPageProps) {
|
||||||
queryRef: PreloadedQuery<ThirdPartyGraphListQuery>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ThirdPartiesPage(props: Props) {
|
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const organizationId = useOrganizationId();
|
const organizationId = useOrganizationId();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const data = usePreloadedQuery<ThirdPartyGraphListQuery>(thirdPartiesQuery, props.queryRef);
|
const queryData = usePreloadedQuery<ThirdPartiesPageQuery>(thirdPartiesPageQuery, props.queryRef);
|
||||||
// eslint-disable-next-line relay/generated-typescript-types
|
const { data: fragmentData, ...pagination } = usePaginationFragment<
|
||||||
const pagination = usePaginationFragment(
|
ThirdPartiesPageRefetchQuery,
|
||||||
paginatedThirdPartiesFragment,
|
ThirdPartiesPageFragment$key
|
||||||
data.node as ThirdPartyGraphPaginatedFragment$key,
|
>(thirdPartiesFragment, queryData.organization);
|
||||||
);
|
|
||||||
|
|
||||||
const thirdParties = pagination.data.thirdParties?.edges.map(edge => edge.node);
|
const refetch = ({
|
||||||
const connectionId = pagination.data.thirdParties.__id;
|
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"));
|
usePageTitle(__("Third parties"));
|
||||||
|
|
||||||
const hasAnyAction
|
const hasAnyAction = thirdParties.some(({ canDelete }) => canDelete);
|
||||||
= thirdParties.some(({ canUpdate, canDelete }) => canUpdate || canDelete);
|
|
||||||
|
|
||||||
const thirdPartiesDocument = data.node?.thirdPartiesDocument;
|
const thirdPartiesDocument = fragmentData.thirdPartiesDocument;
|
||||||
const defaultApproverIds
|
const defaultApproverIds
|
||||||
= thirdPartiesDocument?.defaultApprovers?.map(a => a.id) ?? [];
|
= thirdPartiesDocument?.defaultApprovers?.map(a => a.id) ?? [];
|
||||||
|
|
||||||
@@ -112,7 +160,7 @@ export default function ThirdPartiesPage(props: Props) {
|
|||||||
{__("Document")}
|
{__("Document")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{data.node.canPublishThirdParty && (
|
{fragmentData.canPublishThirdParty && (
|
||||||
<PublishThirdPartyListDialog
|
<PublishThirdPartyListDialog
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
defaultApproverIds={defaultApproverIds}
|
defaultApproverIds={defaultApproverIds}
|
||||||
@@ -125,7 +173,7 @@ export default function ThirdPartiesPage(props: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</PublishThirdPartyListDialog>
|
</PublishThirdPartyListDialog>
|
||||||
)}
|
)}
|
||||||
{data.node.canCreateThirdParty && (
|
{fragmentData.canCreateThirdParty && (
|
||||||
<CreateThirdPartyDialog
|
<CreateThirdPartyDialog
|
||||||
connection={connectionId}
|
connection={connectionId}
|
||||||
organizationId={organizationId}
|
organizationId={organizationId}
|
||||||
@@ -135,7 +183,7 @@ export default function ThirdPartiesPage(props: Props) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<SortableTable {...pagination}>
|
<SortableTable {...pagination} refetch={refetch}>
|
||||||
<Thead>
|
<Thead>
|
||||||
<Tr>
|
<Tr>
|
||||||
<SortableTh field="NAME">{__("Third party")}</SortableTh>
|
<SortableTh field="NAME">{__("Third party")}</SortableTh>
|
||||||
@@ -146,11 +194,10 @@ export default function ThirdPartiesPage(props: Props) {
|
|||||||
</Tr>
|
</Tr>
|
||||||
</Thead>
|
</Thead>
|
||||||
<Tbody>
|
<Tbody>
|
||||||
{thirdParties?.map(thirdParty => (
|
{thirdParties.map(thirdParty => (
|
||||||
<ThirdPartyRow
|
<ThirdPartyRow
|
||||||
key={thirdParty.id}
|
key={thirdParty.id}
|
||||||
thirdParty={thirdParty}
|
thirdPartyKey={thirdParty}
|
||||||
organizationId={organizationId}
|
|
||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
hasAnyAction={hasAnyAction}
|
hasAnyAction={hasAnyAction}
|
||||||
/>
|
/>
|
||||||
@@ -160,61 +207,3 @@ export default function ThirdPartiesPage(props: Props) {
|
|||||||
</div>
|
</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
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// 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 { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
@@ -25,59 +26,113 @@ import {
|
|||||||
TabBadge,
|
TabBadge,
|
||||||
TabLink,
|
TabLink,
|
||||||
Tabs,
|
Tabs,
|
||||||
|
useConfirm,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
ConnectionHandler,
|
graphql,
|
||||||
type PreloadedQuery,
|
type PreloadedQuery,
|
||||||
useFragment,
|
useMutation,
|
||||||
usePreloadedQuery,
|
usePreloadedQuery,
|
||||||
useRelayEnvironment,
|
useRelayEnvironment,
|
||||||
} from "react-relay";
|
} from "react-relay";
|
||||||
import { Link, Outlet } from "react-router";
|
import { Link, Outlet, useNavigate, useParams } from "react-router";
|
||||||
import { fetchQuery } from "relay-runtime";
|
import { ConnectionHandler, fetchQuery } from "relay-runtime";
|
||||||
|
|
||||||
import type { ThirdPartyComplianceTabFragment$key } from "#/__generated__/core/ThirdPartyComplianceTabFragment.graphql";
|
import type { ThirdPartyDetailLayoutDeleteMutation } from "#/__generated__/core/ThirdPartyDetailLayoutDeleteMutation.graphql";
|
||||||
import type { ThirdPartyGraphNodeQuery } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
|
import type { ThirdPartyDetailLayoutQuery } from "#/__generated__/core/ThirdPartyDetailLayoutQuery.graphql";
|
||||||
import {
|
|
||||||
thirdPartyConnectionKey,
|
|
||||||
thirdPartyNodeQuery,
|
|
||||||
useDeleteThirdParty,
|
|
||||||
} from "#/hooks/graph/ThirdPartyGraph";
|
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
import { VettingDialog } from "./dialogs/VettingDialog";
|
import { VettingDialog } from "./_components/VettingDialog";
|
||||||
import { measuresFragment } from "./measures/ThirdPartyMeasuresPage";
|
import { ThirdPartiesConnectionFilter, ThirdPartiesConnectionKey } from "./ThirdPartiesPage";
|
||||||
import { complianceReportsFragment } from "./tabs/ThirdPartyComplianceTab";
|
|
||||||
|
|
||||||
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 = {
|
const deleteThirdPartyMutation = graphql`
|
||||||
queryRef: PreloadedQuery<ThirdPartyGraphNodeQuery>;
|
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 environment = useRelayEnvironment();
|
||||||
const { node: thirdParty } = usePreloadedQuery<ThirdPartyGraphNodeQuery>(thirdPartyNodeQuery, props.queryRef);
|
|
||||||
const { __ } = useTranslate();
|
|
||||||
const organizationId = useOrganizationId();
|
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(() => {
|
useEffect(() => {
|
||||||
thirdPartyIdRef.current = thirdParty.id;
|
thirdPartyIdRef.current = thirdParty.id;
|
||||||
}, [thirdParty.id]);
|
}, [thirdParty.id]);
|
||||||
|
|
||||||
const isVetting = thirdParty.vettingStatus === "PENDING" || thirdParty.vettingStatus === "PROCESSING";
|
const isVetting = thirdParty.vettingStatus === "PENDING"
|
||||||
|
|| thirdParty.vettingStatus === "PROCESSING";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVetting) return;
|
if (!isVetting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
if (document.hidden) return;
|
if (document.hidden) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fetchQuery<ThirdPartyGraphNodeQuery>(
|
fetchQuery(
|
||||||
environment,
|
environment,
|
||||||
thirdPartyNodeQuery,
|
thirdPartyDetailLayoutQuery,
|
||||||
{ thirdPartyId: thirdPartyIdRef.current },
|
{ thirdPartyId: thirdPartyIdRef.current },
|
||||||
{ fetchPolicy: "network-only" },
|
{ fetchPolicy: "network-only" },
|
||||||
).subscribe({});
|
).subscribe({});
|
||||||
@@ -86,26 +141,67 @@ export default function ThirdPartyDetailPage(props: Props) {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isVetting, environment]);
|
}, [isVetting, environment]);
|
||||||
|
|
||||||
const deleteThirdParty = useDeleteThirdParty(
|
const [deleteThirdParty] = useMutation<ThirdPartyDetailLayoutDeleteMutation>(
|
||||||
thirdParty,
|
deleteThirdPartyMutation,
|
||||||
ConnectionHandler.getConnectionID(organizationId, thirdPartyConnectionKey),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 logo = faviconUrl(thirdParty.websiteUrl);
|
||||||
const reportsCount = useFragment(
|
const reportsCount = thirdParty.complianceReportsInfo?.edges.length ?? 0;
|
||||||
complianceReportsFragment,
|
const measuresCount = thirdParty.measuresInfo?.totalCount ?? 0;
|
||||||
thirdParty as ThirdPartyComplianceTabFragment$key,
|
const isVettingFailed = thirdParty.vettingStatus === "FAILED";
|
||||||
).complianceReports.edges.length;
|
const ancestors = thirdParty.ancestors ?? [];
|
||||||
const measuresCount = thirdParty.measuresInfos?.totalCount ?? 0;
|
|
||||||
|
|
||||||
const thirdPartiesUrl = `/organizations/${organizationId}/third-parties`;
|
const thirdPartiesUrl = `/organizations/${organizationId}/third-parties`;
|
||||||
|
|
||||||
const baseThirdPartyUrl
|
const baseThirdPartyUrl
|
||||||
= `/organizations/${organizationId}/third-parties/${thirdParty.id}`;
|
= `/organizations/${organizationId}/third-parties/${thirdParty.id}`;
|
||||||
|
|
||||||
const isVettingFailed = thirdParty.vettingStatus === "FAILED";
|
|
||||||
|
|
||||||
const ancestors = thirdParty.ancestors ?? [];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{isVetting && (
|
{isVetting && (
|
||||||
@@ -181,7 +277,7 @@ export default function ThirdPartyDetailPage(props: Props) {
|
|||||||
<DropdownItem
|
<DropdownItem
|
||||||
variant="danger"
|
variant="danger"
|
||||||
icon={IconTrashCan}
|
icon={IconTrashCan}
|
||||||
onClick={deleteThirdParty}
|
onClick={onDelete}
|
||||||
>
|
>
|
||||||
{__("Delete")}
|
{__("Delete")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
@@ -211,7 +307,7 @@ export default function ThirdPartyDetailPage(props: Props) {
|
|||||||
</TabLink>
|
</TabLink>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<Outlet context={{ thirdParty }} />
|
<Outlet />
|
||||||
</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 { 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 { graphql } from "relay-runtime";
|
||||||
import { useDebounceCallback } from "usehooks-ts";
|
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 { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
|
||||||
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
|
|
||||||
|
|
||||||
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
|
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
|
||||||
|
|
||||||
@@ -22,7 +22,7 @@ import type {
|
|||||||
CommonThirdPartyCombobox_commonThirdParty$key,
|
CommonThirdPartyCombobox_commonThirdParty$key,
|
||||||
} from "#/__generated__/core/CommonThirdPartyCombobox_commonThirdParty.graphql";
|
} from "#/__generated__/core/CommonThirdPartyCombobox_commonThirdParty.graphql";
|
||||||
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.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
|
export type CommonThirdPartyRef
|
||||||
= CommonThirdPartyCombobox_commonThirdParty$data;
|
= CommonThirdPartyCombobox_commonThirdParty$data;
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import { cleanFormData } from "@probo/helpers";
|
import { cleanFormData, formatError } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@@ -22,13 +22,15 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
Field,
|
Field,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
|
import { useMutation } from "react-relay";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { CreateContactDialogMutation } from "#/__generated__/core/CreateContactDialogMutation.graphql";
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -46,7 +48,7 @@ const createContactMutation = graphql`
|
|||||||
node {
|
node {
|
||||||
canUpdate: permission(action: "core:thirdParty-contact:update")
|
canUpdate: permission(action: "core:thirdParty-contact:update")
|
||||||
canDelete: permission(action: "core:thirdParty-contact:delete")
|
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,
|
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);
|
const cleanData = cleanFormData(data);
|
||||||
|
|
||||||
await createContact({
|
createContact({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
thirdPartyId,
|
thirdPartyId,
|
||||||
@@ -112,10 +111,30 @@ export function CreateContactDialog({
|
|||||||
},
|
},
|
||||||
connections: [connectionId],
|
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();
|
dialogRef.current?.close();
|
||||||
reset();
|
reset();
|
||||||
},
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({
|
||||||
|
title: __("Error"),
|
||||||
|
description: formatError(__("Failed to create contact"), error),
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,7 +180,7 @@ export function CreateContactDialog({
|
|||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" disabled={isLoading}>
|
<Button type="submit" disabled={isCreating}>
|
||||||
{__("Create")}
|
{__("Create")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { formatError } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@@ -23,14 +24,16 @@ import {
|
|||||||
ImpactOptions,
|
ImpactOptions,
|
||||||
SentitivityOptions,
|
SentitivityOptions,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
|
import { useMutation } from "react-relay";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { CreateRiskAssessmentDialogMutation } from "#/__generated__/core/CreateRiskAssessmentDialogMutation.graphql";
|
||||||
import { ControlledField } from "#/components/form/ControlledField";
|
import { ControlledField } from "#/components/form/ControlledField";
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -46,7 +49,7 @@ const createRiskAssessmentMutation = graphql`
|
|||||||
createThirdPartyRiskAssessment(input: $input) {
|
createThirdPartyRiskAssessment(input: $input) {
|
||||||
thirdPartyRiskAssessmentEdge @prependEdge(connections: $connections) {
|
thirdPartyRiskAssessmentEdge @prependEdge(connections: $connections) {
|
||||||
node {
|
node {
|
||||||
...ThirdPartyRiskAssessmentTabFragment_assessment
|
...ThirdPartyRiskAssessmentRow_assessment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,18 +79,16 @@ export function CreateRiskAssessmentDialog({
|
|||||||
businessImpact: "LOW",
|
businessImpact: "LOW",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [createRiskAssessment, isLoading] = useMutationWithToasts(
|
const { toast } = useToast();
|
||||||
createRiskAssessmentMutation,
|
const [createRiskAssessment, isCreating]
|
||||||
{
|
= useMutation<CreateRiskAssessmentDialogMutation>(
|
||||||
successMessage: __("Risk Assessment created successfully."),
|
createRiskAssessmentMutation,
|
||||||
errorMessage: __("Failed to create Risk Assessment"),
|
);
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||||
const nextYear = new Date();
|
const nextYear = new Date();
|
||||||
nextYear.setFullYear(nextYear.getFullYear() + 1);
|
nextYear.setFullYear(nextYear.getFullYear() + 1);
|
||||||
await createRiskAssessment({
|
createRiskAssessment({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
...data,
|
...data,
|
||||||
@@ -97,10 +98,36 @@ export function CreateRiskAssessmentDialog({
|
|||||||
},
|
},
|
||||||
connections: [connection],
|
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();
|
dialogRef.current?.close();
|
||||||
reset();
|
reset();
|
||||||
},
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({
|
||||||
|
title: __("Error"),
|
||||||
|
description: formatError(
|
||||||
|
__("Failed to create Risk Assessment"),
|
||||||
|
error,
|
||||||
|
),
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,7 +173,7 @@ export function CreateRiskAssessmentDialog({
|
|||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" disabled={isLoading}>
|
<Button type="submit" disabled={isCreating}>
|
||||||
{__("Create")}
|
{__("Create")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import { cleanFormData } from "@probo/helpers";
|
import { cleanFormData, formatError } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@@ -22,13 +22,15 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
Field,
|
Field,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { type ReactNode } from "react";
|
import { type ReactNode } from "react";
|
||||||
|
import { useMutation } from "react-relay";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql } from "relay-runtime";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { CreateServiceDialogMutation } from "#/__generated__/core/CreateServiceDialogMutation.graphql";
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -44,7 +46,7 @@ const createServiceMutation = graphql`
|
|||||||
createThirdPartyService(input: $input) {
|
createThirdPartyService(input: $input) {
|
||||||
thirdPartyServiceEdge @prependEdge(connections: $connections) {
|
thirdPartyServiceEdge @prependEdge(connections: $connections) {
|
||||||
node {
|
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,
|
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);
|
const cleanData = cleanFormData(data);
|
||||||
|
|
||||||
await createService({
|
createService({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
thirdPartyId,
|
thirdPartyId,
|
||||||
@@ -91,10 +90,30 @@ export function CreateServiceDialog({
|
|||||||
},
|
},
|
||||||
connections: [connectionId],
|
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();
|
dialogRef.current?.close();
|
||||||
reset();
|
reset();
|
||||||
},
|
},
|
||||||
|
onError(error) {
|
||||||
|
toast({
|
||||||
|
title: __("Error"),
|
||||||
|
description: formatError(__("Failed to create service"), error),
|
||||||
|
variant: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -128,7 +147,7 @@ export function CreateServiceDialog({
|
|||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" disabled={isLoading}>
|
<Button type="submit" disabled={isCreating}>
|
||||||
{__("Create")}
|
{__("Create")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { formatError } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Combobox,
|
Combobox,
|
||||||
@@ -21,17 +22,38 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
IconPlusLarge,
|
IconPlusLarge,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { type ReactNode, Suspense, useCallback, useState } from "react";
|
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 { useDebounceCallback } from "usehooks-ts";
|
||||||
|
|
||||||
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
|
import type { CommonThirdPartyComboboxQuery } from "#/__generated__/core/CommonThirdPartyComboboxQuery.graphql";
|
||||||
import type { CreateThirdPartyInput } from "#/__generated__/core/ThirdPartyGraphCreateMutation.graphql";
|
import type {
|
||||||
import { useCreateThirdPartyMutation } from "#/hooks/graph/ThirdPartyGraph";
|
CreateThirdPartyDialogCreateMutation,
|
||||||
|
CreateThirdPartyInput,
|
||||||
|
} from "#/__generated__/core/CreateThirdPartyDialogCreateMutation.graphql";
|
||||||
|
|
||||||
import { commonThirdPartiesQuery, CommonThirdPartyCombobox } from "./CommonThirdPartyCombobox";
|
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 = {
|
type Props = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
organizationId: string;
|
organizationId: string;
|
||||||
@@ -44,7 +66,8 @@ export function CreateThirdPartyDialog({
|
|||||||
connection,
|
connection,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const [createThirdParty] = useCreateThirdPartyMutation();
|
const { toast } = useToast();
|
||||||
|
const [createThirdParty] = useMutation<CreateThirdPartyDialogCreateMutation>(createThirdPartyMutation);
|
||||||
const dialogRef = useDialogRef();
|
const dialogRef = useDialogRef();
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [queryRef, loadQuery]
|
const [queryRef, loadQuery]
|
||||||
@@ -62,14 +85,34 @@ export function CreateThirdPartyDialog({
|
|||||||
...thirdParty,
|
...thirdParty,
|
||||||
organizationId,
|
organizationId,
|
||||||
};
|
};
|
||||||
void createThirdParty({
|
createThirdParty({
|
||||||
variables: {
|
variables: {
|
||||||
input,
|
input,
|
||||||
connections: [connection],
|
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();
|
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
|
<CommonThirdPartyCombobox
|
||||||
queryRef={queryRef}
|
queryRef={queryRef}
|
||||||
excludeNames={new Set()}
|
excludeNames={new Set()}
|
||||||
onSelect={thirdPartyRef => void onSelect(thirdPartyRef)}
|
onSelect={onSelect}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)}
|
)}
|
||||||
{searchQuery.trim().length >= 2 && (
|
{searchQuery.trim().length >= 2 && (
|
||||||
<ComboboxItem onClick={() => void onSelect(searchQuery.trim())}>
|
<ComboboxItem onClick={() => onSelect(searchQuery.trim())}>
|
||||||
<IconPlusLarge size={20} />
|
<IconPlusLarge size={20} />
|
||||||
{__("Create a new third party")}
|
{__("Create a new third party")}
|
||||||
{" "}
|
{" "}
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import { sprintf } from "@probo/helpers";
|
import { formatError, sprintf } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -21,10 +21,11 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
Spinner,
|
Spinner,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} 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`
|
const deleteBusinessAssociateAgreementMutation = graphql`
|
||||||
mutation DeleteBusinessAssociateAgreementDialogMutation(
|
mutation DeleteBusinessAssociateAgreementDialogMutation(
|
||||||
@@ -52,22 +53,50 @@ export function DeleteBusinessAssociateAgreementDialog({
|
|||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const ref = useDialogRef();
|
const ref = useDialogRef();
|
||||||
|
|
||||||
const [mutate, isDeleting] = useMutationWithToasts(deleteBusinessAssociateAgreementMutation, {
|
const { toast } = useToast();
|
||||||
successMessage: __("Business Associate Agreement deleted successfully"),
|
const [deleteAgreement, isDeleting]
|
||||||
errorMessage: __("Failed to delete Business Associate Agreement"),
|
= useMutation<DeleteBusinessAssociateAgreementDialogMutation>(
|
||||||
});
|
deleteBusinessAssociateAgreementMutation,
|
||||||
|
);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = () => {
|
||||||
await mutate({
|
deleteAgreement({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
thirdPartyId,
|
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 (
|
return (
|
||||||
@@ -92,7 +121,7 @@ export function DeleteBusinessAssociateAgreementDialog({
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => void handleDelete()}
|
onClick={handleDelete}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
icon={isDeleting ? Spinner : undefined}
|
icon={isDeleting ? Spinner : undefined}
|
||||||
>
|
>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import { sprintf } from "@probo/helpers";
|
import { formatError, sprintf } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -21,10 +21,11 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
Spinner,
|
Spinner,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} 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`
|
const deleteDataPrivacyAgreementMutation = graphql`
|
||||||
mutation DeleteDataPrivacyAgreementDialogMutation(
|
mutation DeleteDataPrivacyAgreementDialogMutation(
|
||||||
@@ -52,22 +53,50 @@ export function DeleteDataPrivacyAgreementDialog({
|
|||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
const ref = useDialogRef();
|
const ref = useDialogRef();
|
||||||
|
|
||||||
const [mutate, isDeleting] = useMutationWithToasts(deleteDataPrivacyAgreementMutation, {
|
const { toast } = useToast();
|
||||||
successMessage: __("Data Privacy Agreement deleted successfully"),
|
const [deleteAgreement, isDeleting]
|
||||||
errorMessage: __("Failed to delete Data Privacy Agreement"),
|
= useMutation<DeleteDataPrivacyAgreementDialogMutation>(
|
||||||
});
|
deleteDataPrivacyAgreementMutation,
|
||||||
|
);
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = () => {
|
||||||
await mutate({
|
deleteAgreement({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
thirdPartyId,
|
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 (
|
return (
|
||||||
@@ -92,7 +121,7 @@ export function DeleteDataPrivacyAgreementDialog({
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => void handleDelete()}
|
onClick={handleDelete}
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
icon={isDeleting ? Spinner : undefined}
|
icon={isDeleting ? Spinner : undefined}
|
||||||
>
|
>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { formatError } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -22,12 +23,13 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Spinner,
|
Spinner,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { graphql } from "react-relay";
|
import { graphql, useMutation } from "react-relay";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { EditBusinessAssociateAgreementDialogMutation } from "#/__generated__/core/EditBusinessAssociateAgreementDialogMutation.graphql";
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
|
||||||
|
|
||||||
const updateBusinessAssociateAgreementMutation = graphql`
|
const updateBusinessAssociateAgreementMutation = graphql`
|
||||||
mutation EditBusinessAssociateAgreementDialogMutation(
|
mutation EditBusinessAssociateAgreementDialogMutation(
|
||||||
@@ -79,7 +81,6 @@ export function EditBusinessAssociateAgreementDialog({
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isSubmitting },
|
|
||||||
reset,
|
reset,
|
||||||
} = useFormWithSchema(schema, {
|
} = useFormWithSchema(schema, {
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -88,18 +89,19 @@ export function EditBusinessAssociateAgreementDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [mutate] = useMutationWithToasts(updateBusinessAssociateAgreementMutation, {
|
const { toast } = useToast();
|
||||||
successMessage: __("Business Associate Agreement updated successfully"),
|
const [updateAgreement, isUpdating]
|
||||||
errorMessage: __("Failed to update Business Associate Agreement"),
|
= useMutation<EditBusinessAssociateAgreementDialogMutation>(
|
||||||
});
|
updateBusinessAssociateAgreementMutation,
|
||||||
|
);
|
||||||
|
|
||||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||||
const formatDatetime = (dateString?: string) => {
|
const formatDatetime = (dateString?: string) => {
|
||||||
if (!dateString) return null;
|
if (!dateString) return null;
|
||||||
return `${dateString}T00:00:00Z`;
|
return `${dateString}T00:00:00Z`;
|
||||||
};
|
};
|
||||||
|
|
||||||
await mutate({
|
updateAgreement({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
thirdPartyId,
|
thirdPartyId,
|
||||||
@@ -107,10 +109,37 @@ export function EditBusinessAssociateAgreementDialog({
|
|||||||
validUntil: formatDatetime(data.validUntil),
|
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 = () => {
|
const handleClose = () => {
|
||||||
@@ -140,8 +169,8 @@ export function EditBusinessAssociateAgreementDialog({
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isUpdating}
|
||||||
icon={isSubmitting ? Spinner : undefined}
|
icon={isUpdating ? Spinner : undefined}
|
||||||
>
|
>
|
||||||
{__("Update")}
|
{__("Update")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import { cleanFormData } from "@probo/helpers";
|
import { cleanFormData, formatError } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@@ -22,26 +22,37 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
Field,
|
Field,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql, useFragment, useMutation } from "react-relay";
|
||||||
import { z } from "zod";
|
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 { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
contactId: string;
|
contactKey: EditContactDialog_contact$key;
|
||||||
contact: ThirdPartyContactsTabFragment_contact$data;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const editContactDialogFragment = graphql`
|
||||||
|
fragment EditContactDialog_contact on ThirdPartyContact {
|
||||||
|
id
|
||||||
|
fullName
|
||||||
|
email
|
||||||
|
phone
|
||||||
|
role
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const updateContactMutation = graphql`
|
const updateContactMutation = graphql`
|
||||||
mutation EditContactDialogUpdateMutation($input: UpdateThirdPartyContactInput!) {
|
mutation EditContactDialogUpdateMutation($input: UpdateThirdPartyContactInput!) {
|
||||||
updateThirdPartyContact(input: $input) {
|
updateThirdPartyContact(input: $input) {
|
||||||
thirdPartyContact {
|
thirdPartyContact {
|
||||||
...ThirdPartyContactsTabFragment_contact
|
...ThirdPartyContactRow_contact
|
||||||
|
...EditContactDialog_contact
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,8 +60,10 @@ const updateContactMutation = graphql`
|
|||||||
|
|
||||||
const phoneRegex = /^\+[0-9]{8,15}$/;
|
const phoneRegex = /^\+[0-9]{8,15}$/;
|
||||||
|
|
||||||
export function EditContactDialog({ contactId, contact, onClose }: Props) {
|
export function EditContactDialog({ contactKey, onClose }: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const contact = useFragment(editContactDialogFragment, contactKey);
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
fullName: z.string().optional(),
|
fullName: z.string().optional(),
|
||||||
@@ -81,27 +94,42 @@ export function EditContactDialog({ contactId, contact, onClose }: Props) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [updateContact, isLoading] = useMutationWithToasts(
|
const [updateContact, isUpdating]
|
||||||
updateContactMutation,
|
= useMutation<EditContactDialogUpdateMutation>(updateContactMutation);
|
||||||
{
|
|
||||||
successMessage: __("Contact updated successfully."),
|
|
||||||
errorMessage: __("Failed to update contact"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||||
const cleanData = cleanFormData(data);
|
const cleanData = cleanFormData(data);
|
||||||
|
|
||||||
await updateContact({
|
updateContact({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
id: contactId,
|
id: contact.id,
|
||||||
...cleanData,
|
...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();
|
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>
|
</DialogContent>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" disabled={isLoading}>
|
<Button type="submit" disabled={isUpdating}>
|
||||||
{__("Save")}
|
{__("Save")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { formatError } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -22,12 +23,13 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Spinner,
|
Spinner,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { graphql } from "react-relay";
|
import { graphql, useMutation } from "react-relay";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { EditDataPrivacyAgreementDialogMutation } from "#/__generated__/core/EditDataPrivacyAgreementDialogMutation.graphql";
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
|
||||||
|
|
||||||
const updateDataPrivacyAgreementMutation = graphql`
|
const updateDataPrivacyAgreementMutation = graphql`
|
||||||
mutation EditDataPrivacyAgreementDialogMutation(
|
mutation EditDataPrivacyAgreementDialogMutation(
|
||||||
@@ -79,7 +81,6 @@ export function EditDataPrivacyAgreementDialog({
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isSubmitting },
|
|
||||||
reset,
|
reset,
|
||||||
} = useFormWithSchema(schema, {
|
} = useFormWithSchema(schema, {
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -88,18 +89,19 @@ export function EditDataPrivacyAgreementDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [mutate] = useMutationWithToasts(updateDataPrivacyAgreementMutation, {
|
const { toast } = useToast();
|
||||||
successMessage: __("Data Privacy Agreement updated successfully"),
|
const [updateAgreement, isUpdating]
|
||||||
errorMessage: __("Failed to update Data Privacy Agreement"),
|
= useMutation<EditDataPrivacyAgreementDialogMutation>(
|
||||||
});
|
updateDataPrivacyAgreementMutation,
|
||||||
|
);
|
||||||
|
|
||||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||||
const formatDatetime = (dateString?: string) => {
|
const formatDatetime = (dateString?: string) => {
|
||||||
if (!dateString) return null;
|
if (!dateString) return null;
|
||||||
return `${dateString}T00:00:00Z`;
|
return `${dateString}T00:00:00Z`;
|
||||||
};
|
};
|
||||||
|
|
||||||
await mutate({
|
updateAgreement({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
thirdPartyId,
|
thirdPartyId,
|
||||||
@@ -107,10 +109,37 @@ export function EditDataPrivacyAgreementDialog({
|
|||||||
validUntil: formatDatetime(data.validUntil),
|
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 = () => {
|
const handleClose = () => {
|
||||||
@@ -140,8 +169,8 @@ export function EditDataPrivacyAgreementDialog({
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isUpdating}
|
||||||
icon={isSubmitting ? Spinner : undefined}
|
icon={isUpdating ? Spinner : undefined}
|
||||||
>
|
>
|
||||||
{__("Update")}
|
{__("Update")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import { cleanFormData } from "@probo/helpers";
|
import { cleanFormData, formatError } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Breadcrumb,
|
Breadcrumb,
|
||||||
@@ -22,35 +22,44 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
Field,
|
Field,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { graphql } from "relay-runtime";
|
import { graphql, useFragment, useMutation } from "react-relay";
|
||||||
import { z } from "zod";
|
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 { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
serviceId: string;
|
serviceKey: EditServiceDialog_service$key;
|
||||||
service: {
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
};
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const editServiceDialogFragment = graphql`
|
||||||
|
fragment EditServiceDialog_service on ThirdPartyService {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
description
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const updateServiceMutation = graphql`
|
const updateServiceMutation = graphql`
|
||||||
mutation EditServiceDialogUpdateMutation($input: UpdateThirdPartyServiceInput!) {
|
mutation EditServiceDialogUpdateMutation($input: UpdateThirdPartyServiceInput!) {
|
||||||
updateThirdPartyService(input: $input) {
|
updateThirdPartyService(input: $input) {
|
||||||
thirdPartyService {
|
thirdPartyService {
|
||||||
...ThirdPartyServicesTabFragment_service
|
...ThirdPartyServiceRow_service
|
||||||
|
...EditServiceDialog_service
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function EditServiceDialog({ serviceId, service, onClose }: Props) {
|
export function EditServiceDialog({ serviceKey, onClose }: Props) {
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const service = useFragment(editServiceDialogFragment, serviceKey);
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z.string().min(1, __("Service name is required")),
|
name: z.string().min(1, __("Service name is required")),
|
||||||
@@ -67,27 +76,42 @@ export function EditServiceDialog({ serviceId, service, onClose }: Props) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const [updateService, isLoading] = useMutationWithToasts(
|
const [updateService, isUpdating]
|
||||||
updateServiceMutation,
|
= useMutation<EditServiceDialogUpdateMutation>(updateServiceMutation);
|
||||||
{
|
|
||||||
successMessage: __("Service updated successfully."),
|
|
||||||
errorMessage: __("Failed to update service"),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||||
const cleanData = cleanFormData(data);
|
const cleanData = cleanFormData(data);
|
||||||
|
|
||||||
await updateService({
|
updateService({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
id: serviceId,
|
id: service.id,
|
||||||
...cleanData,
|
...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();
|
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>
|
</DialogContent>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button type="submit" disabled={isLoading}>
|
<Button type="submit" disabled={isUpdating}>
|
||||||
{__("Save")}
|
{__("Save")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</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
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { formatError } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -23,13 +24,14 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Spinner,
|
Spinner,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { graphql } from "react-relay";
|
import { graphql, useMutation } from "react-relay";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { UploadBusinessAssociateAgreementDialogMutation } from "#/__generated__/core/UploadBusinessAssociateAgreementDialogMutation.graphql";
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
|
||||||
|
|
||||||
const uploadBusinessAssociateAgreementMutation = graphql`
|
const uploadBusinessAssociateAgreementMutation = graphql`
|
||||||
mutation UploadBusinessAssociateAgreementDialogMutation(
|
mutation UploadBusinessAssociateAgreementDialogMutation(
|
||||||
@@ -74,7 +76,7 @@ export function UploadBusinessAssociateAgreementDialog({
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors },
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
} = useFormWithSchema(schema, {
|
} = useFormWithSchema(schema, {
|
||||||
@@ -85,10 +87,11 @@ export function UploadBusinessAssociateAgreementDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [mutate] = useMutationWithToasts(uploadBusinessAssociateAgreementMutation, {
|
const { toast } = useToast();
|
||||||
successMessage: __("Business Associate Agreement uploaded successfully"),
|
const [uploadAgreement, isUploading]
|
||||||
errorMessage: __("Failed to upload Business Associate Agreement"),
|
= useMutation<UploadBusinessAssociateAgreementDialogMutation>(
|
||||||
});
|
uploadBusinessAssociateAgreementMutation,
|
||||||
|
);
|
||||||
|
|
||||||
const handleDrop = (files: File[]) => {
|
const handleDrop = (files: File[]) => {
|
||||||
if (files.length > 0) {
|
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) {
|
if (!uploadedFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -108,7 +111,7 @@ export function UploadBusinessAssociateAgreementDialog({
|
|||||||
return `${dateString}T00:00:00Z`;
|
return `${dateString}T00:00:00Z`;
|
||||||
};
|
};
|
||||||
|
|
||||||
await mutate({
|
uploadAgreement({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
thirdPartyId,
|
thirdPartyId,
|
||||||
@@ -121,12 +124,39 @@ export function UploadBusinessAssociateAgreementDialog({
|
|||||||
uploadables: {
|
uploadables: {
|
||||||
"input.file": uploadedFile,
|
"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 = () => {
|
const handleClose = () => {
|
||||||
@@ -146,7 +176,7 @@ export function UploadBusinessAssociateAgreementDialog({
|
|||||||
<DialogContent padded className="space-y-4">
|
<DialogContent padded className="space-y-4">
|
||||||
<Dropzone
|
<Dropzone
|
||||||
description={__("Only PDF files up to 10MB are allowed")}
|
description={__("Only PDF files up to 10MB are allowed")}
|
||||||
isUploading={isSubmitting}
|
isUploading={isUploading}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
accept={{
|
accept={{
|
||||||
"application/pdf": [".pdf"],
|
"application/pdf": [".pdf"],
|
||||||
@@ -186,8 +216,8 @@ export function UploadBusinessAssociateAgreementDialog({
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || !uploadedFile}
|
disabled={isUploading || !uploadedFile}
|
||||||
icon={isSubmitting ? Spinner : undefined}
|
icon={isUploading ? Spinner : undefined}
|
||||||
>
|
>
|
||||||
{__("Upload")}
|
{__("Upload")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import { formatDatetime, todayAsDateInput } from "@probo/helpers";
|
import { formatDatetime, formatError, todayAsDateInput } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -24,13 +24,14 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Spinner,
|
Spinner,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { graphql } from "react-relay";
|
import { graphql, useMutation } from "react-relay";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { UploadComplianceReportDialogMutation } from "#/__generated__/core/UploadComplianceReportDialogMutation.graphql";
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
|
||||||
|
|
||||||
const uploadComplianceReportMutation = graphql`
|
const uploadComplianceReportMutation = graphql`
|
||||||
mutation UploadComplianceReportDialogMutation(
|
mutation UploadComplianceReportDialogMutation(
|
||||||
@@ -82,7 +83,6 @@ export function UploadComplianceReportDialog({
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { isSubmitting },
|
|
||||||
reset,
|
reset,
|
||||||
} = useFormWithSchema(schema, {
|
} = useFormWithSchema(schema, {
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -91,10 +91,11 @@ export function UploadComplianceReportDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [mutate] = useMutationWithToasts(uploadComplianceReportMutation, {
|
const { toast } = useToast();
|
||||||
successMessage: __("Compliance report uploaded successfully"),
|
const [uploadComplianceReport, isUploading]
|
||||||
errorMessage: __("Failed to upload compliance report"),
|
= useMutation<UploadComplianceReportDialogMutation>(
|
||||||
});
|
uploadComplianceReportMutation,
|
||||||
|
);
|
||||||
|
|
||||||
const handleDrop = (files: File[]) => {
|
const handleDrop = (files: File[]) => {
|
||||||
if (files.length > 0) {
|
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) {
|
if (!uploadedFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await mutate({
|
uploadComplianceReport({
|
||||||
variables: {
|
variables: {
|
||||||
connections: [connectionId],
|
connections: [connectionId],
|
||||||
input: {
|
input: {
|
||||||
@@ -121,12 +122,39 @@ export function UploadComplianceReportDialog({
|
|||||||
uploadables: {
|
uploadables: {
|
||||||
"input.file": uploadedFile,
|
"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 = () => {
|
const handleClose = () => {
|
||||||
@@ -146,7 +174,7 @@ export function UploadComplianceReportDialog({
|
|||||||
<DialogContent padded className="space-y-4">
|
<DialogContent padded className="space-y-4">
|
||||||
<Dropzone
|
<Dropzone
|
||||||
description={__("Only PDF files up to 10MB are allowed")}
|
description={__("Only PDF files up to 10MB are allowed")}
|
||||||
isUploading={isSubmitting}
|
isUploading={isUploading}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
accept={{
|
accept={{
|
||||||
"application/pdf": [".pdf"],
|
"application/pdf": [".pdf"],
|
||||||
@@ -177,8 +205,8 @@ export function UploadComplianceReportDialog({
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || !uploadedFile}
|
disabled={isUploading || !uploadedFile}
|
||||||
icon={isSubmitting ? Spinner : undefined}
|
icon={isUploading ? Spinner : undefined}
|
||||||
>
|
>
|
||||||
{__("Upload")}
|
{__("Upload")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
import { formatError } from "@probo/helpers";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -23,13 +24,14 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Spinner,
|
Spinner,
|
||||||
useDialogRef,
|
useDialogRef,
|
||||||
|
useToast,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { graphql } from "react-relay";
|
import { graphql, useMutation } from "react-relay";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import type { UploadDataPrivacyAgreementDialogMutation } from "#/__generated__/core/UploadDataPrivacyAgreementDialogMutation.graphql";
|
||||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
|
||||||
|
|
||||||
const uploadDataPrivacyAgreementMutation = graphql`
|
const uploadDataPrivacyAgreementMutation = graphql`
|
||||||
mutation UploadDataPrivacyAgreementDialogMutation(
|
mutation UploadDataPrivacyAgreementDialogMutation(
|
||||||
@@ -74,7 +76,7 @@ export function UploadDataPrivacyAgreementDialog({
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors },
|
||||||
reset,
|
reset,
|
||||||
setValue,
|
setValue,
|
||||||
} = useFormWithSchema(schema, {
|
} = useFormWithSchema(schema, {
|
||||||
@@ -85,10 +87,11 @@ export function UploadDataPrivacyAgreementDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [mutate] = useMutationWithToasts(uploadDataPrivacyAgreementMutation, {
|
const { toast } = useToast();
|
||||||
successMessage: __("Data Privacy Agreement uploaded successfully"),
|
const [uploadAgreement, isUploading]
|
||||||
errorMessage: __("Failed to upload Data Privacy Agreement"),
|
= useMutation<UploadDataPrivacyAgreementDialogMutation>(
|
||||||
});
|
uploadDataPrivacyAgreementMutation,
|
||||||
|
);
|
||||||
|
|
||||||
const handleDrop = (files: File[]) => {
|
const handleDrop = (files: File[]) => {
|
||||||
if (files.length > 0) {
|
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) {
|
if (!uploadedFile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -108,7 +111,7 @@ export function UploadDataPrivacyAgreementDialog({
|
|||||||
return `${dateString}T00:00:00Z`;
|
return `${dateString}T00:00:00Z`;
|
||||||
};
|
};
|
||||||
|
|
||||||
await mutate({
|
uploadAgreement({
|
||||||
variables: {
|
variables: {
|
||||||
input: {
|
input: {
|
||||||
thirdPartyId,
|
thirdPartyId,
|
||||||
@@ -121,12 +124,39 @@ export function UploadDataPrivacyAgreementDialog({
|
|||||||
uploadables: {
|
uploadables: {
|
||||||
"input.file": uploadedFile,
|
"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 = () => {
|
const handleClose = () => {
|
||||||
@@ -146,7 +176,7 @@ export function UploadDataPrivacyAgreementDialog({
|
|||||||
<DialogContent padded className="space-y-4">
|
<DialogContent padded className="space-y-4">
|
||||||
<Dropzone
|
<Dropzone
|
||||||
description={__("Only PDF files up to 10MB are allowed")}
|
description={__("Only PDF files up to 10MB are allowed")}
|
||||||
isUploading={isSubmitting}
|
isUploading={isUploading}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
accept={{
|
accept={{
|
||||||
"application/pdf": [".pdf"],
|
"application/pdf": [".pdf"],
|
||||||
@@ -186,8 +216,8 @@ export function UploadDataPrivacyAgreementDialog({
|
|||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting || !uploadedFile}
|
disabled={isUploading || !uploadedFile}
|
||||||
icon={isSubmitting ? Spinner : undefined}
|
icon={isUploading ? Spinner : undefined}
|
||||||
>
|
>
|
||||||
{__("Upload")}
|
{__("Upload")}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -44,8 +44,8 @@ const vetMutation = graphql`
|
|||||||
websiteUrl
|
websiteUrl
|
||||||
vettingStatus
|
vettingStatus
|
||||||
...useThirdPartyFormFragment
|
...useThirdPartyFormFragment
|
||||||
...ThirdPartyComplianceTabFragment
|
...ThirdPartyCompliancePageFragment
|
||||||
...ThirdPartyRiskAssessmentTabFragment
|
...ThirdPartyRiskAssessmentPageFragment
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
certifications,
|
certifications,
|
||||||
objectEntries,
|
objectEntries,
|
||||||
} from "@probo/helpers";
|
} from "@probo/helpers";
|
||||||
|
import { usePageTitle } from "@probo/hooks";
|
||||||
import { useTranslate } from "@probo/i18n";
|
import { useTranslate } from "@probo/i18n";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -30,21 +31,42 @@ import {
|
|||||||
import { clsx } from "clsx";
|
import { clsx } from "clsx";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Controller } from "react-hook-form";
|
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";
|
import { useThirdPartyForm } from "#/hooks/forms/useThirdPartyForm";
|
||||||
|
|
||||||
/**
|
export const thirdPartyCertificationsPageQuery = graphql`
|
||||||
* ThirdParty certifications tab
|
query ThirdPartyCertificationsPageQuery($thirdPartyId: ID!) {
|
||||||
*/
|
node(id: $thirdPartyId) {
|
||||||
export default function ThirdPartyCertificationsTab() {
|
__typename
|
||||||
const { thirdParty } = useOutletContext<{
|
... on ThirdParty {
|
||||||
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
|
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 { __ } = useTranslate();
|
||||||
const { control, handleSubmit } = useThirdPartyForm(thirdParty);
|
const { control, handleSubmit } = useThirdPartyForm(thirdParty);
|
||||||
|
|
||||||
|
usePageTitle(thirdParty.name + " - " + __("Certifications"));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
@@ -74,15 +96,12 @@ export default function ThirdPartyCertificationsTab() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type CertificationsProps = {
|
interface CertificationsProps {
|
||||||
value: string[];
|
value: string[];
|
||||||
onValueChange: (value: string[]) => void;
|
onValueChange: (value: string[]) => void;
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
};
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* List all certifications badges
|
|
||||||
*/
|
|
||||||
function Certifications(props: CertificationsProps) {
|
function Certifications(props: CertificationsProps) {
|
||||||
const categorizedCertifications = Object.values(certifications).flat();
|
const categorizedCertifications = Object.values(certifications).flat();
|
||||||
const { __ } = useTranslate();
|
const { __ } = useTranslate();
|
||||||
@@ -92,7 +111,7 @@ function Certifications(props: CertificationsProps) {
|
|||||||
([key, value]) =>
|
([key, value]) =>
|
||||||
[key, value.filter(c => props.value.includes(c))] as const,
|
[key, value.filter(c => props.value.includes(c))] as const,
|
||||||
)
|
)
|
||||||
.filter(([, certifications]) => certifications.length > 0);
|
.filter(([, certs]) => certs.length > 0);
|
||||||
categories.push([
|
categories.push([
|
||||||
"custom",
|
"custom",
|
||||||
props.value.filter(c => !categorizedCertifications.includes(c)),
|
props.value.filter(c => !categorizedCertifications.includes(c)),
|
||||||
@@ -110,13 +129,13 @@ function Certifications(props: CertificationsProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{categories.map(([key, certifications]) => (
|
{categories.map(([key, certs]) => (
|
||||||
<div key={key} className="space-y-2">
|
<div key={key} className="space-y-2">
|
||||||
<div className="text-sm font-medium text-txt-secondary">
|
<div className="text-sm font-medium text-txt-secondary">
|
||||||
{certificationCategoryLabel(__, key)}
|
{certificationCategoryLabel(__, key)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{certifications.map(certification => (
|
{certs.map(certification => (
|
||||||
<Badge asChild size="md" key={certification}>
|
<Badge asChild size="md" key={certification}>
|
||||||
{props.readOnly
|
{props.readOnly
|
||||||
? (
|
? (
|
||||||
@@ -155,9 +174,6 @@ function Certifications(props: CertificationsProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Input to add a new certification
|
|
||||||
*/
|
|
||||||
function CertificationInput({
|
function CertificationInput({
|
||||||
certifications,
|
certifications,
|
||||||
onAdd,
|
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
|
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
// PERFORMANCE OF THIS SOFTWARE.
|
// PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||||
import { useOutletContext, useParams } from "react-router";
|
|
||||||
|
|
||||||
import type { ThirdPartyMeasuresPageFragment$key } from "#/__generated__/core/ThirdPartyMeasuresPageFragment.graphql";
|
import type { ThirdPartyMeasuresPageQuery } from "#/__generated__/core/ThirdPartyMeasuresPageQuery.graphql";
|
||||||
import { LinkedMeasuresCard } from "#/components/measures/LinkedMeasuresCard";
|
import { LinkedMeasuresCard } from "#/components/measures/LinkedMeasuresCard";
|
||||||
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
|
import { useMutationWithIncrement } from "#/hooks/useMutationWithIncrement";
|
||||||
|
|
||||||
export const measuresFragment = graphql`
|
export const thirdPartyMeasuresPageQuery = graphql`
|
||||||
fragment ThirdPartyMeasuresPageFragment on ThirdParty {
|
query ThirdPartyMeasuresPageQuery($thirdPartyId: ID!) {
|
||||||
id
|
node(id: $thirdPartyId) {
|
||||||
canCreateMeasureThirdPartyMapping: permission(
|
__typename
|
||||||
action: "core:measure:create-third-party-mapping"
|
... on ThirdParty {
|
||||||
)
|
id
|
||||||
canDeleteMeasureThirdPartyMapping: permission(
|
canCreateMeasureThirdPartyMapping: permission(
|
||||||
action: "core:measure:delete-third-party-mapping"
|
action: "core:measure:create-third-party-mapping"
|
||||||
)
|
)
|
||||||
measures(first: 100) @connection(key: "ThirdPartyMeasuresPage_measures") {
|
canDeleteMeasureThirdPartyMapping: permission(
|
||||||
__id
|
action: "core:measure:delete-third-party-mapping"
|
||||||
edges {
|
)
|
||||||
node {
|
measures(first: 100) @connection(key: "ThirdPartyMeasuresPage_measures") {
|
||||||
id
|
__id
|
||||||
...LinkedMeasuresCardFragment
|
edges {
|
||||||
|
node {
|
||||||
|
id
|
||||||
|
...LinkedMeasuresCardFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -67,24 +71,26 @@ const detachMeasureMutation = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function ThirdPartyMeasuresPage() {
|
interface ThirdPartyMeasuresPageProps {
|
||||||
const { thirdPartyId } = useParams<{ thirdPartyId: string }>();
|
queryRef: PreloadedQuery<ThirdPartyMeasuresPageQuery>;
|
||||||
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) ?? [];
|
|
||||||
|
|
||||||
const canLink = data.canCreateMeasureThirdPartyMapping;
|
export default function ThirdPartyMeasuresPage(props: ThirdPartyMeasuresPageProps) {
|
||||||
const canUnlink = data.canDeleteMeasureThirdPartyMapping;
|
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 readOnly = !canLink && !canUnlink;
|
||||||
|
|
||||||
const incrementOptions = {
|
const incrementOptions = {
|
||||||
id: data.id,
|
id: thirdParty.id,
|
||||||
node: "measures(first:0)",
|
node: "measures(first:0)",
|
||||||
};
|
};
|
||||||
const [detachMeasure, isDetaching] = useMutationWithIncrement(
|
const [detachMeasure, isDetaching] = useMutationWithIncrement(
|
||||||
@@ -109,7 +115,7 @@ export default function ThirdPartyMeasuresPage() {
|
|||||||
measures={measures}
|
measures={measures}
|
||||||
onAttach={attachMeasure}
|
onAttach={attachMeasure}
|
||||||
onDetach={detachMeasure}
|
onDetach={detachMeasure}
|
||||||
params={{ thirdPartyId: data.id }}
|
params={{ thirdPartyId: thirdParty.id }}
|
||||||
connectionId={connectionId}
|
connectionId={connectionId}
|
||||||
readOnly={readOnly}
|
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,
|
Option,
|
||||||
} from "@probo/ui";
|
} from "@probo/ui";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { graphql, useFragment } from "react-relay";
|
import { graphql, type PreloadedQuery, useFragment, usePreloadedQuery } from "react-relay";
|
||||||
import { useOutletContext } from "react-router";
|
|
||||||
|
|
||||||
import type { ThirdPartyGraphNodeQuery$data } from "#/__generated__/core/ThirdPartyGraphNodeQuery.graphql";
|
import type { ThirdPartyOverviewPageBusinessAssociateAgreementFragment$key } from "#/__generated__/core/ThirdPartyOverviewPageBusinessAssociateAgreementFragment.graphql";
|
||||||
import type { ThirdPartyOverviewTabBusinessAssociateAgreementFragment$key } from "#/__generated__/core/ThirdPartyOverviewTabBusinessAssociateAgreementFragment.graphql";
|
import type { ThirdPartyOverviewPageDataPrivacyAgreementFragment$key } from "#/__generated__/core/ThirdPartyOverviewPageDataPrivacyAgreementFragment.graphql";
|
||||||
import type { ThirdPartyOverviewTabDataPrivacyAgreementFragment$key } from "#/__generated__/core/ThirdPartyOverviewTabDataPrivacyAgreementFragment.graphql";
|
import type { ThirdPartyOverviewPageQuery } from "#/__generated__/core/ThirdPartyOverviewPageQuery.graphql";
|
||||||
import type { ThirdPartyCategory } from "#/__generated__/core/useThirdPartyFormFragment.graphql";
|
import type { ThirdPartyCategory } from "#/__generated__/core/useThirdPartyFormFragment.graphql";
|
||||||
import { ControlledField } from "#/components/form/ControlledField";
|
import { ControlledField } from "#/components/form/ControlledField";
|
||||||
import { CountriesField } from "#/components/form/CountriesField";
|
import { CountriesField } from "#/components/form/CountriesField";
|
||||||
@@ -39,15 +38,15 @@ import { PeopleSelectField } from "#/components/form/PeopleSelectField";
|
|||||||
import { useThirdPartyForm } from "#/hooks/forms/useThirdPartyForm";
|
import { useThirdPartyForm } from "#/hooks/forms/useThirdPartyForm";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
import { DeleteBusinessAssociateAgreementDialog } from "../dialogs/DeleteBusinessAssociateAgreementDialog";
|
import { DeleteBusinessAssociateAgreementDialog } from "../_components/DeleteBusinessAssociateAgreementDialog";
|
||||||
import { DeleteDataPrivacyAgreementDialog } from "../dialogs/DeleteDataPrivacyAgreementDialog";
|
import { DeleteDataPrivacyAgreementDialog } from "../_components/DeleteDataPrivacyAgreementDialog";
|
||||||
import { EditBusinessAssociateAgreementDialog } from "../dialogs/EditBusinessAssociateAgreementDialog";
|
import { EditBusinessAssociateAgreementDialog } from "../_components/EditBusinessAssociateAgreementDialog";
|
||||||
import { EditDataPrivacyAgreementDialog } from "../dialogs/EditDataPrivacyAgreementDialog";
|
import { EditDataPrivacyAgreementDialog } from "../_components/EditDataPrivacyAgreementDialog";
|
||||||
import { UploadBusinessAssociateAgreementDialog } from "../dialogs/UploadBusinessAssociateAgreementDialog";
|
import { UploadBusinessAssociateAgreementDialog } from "../_components/UploadBusinessAssociateAgreementDialog";
|
||||||
import { UploadDataPrivacyAgreementDialog } from "../dialogs/UploadDataPrivacyAgreementDialog";
|
import { UploadDataPrivacyAgreementDialog } from "../_components/UploadDataPrivacyAgreementDialog";
|
||||||
|
|
||||||
const thirdPartyBusinessAssociateAgreementFragment = graphql`
|
const thirdPartyBusinessAssociateAgreementFragment = graphql`
|
||||||
fragment ThirdPartyOverviewTabBusinessAssociateAgreementFragment on ThirdParty {
|
fragment ThirdPartyOverviewPageBusinessAssociateAgreementFragment on ThirdParty {
|
||||||
businessAssociateAgreement {
|
businessAssociateAgreement {
|
||||||
id
|
id
|
||||||
file {
|
file {
|
||||||
@@ -67,7 +66,7 @@ const thirdPartyBusinessAssociateAgreementFragment = graphql`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const thirdPartyDataPrivacyAgreementFragment = graphql`
|
const thirdPartyDataPrivacyAgreementFragment = graphql`
|
||||||
fragment ThirdPartyOverviewTabDataPrivacyAgreementFragment on ThirdParty {
|
fragment ThirdPartyOverviewPageDataPrivacyAgreementFragment on ThirdParty {
|
||||||
dataPrivacyAgreement {
|
dataPrivacyAgreement {
|
||||||
id
|
id
|
||||||
file {
|
file {
|
||||||
@@ -82,10 +81,38 @@ const thirdPartyDataPrivacyAgreementFragment = graphql`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function ThirdPartyOverviewTab() {
|
export const thirdPartyOverviewPageQuery = graphql`
|
||||||
const { thirdParty } = useOutletContext<{
|
query ThirdPartyOverviewPageQuery($thirdPartyId: ID!) {
|
||||||
thirdParty: ThirdPartyGraphNodeQuery$data["node"];
|
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 { __ } = useTranslate();
|
||||||
const thirdPartyCategories: { value: ThirdPartyCategory; label: string }[] = [
|
const thirdPartyCategories: { value: ThirdPartyCategory; label: string }[] = [
|
||||||
@@ -125,14 +152,14 @@ export default function ThirdPartyOverviewTab() {
|
|||||||
} = useThirdPartyForm(thirdParty);
|
} = useThirdPartyForm(thirdParty);
|
||||||
|
|
||||||
const thirdPartyWithBAA
|
const thirdPartyWithBAA
|
||||||
= useFragment<ThirdPartyOverviewTabBusinessAssociateAgreementFragment$key>(
|
= useFragment<ThirdPartyOverviewPageBusinessAssociateAgreementFragment$key>(
|
||||||
thirdPartyBusinessAssociateAgreementFragment,
|
thirdPartyBusinessAssociateAgreementFragment,
|
||||||
thirdParty,
|
thirdParty,
|
||||||
);
|
);
|
||||||
const businessAssociateAgreement = thirdPartyWithBAA.businessAssociateAgreement;
|
const businessAssociateAgreement = thirdPartyWithBAA.businessAssociateAgreement;
|
||||||
|
|
||||||
const thirdPartyWithDPA
|
const thirdPartyWithDPA
|
||||||
= useFragment<ThirdPartyOverviewTabDataPrivacyAgreementFragment$key>(
|
= useFragment<ThirdPartyOverviewPageDataPrivacyAgreementFragment$key>(
|
||||||
thirdPartyDataPrivacyAgreementFragment,
|
thirdPartyDataPrivacyAgreementFragment,
|
||||||
thirdParty,
|
thirdParty,
|
||||||
);
|
);
|
||||||
@@ -169,7 +196,6 @@ export default function ThirdPartyOverviewTab() {
|
|||||||
: e => void handleSubmit(e)}
|
: e => void handleSubmit(e)}
|
||||||
className="space-y-12"
|
className="space-y-12"
|
||||||
>
|
>
|
||||||
{/* ThirdParty Details */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-base font-medium">{__("Third party details")}</h2>
|
<h2 className="text-base font-medium">{__("Third party details")}</h2>
|
||||||
<Card className="space-y-4" padded>
|
<Card className="space-y-4" padded>
|
||||||
@@ -237,7 +263,6 @@ export default function ThirdPartyOverviewTab() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Ownership */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-base font-medium">{__("Ownership details")}</h2>
|
<h2 className="text-base font-medium">{__("Ownership details")}</h2>
|
||||||
<Card className="space-y-4" padded>
|
<Card className="space-y-4" padded>
|
||||||
@@ -262,7 +287,6 @@ export default function ThirdPartyOverviewTab() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Links */}
|
|
||||||
<div className="space-y-4 mb-4">
|
<div className="space-y-4 mb-4">
|
||||||
<h2 className="text-base font-medium">{__("Links")}</h2>
|
<h2 className="text-base font-medium">{__("Links")}</h2>
|
||||||
<Card className="divide-y divide-border-low">
|
<Card className="divide-y divide-border-low">
|
||||||
@@ -292,7 +316,6 @@ export default function ThirdPartyOverviewTab() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Data agreements */}
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-base font-medium">{__("Data agreements")}</h2>
|
<h2 className="text-base font-medium">{__("Data agreements")}</h2>
|
||||||
<Card className="space-y-4" padded>
|
<Card className="space-y-4" padded>
|
||||||
@@ -446,7 +469,6 @@ export default function ThirdPartyOverviewTab() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit */}
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
{thirdParty.canUpdate && (
|
{thirdParty.canUpdate && (
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
<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 { SortableTable, SortableTh } from "#/components/SortableTable";
|
||||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||||
|
|
||||||
import { AddChildThirdPartyDialog } from "../dialogs/AddChildThirdPartyDialog";
|
import { AddChildThirdPartyDialog } from "../_components/AddChildThirdPartyDialog";
|
||||||
|
|
||||||
// Keep in sync with coredata.MaxThirdPartyLevel on the backend.
|
// Keep in sync with coredata.MaxThirdPartyLevel on the backend.
|
||||||
const MAX_THIRD_PARTY_LEVEL = 4;
|
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 { cookieBannerRoutes } from "./pages/organizations/cookie-banners/routes";
|
||||||
import { riskAssessmentRoutes } from "./pages/organizations/risk-assessments/routes";
|
import { riskAssessmentRoutes } from "./pages/organizations/risk-assessments/routes";
|
||||||
import { riskRoutes } from "./pages/organizations/risks/routes";
|
import { riskRoutes } from "./pages/organizations/risks/routes";
|
||||||
|
import { thirdPartyRoutes } from "./pages/organizations/third-parties/routes";
|
||||||
import { CurrentUser } from "./providers/CurrentUser";
|
import { CurrentUser } from "./providers/CurrentUser";
|
||||||
import { accessReviewRoutes } from "./routes/accessReviewRoutes";
|
import { accessReviewRoutes } from "./routes/accessReviewRoutes";
|
||||||
import { assetRoutes } from "./routes/assetRoutes";
|
import { assetRoutes } from "./routes/assetRoutes";
|
||||||
@@ -48,7 +49,6 @@ import { processingActivityRoutes } from "./routes/processingActivityRoutes";
|
|||||||
import { rightsRequestRoutes } from "./routes/rightsRequestRoutes";
|
import { rightsRequestRoutes } from "./routes/rightsRequestRoutes";
|
||||||
import { statementsOfApplicabilityRoutes } from "./routes/statementsOfApplicabilityRoutes";
|
import { statementsOfApplicabilityRoutes } from "./routes/statementsOfApplicabilityRoutes";
|
||||||
import { taskRoutes } from "./routes/taskRoutes";
|
import { taskRoutes } from "./routes/taskRoutes";
|
||||||
import { thirdPartyRoutes } from "./routes/thirdPartyRoutes";
|
|
||||||
|
|
||||||
const routes = [
|
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