Redesign document approval flow
Replace the per-approver add/remove model with a quorum-based approval system. Documents now have default approvers that are pre-populated when requesting approval, and the publish dialog lets users adjust the list before submitting. Key changes: - Add PENDING_APPROVAL document version status with dedicated transitions - Introduce approval quorums with request/approve/reject/void lifecycle - Add default approvers per document (stored in document_default_approvers) with MERGE-based upsert for efficient sync - Add NoDuplicates validator for slice fields - Split ALTER TYPE ADD VALUE migrations into separate files (required by PostgreSQL when run inside transactions) - Use VOIDED consistently for both quorum status and decision state enums - Expose void/approve/reject through GraphQL and MCP, with e2e tests - Add approval management UI: publish dialog with approver selection, approval list with void support, and external approve/reject page Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -22,7 +22,7 @@ import { graphql } from "relay-runtime";
|
||||
import type { DocumentLayoutQuery } from "#/__generated__/core/DocumentLayoutQuery.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { DocumentActionsDropdownn } from "./_components/DocumentActionsDropdown";
|
||||
import { DocumentActionsDropdown } from "./_components/DocumentActionsDropdown";
|
||||
import { DocumentLayoutDrawer } from "./_components/DocumentLayoutDrawer";
|
||||
import { DocumentTitleForm } from "./_components/DocumentTitleForm";
|
||||
import { DocumentVersionsDropdown } from "./_components/DocumentVersionsDropdown";
|
||||
@@ -142,7 +142,6 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
const isPublished = currentVersion.status === "PUBLISHED";
|
||||
const lastQuorum = currentVersion.approvalQuorums?.edges?.[0]?.node ?? null;
|
||||
const hasApprovals = lastQuorum != null;
|
||||
const hasPendingApproval = lastQuorum?.status === "PENDING";
|
||||
|
||||
const urlPrefix = versionId
|
||||
? `/organizations/${organizationId}/documents/${document.id}/versions/${versionId}`
|
||||
@@ -174,7 +173,7 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
</Button>
|
||||
)}
|
||||
<DocumentVersionsDropdown />
|
||||
<DocumentActionsDropdownn
|
||||
<DocumentActionsDropdown
|
||||
documentFragmentRef={document}
|
||||
versionFragmentRef={currentVersion}
|
||||
onRefetch={onRefetch}
|
||||
@@ -223,7 +222,6 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
ref={publishDialogRef}
|
||||
documentId={document.id}
|
||||
documentFragmentRef={document}
|
||||
hasPendingApproval={hasPendingApproval}
|
||||
onSuccess={handlePublishOrApproval}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Badge,
|
||||
@@ -24,8 +25,10 @@ import {
|
||||
Label,
|
||||
PropertyRow,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode } from "react";
|
||||
import { useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -33,11 +36,11 @@ import type { CreateDocumentDialogMutation } from "#/__generated__/core/CreateDo
|
||||
import { ControlledField } from "#/components/form/ControlledField";
|
||||
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
|
||||
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
|
||||
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
type Props = {
|
||||
type CreateDocumentDialogProps = {
|
||||
trigger?: ReactNode;
|
||||
connection: string;
|
||||
};
|
||||
@@ -68,14 +71,16 @@ const documentSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
documentType: z.enum(["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"]),
|
||||
classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
|
||||
defaultApproverIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
/**
|
||||
* Dialog to create or update a document
|
||||
*/
|
||||
export function CreateDocumentDialog({ trigger, connection }: Props) {
|
||||
export function CreateDocumentDialog({ trigger, connection }: CreateDocumentDialogProps) {
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
const { toast } = useToast();
|
||||
|
||||
const { control, handleSubmit, register, formState, reset } = useFormWithSchema(
|
||||
documentSchema,
|
||||
@@ -83,15 +88,16 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
|
||||
defaultValues: {
|
||||
documentType: "POLICY",
|
||||
classification: "INTERNAL",
|
||||
defaultApproverIds: [],
|
||||
},
|
||||
},
|
||||
);
|
||||
const errors = formState.errors ?? {};
|
||||
const [createDocument, isLoading]
|
||||
= useMutationWithToasts<CreateDocumentDialogMutation>(createDocumentMutation);
|
||||
= useMutation<CreateDocumentDialogMutation>(createDocumentMutation);
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof documentSchema>) => {
|
||||
await createDocument({
|
||||
const onSubmit = (data: z.infer<typeof documentSchema>) => {
|
||||
createDocument({
|
||||
variables: {
|
||||
input: {
|
||||
...data,
|
||||
@@ -99,12 +105,18 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
|
||||
},
|
||||
connections: [connection],
|
||||
},
|
||||
successMessage: __("Document created successfully."),
|
||||
errorMessage: __("Failed to create document"),
|
||||
onSuccess: () => {
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({ title: __("Error"), description: formatError(__("Failed to create document"), errors), variant: "error" });
|
||||
return;
|
||||
}
|
||||
toast({ title: __("Success"), description: __("Document created successfully."), variant: "success" });
|
||||
dialogRef.current?.close();
|
||||
reset();
|
||||
},
|
||||
onError(error) {
|
||||
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -165,6 +177,15 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
|
||||
</ControlledField>
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyRow label={__("Approvers")}>
|
||||
<PeopleMultiSelectField
|
||||
name="defaultApproverIds"
|
||||
control={control}
|
||||
organizationId={organizationId}
|
||||
placeholder={__("Add approvers...")}
|
||||
/>
|
||||
</PropertyRow>
|
||||
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -23,12 +23,11 @@ import { ConnectionHandler, graphql } from "relay-runtime";
|
||||
import type { DocumentActionsDropdown_archiveMutation } from "#/__generated__/core/DocumentActionsDropdown_archiveMutation.graphql";
|
||||
import type { DocumentActionsDropdown_createDraftMutation } from "#/__generated__/core/DocumentActionsDropdown_createDraftMutation.graphql";
|
||||
import type { DocumentActionsDropdown_documentFragment$key } from "#/__generated__/core/DocumentActionsDropdown_documentFragment.graphql";
|
||||
import type { DocumentActionsDropdown_exportVersionMutation } from "#/__generated__/core/DocumentActionsDropdown_exportVersionMutation.graphql";
|
||||
import type { DocumentActionsDropdown_unarchiveMutation } from "#/__generated__/core/DocumentActionsDropdown_unarchiveMutation.graphql";
|
||||
import type { DocumentActionsDropdown_versionFragment$key } from "#/__generated__/core/DocumentActionsDropdown_versionFragment.graphql";
|
||||
import type { DocumentActionsDropdownn_exportVersionMutation } from "#/__generated__/core/DocumentActionsDropdownn_exportVersionMutation.graphql";
|
||||
import { PdfDownloadDialog, type PdfDownloadDialogRef } from "#/components/documents/PdfDownloadDialog";
|
||||
import { DocumentsConnectionKey, useDeleteDocumentMutation, useDeleteDraftDocumentVersionMutation } from "#/hooks/graph/DocumentGraph";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import { CurrentUser } from "#/providers/CurrentUser";
|
||||
|
||||
@@ -129,7 +128,7 @@ const versionFragment = graphql`
|
||||
`;
|
||||
|
||||
const exportDocumentVersionMutation = graphql`
|
||||
mutation DocumentActionsDropdownn_exportVersionMutation(
|
||||
mutation DocumentActionsDropdown_exportVersionMutation(
|
||||
$input: ExportDocumentVersionPDFInput!
|
||||
) {
|
||||
exportDocumentVersionPDF(input: $input) {
|
||||
@@ -138,7 +137,7 @@ const exportDocumentVersionMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
export function DocumentActionsDropdownn(props: {
|
||||
export function DocumentActionsDropdown(props: {
|
||||
documentFragmentRef: DocumentActionsDropdown_documentFragment$key;
|
||||
versionFragmentRef: DocumentActionsDropdown_versionFragment$key;
|
||||
onRefetch: () => void;
|
||||
@@ -158,7 +157,7 @@ export function DocumentActionsDropdownn(props: {
|
||||
const version = useFragment<DocumentActionsDropdown_versionFragment$key>(versionFragment, versionFragmentRef);
|
||||
|
||||
const lastVersion = document.versions.edges[0].node;
|
||||
const hasDraft = lastVersion.status === "DRAFT";
|
||||
const isLastVersionPublished = lastVersion.status === "PUBLISHED";
|
||||
const isDraft = version.status === "DRAFT";
|
||||
|
||||
const [createDraftDocumentVersion, isCreatingDraft]
|
||||
@@ -171,13 +170,7 @@ export function DocumentActionsDropdownn(props: {
|
||||
const [deleteDraftDocumentVersion, isDeletingDraft]
|
||||
= useDeleteDraftDocumentVersionMutation();
|
||||
const [exportDocumentVersion, isExporting]
|
||||
= useMutationWithToasts<DocumentActionsDropdownn_exportVersionMutation>(
|
||||
exportDocumentVersionMutation,
|
||||
{
|
||||
successMessage: __("PDF download started."),
|
||||
errorMessage: __("Failed to generate PDF"),
|
||||
},
|
||||
);
|
||||
= useMutation<DocumentActionsDropdown_exportVersionMutation>(exportDocumentVersionMutation);
|
||||
|
||||
const handleCreateDraft = () => {
|
||||
const connectionId = ConnectionHandler.getConnectionID(document.id, "DocumentversionsDropdownMenu_versions");
|
||||
@@ -205,7 +198,6 @@ export function DocumentActionsDropdownn(props: {
|
||||
void navigate(`/organizations/${organizationId}/documents/${document.id}/versions/${newVersionId}`);
|
||||
},
|
||||
onError(error) {
|
||||
console.log(error);
|
||||
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||
},
|
||||
});
|
||||
@@ -320,7 +312,7 @@ export function DocumentActionsDropdownn(props: {
|
||||
);
|
||||
};
|
||||
|
||||
const handleExportDocumentVersion = async (options: {
|
||||
const handleExportDocumentVersion = (options: {
|
||||
withWatermark: boolean;
|
||||
withSignatures: boolean;
|
||||
watermarkEmail?: string;
|
||||
@@ -333,10 +325,15 @@ export function DocumentActionsDropdownn(props: {
|
||||
&& options.watermarkEmail && { watermarkEmail: options.watermarkEmail }),
|
||||
};
|
||||
|
||||
await exportDocumentVersion({
|
||||
exportDocumentVersion({
|
||||
variables: { input },
|
||||
onCompleted: (data, errors) => {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: errors[0]?.message || __("Failed to generate PDF"),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -349,6 +346,9 @@ export function DocumentActionsDropdownn(props: {
|
||||
window.document.body.removeChild(link);
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -356,12 +356,12 @@ export function DocumentActionsDropdownn(props: {
|
||||
<>
|
||||
<PdfDownloadDialog
|
||||
ref={pdfDownloadDialogRef}
|
||||
onDownload={options => void handleExportDocumentVersion(options)}
|
||||
onDownload={handleExportDocumentVersion}
|
||||
isLoading={isExporting}
|
||||
defaultEmail={defaultEmail}
|
||||
/>
|
||||
<ActionDropdown variant="secondary">
|
||||
{document.canUpdate && !hasDraft && (
|
||||
{document.canUpdate && isLastVersionPublished && (
|
||||
<DropdownItem
|
||||
onClick={handleCreateDraft}
|
||||
icon={IconPencil}
|
||||
@@ -388,7 +388,7 @@ export function DocumentActionsDropdownn(props: {
|
||||
>
|
||||
{__("Download PDF")}
|
||||
</DropdownItem>
|
||||
{document.canArchive && (
|
||||
{document.canArchive && document.status === "ACTIVE" && (
|
||||
<DropdownItem
|
||||
icon={IconArchive}
|
||||
disabled={isArchiving}
|
||||
@@ -397,7 +397,7 @@ export function DocumentActionsDropdownn(props: {
|
||||
{__("Archive document")}
|
||||
</DropdownItem>
|
||||
)}
|
||||
{document.canUnarchive && (
|
||||
{document.canUnarchive && document.status === "ARCHIVED" && (
|
||||
<DropdownItem
|
||||
icon={IconArchive}
|
||||
disabled={isUnarchiving}
|
||||
|
||||
@@ -21,19 +21,27 @@ import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { DocumentLayoutDrawer_documentFragment$key } from "#/__generated__/core/DocumentLayoutDrawer_documentFragment.graphql";
|
||||
import type { DocumentLayoutDrawer_updateApproversMutation } from "#/__generated__/core/DocumentLayoutDrawer_updateApproversMutation.graphql";
|
||||
import type { DocumentLayoutDrawer_updateClassificationMutation } from "#/__generated__/core/DocumentLayoutDrawer_updateClassificationMutation.graphql";
|
||||
import type { DocumentLayoutDrawer_versionFragment$key } from "#/__generated__/core/DocumentLayoutDrawer_versionFragment.graphql";
|
||||
import type { DocumentLayoutDrawerMutation } from "#/__generated__/core/DocumentLayoutDrawerMutation.graphql";
|
||||
import { ControlledField } from "#/components/form/ControlledField";
|
||||
import { DocumentClassificationOptions } from "#/components/form/DocumentClassificationOptions";
|
||||
import { DocumentTypeOptions } from "#/components/form/DocumentTypeOptions";
|
||||
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
const documentFragment = graphql`
|
||||
fragment DocumentLayoutDrawer_documentFragment on Document {
|
||||
id
|
||||
status
|
||||
archivedAt
|
||||
canUpdate: permission(action: "core:document:update")
|
||||
defaultApprovers {
|
||||
id
|
||||
fullName
|
||||
emailAddress
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -72,6 +80,21 @@ const updateClassificationMutation = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const updateApproversMutation = graphql`
|
||||
mutation DocumentLayoutDrawer_updateApproversMutation($input: UpdateDocumentInput!) {
|
||||
updateDocument(input: $input) {
|
||||
document {
|
||||
id
|
||||
defaultApprovers {
|
||||
id
|
||||
fullName
|
||||
emailAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const schema = z.object({
|
||||
documentType: z.enum(documentTypes),
|
||||
});
|
||||
@@ -80,6 +103,10 @@ const classificationSchema = z.object({
|
||||
classification: z.enum(documentClassifications),
|
||||
});
|
||||
|
||||
const approversSchema = z.object({
|
||||
approverIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
export function DocumentLayoutDrawer(props: {
|
||||
documentFragmentRef: DocumentLayoutDrawer_documentFragment$key;
|
||||
versionFragmentRef: DocumentLayoutDrawer_versionFragment$key;
|
||||
@@ -87,9 +114,11 @@ export function DocumentLayoutDrawer(props: {
|
||||
const { documentFragmentRef, versionFragmentRef } = props;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
|
||||
const [isEditingType, setIsEditingType] = useState(false);
|
||||
const [isEditingClassification, setIsEditingClassification] = useState(false);
|
||||
const [isEditingApprovers, setIsEditingApprovers] = useState(false);
|
||||
|
||||
const { toast } = useToast();
|
||||
const document = useFragment<DocumentLayoutDrawer_documentFragment$key>(documentFragment, documentFragmentRef);
|
||||
@@ -120,12 +149,28 @@ export function DocumentLayoutDrawer(props: {
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
control: approversControl,
|
||||
handleSubmit: handleApproversSubmit,
|
||||
reset: resetApprovers,
|
||||
} = useFormWithSchema(
|
||||
approversSchema,
|
||||
{
|
||||
defaultValues: {
|
||||
approverIds: document.defaultApprovers.map(a => a.id),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const [updateDocumentType, isUpdatingDocumentType]
|
||||
= useMutation<DocumentLayoutDrawerMutation>(updateDocumentTypeMutation);
|
||||
|
||||
const [updateClassification, isUpdatingClassification]
|
||||
= useMutation<DocumentLayoutDrawer_updateClassificationMutation>(updateClassificationMutation);
|
||||
|
||||
const [updateApprovers, isUpdatingApprovers]
|
||||
= useMutation<DocumentLayoutDrawer_updateApproversMutation>(updateApproversMutation);
|
||||
|
||||
const handleUpdateDocumentType = (data: {
|
||||
documentType: (typeof documentTypes)[number];
|
||||
}) => {
|
||||
@@ -182,11 +227,74 @@ export function DocumentLayoutDrawer(props: {
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateApprovers = (data: { approverIds: string[] }) => {
|
||||
updateApprovers({
|
||||
variables: {
|
||||
input: {
|
||||
id: document.id,
|
||||
defaultApproverIds: data.approverIds,
|
||||
},
|
||||
},
|
||||
onCompleted: () => {
|
||||
setIsEditingApprovers(false);
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Approvers updated successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: __("Failed to update approvers"),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer>
|
||||
<div className="text-base text-txt-primary font-medium mb-4">
|
||||
{__("Properties")}
|
||||
</div>
|
||||
<PropertyRow label={__("Approvers")}>
|
||||
{isEditingApprovers
|
||||
? (
|
||||
<EditablePropertyContent
|
||||
onSave={() => void handleApproversSubmit(handleUpdateApprovers)()}
|
||||
onCancel={() => {
|
||||
setIsEditingApprovers(false);
|
||||
resetApprovers({ approverIds: document.defaultApprovers.map(a => a.id) });
|
||||
}}
|
||||
disabled={isUpdatingApprovers}
|
||||
>
|
||||
<PeopleMultiSelectField
|
||||
name="approverIds"
|
||||
control={approversControl}
|
||||
organizationId={organizationId}
|
||||
selectedPeople={document.defaultApprovers.map(a => ({
|
||||
id: a.id,
|
||||
fullName: a.fullName,
|
||||
emailAddress: a.emailAddress,
|
||||
}))}
|
||||
placeholder={__("Add approvers...")}
|
||||
/>
|
||||
</EditablePropertyContent>
|
||||
)
|
||||
: (
|
||||
<ReadOnlyPropertyContent
|
||||
onEdit={() => setIsEditingApprovers(true)}
|
||||
canEdit={canEdit}
|
||||
>
|
||||
<div className="text-sm text-txt-secondary">
|
||||
{document.defaultApprovers.length > 0
|
||||
? document.defaultApprovers.map(a => a.fullName).join(", ")
|
||||
: __("None")}
|
||||
</div>
|
||||
</ReadOnlyPropertyContent>
|
||||
)}
|
||||
</PropertyRow>
|
||||
<PropertyRow label={__("Type")}>
|
||||
{isEditingType
|
||||
? (
|
||||
@@ -251,11 +359,11 @@ export function DocumentLayoutDrawer(props: {
|
||||
</PropertyRow>
|
||||
<PropertyRow label={__("Status")}>
|
||||
<Badge
|
||||
variant={isDraft ? "highlight" : "success"}
|
||||
variant={version.status === "PUBLISHED" ? "success" : version.status === "PENDING_APPROVAL" ? "warning" : "highlight"}
|
||||
size="md"
|
||||
className="gap-2"
|
||||
>
|
||||
{isDraft ? __("Draft") : __("Published")}
|
||||
{version.status === "PUBLISHED" ? __("Published") : version.status === "PENDING_APPROVAL" ? __("Pending approval") : __("Draft")}
|
||||
</Badge>
|
||||
</PropertyRow>
|
||||
<PropertyRow label={__("Version")}>
|
||||
|
||||
@@ -365,15 +365,14 @@ export function DocumentList(props: {
|
||||
</SortableTh>
|
||||
<Th className="w-32">{__("Classification")}</Th>
|
||||
<Th className="w-60">{__("Approvers")}</Th>
|
||||
<Th className="w-60">{__("Last update")}</Th>
|
||||
<Th className="w-20">{__("Approvals")}</Th>
|
||||
<Th className="w-40">{__("Last update")}</Th>
|
||||
<Th className="w-20">{__("Signatures")}</Th>
|
||||
{hasAnyAction && <Th className="w-18"></Th>}
|
||||
</Tr>
|
||||
)
|
||||
: (
|
||||
<Tr>
|
||||
<Th colspan={hasAnyAction ? 11 : 10} compact>
|
||||
<Th colspan={hasAnyAction ? 10 : 9} compact>
|
||||
<div className="flex justify-between items-center h-8">
|
||||
<div className="flex gap-2 items-center">
|
||||
{sprintf(__("%s documents selected"), selection.length)}
|
||||
|
||||
@@ -28,6 +28,10 @@ const fragment = graphql`
|
||||
title
|
||||
updatedAt
|
||||
canDelete: permission(action: "core:document:delete")
|
||||
defaultApprovers {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
recentVersions: versions(first: 2 orderBy: { field: CREATED_AT direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
@@ -41,15 +45,8 @@ const fragment = graphql`
|
||||
edges {
|
||||
node {
|
||||
status
|
||||
decisions(first: 20) {
|
||||
decisions(first: 0) {
|
||||
totalCount
|
||||
edges {
|
||||
node {
|
||||
approver {
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
approvedDecisions: decisions(first: 0 filter: { states: [APPROVED] }) {
|
||||
totalCount
|
||||
@@ -96,29 +93,30 @@ export function DocumentListItem(props: {
|
||||
} = props;
|
||||
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
const [deleteDocument] = useMutation<DocumentListItem_deleteMutation>(deleteDocumentMutation);
|
||||
const confirm = useConfirm();
|
||||
const document = useFragment<DocumentListItemFragment$key>(
|
||||
fragment,
|
||||
fragmentRef,
|
||||
);
|
||||
const lastVersion = document.recentVersions.edges[0].node;
|
||||
const approverQuorum = lastVersion.approvalQuorums?.edges?.[0]?.node
|
||||
?? document.recentVersions.edges[1]?.node.approvalQuorums?.edges?.[0]?.node;
|
||||
|
||||
const { __ } = useTranslate();
|
||||
const lastVersionEdge = document.recentVersions.edges[0];
|
||||
if (!lastVersionEdge) return null;
|
||||
const lastVersion = lastVersionEdge.node;
|
||||
|
||||
const statusVariant = {
|
||||
DRAFT: "neutral",
|
||||
PENDING_APPROVAL: "warning",
|
||||
PUBLISHED: "success",
|
||||
} as const;
|
||||
|
||||
const statusLabel = {
|
||||
DRAFT: __("Draft"),
|
||||
PENDING_APPROVAL: __("Pending approval"),
|
||||
PUBLISHED: __("Published"),
|
||||
} as const;
|
||||
|
||||
const [deleteDocument] = useMutation<DocumentListItem_deleteMutation>(deleteDocumentMutation);
|
||||
const confirm = useConfirm();
|
||||
|
||||
const handleDelete = () => {
|
||||
confirm(
|
||||
() =>
|
||||
@@ -172,23 +170,19 @@ export function DocumentListItem(props: {
|
||||
</Td>
|
||||
<Td className="w-60">
|
||||
{(() => {
|
||||
const decisions = approverQuorum?.decisions;
|
||||
if (!decisions?.edges.length) return "—";
|
||||
const names = decisions.edges.map(e => e.node.approver.fullName).join(", ");
|
||||
return decisions.totalCount > 20 ? `${names}...` : names;
|
||||
})()}
|
||||
</Td>
|
||||
<Td className="w-60">{formatDate(document.updatedAt)}</Td>
|
||||
<Td className="w-20">
|
||||
{(() => {
|
||||
const lastQuorum = lastVersion.approvalQuorums?.edges?.[0]?.node;
|
||||
return lastQuorum
|
||||
? lastQuorum.status === "REJECTED"
|
||||
? __("Rejected")
|
||||
: `${lastQuorum.approvedDecisions.totalCount}/${lastQuorum.decisions.totalCount}`
|
||||
: "—";
|
||||
if (lastVersion.status === "PENDING_APPROVAL") {
|
||||
const quorum = lastVersion.approvalQuorums?.edges?.[0]?.node;
|
||||
if (quorum) {
|
||||
if (quorum.status === "REJECTED") return __("Rejected");
|
||||
return `${quorum.approvedDecisions.totalCount}/${quorum.decisions.totalCount}`;
|
||||
}
|
||||
return "—";
|
||||
}
|
||||
if (!document.defaultApprovers.length) return "—";
|
||||
return document.defaultApprovers.map(a => a.fullName).join(", ");
|
||||
})()}
|
||||
</Td>
|
||||
<Td className="w-40">{formatDate(document.updatedAt)}</Td>
|
||||
<Td className="w-20">
|
||||
{lastVersion.signedSignatures.totalCount}
|
||||
/
|
||||
|
||||
@@ -78,6 +78,11 @@ export function DocumentVersionsDropdownItem(props: {
|
||||
{__("Draft")}
|
||||
</Badge>
|
||||
)}
|
||||
{version.status === "PENDING_APPROVAL" && (
|
||||
<Badge variant="warning" size="sm">
|
||||
{__("Pending approval")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-txt-secondary whitespace-nowrap overflow-hidden text-ellipsis">
|
||||
{dateTimeFormat(version.publishedAt ?? version.updatedAt)}
|
||||
|
||||
@@ -21,12 +21,11 @@ import {
|
||||
DialogFooter,
|
||||
IconSend,
|
||||
IconUpload,
|
||||
IconWarning,
|
||||
Textarea,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type Ref, useImperativeHandle, useRef } from "react";
|
||||
import { type Ref, useImperativeHandle, useMemo, useRef } from "react";
|
||||
import { useFragment, useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
@@ -43,36 +42,17 @@ export type PublishDialogRef = {
|
||||
open: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
type PublishDialogProps = {
|
||||
ref: Ref<PublishDialogRef>;
|
||||
documentId: string;
|
||||
documentFragmentRef: PublishDialog_documentFragment$key;
|
||||
hasPendingApproval: boolean;
|
||||
onSuccess: () => void;
|
||||
};
|
||||
|
||||
const documentFragment = graphql`
|
||||
fragment PublishDialog_documentFragment on Document {
|
||||
lastPublishedVersion: versions(first: 1, orderBy: { field: CREATED_AT, direction: DESC }, filter: { statuses: [PUBLISHED] }) {
|
||||
edges {
|
||||
node {
|
||||
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
decisions(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
approver {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
defaultApprovers {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
@@ -147,24 +127,20 @@ export function PublishDialog({
|
||||
ref,
|
||||
documentId,
|
||||
documentFragmentRef,
|
||||
hasPendingApproval,
|
||||
onSuccess,
|
||||
}: Props) {
|
||||
}: PublishDialogProps) {
|
||||
const document = useFragment(documentFragment, documentFragmentRef);
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const organizationId = useOrganizationId();
|
||||
const dialogRef = useDialogRef();
|
||||
|
||||
const previousApproverIds = document.lastPublishedVersion.edges[0]
|
||||
?.node.approvalQuorums.edges[0]
|
||||
?.node.decisions?.edges.map(e => e.node.approver.id)
|
||||
?? [];
|
||||
|
||||
const schema = z.object({
|
||||
const publishSchema = useMemo(() => z.object({
|
||||
changelog: z.string().min(1, __("Changelog is required")),
|
||||
approverIds: z.array(z.string()),
|
||||
});
|
||||
}), [__]);
|
||||
|
||||
const defaultApproverIds = document.defaultApprovers.map(a => a.id);
|
||||
|
||||
const {
|
||||
control,
|
||||
@@ -173,7 +149,7 @@ export function PublishDialog({
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useFormWithSchema(schema, {
|
||||
} = useFormWithSchema(publishSchema, {
|
||||
defaultValues: {
|
||||
changelog: "",
|
||||
approverIds: [],
|
||||
@@ -184,7 +160,7 @@ export function PublishDialog({
|
||||
open: () => {
|
||||
reset({
|
||||
changelog: "",
|
||||
approverIds: previousApproverIds,
|
||||
approverIds: defaultApproverIds,
|
||||
});
|
||||
dialogRef.current?.open();
|
||||
},
|
||||
@@ -199,6 +175,7 @@ export function PublishDialog({
|
||||
|
||||
const isBusy = isPublishingMajor || isPublishingMinor || isRequesting;
|
||||
const approverIds = watch("approverIds");
|
||||
const hasApprovers = approverIds.length > 0;
|
||||
const actionRef = useRef<"publish" | "publish-minor" | "request-approval">("publish");
|
||||
|
||||
const onPublishCompleted = (_: unknown, errors: ReadonlyArray<{ message: string }> | null) => {
|
||||
@@ -223,7 +200,7 @@ export function PublishDialog({
|
||||
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||
};
|
||||
|
||||
const handlePublishMajor = (data: z.infer<typeof schema>) => {
|
||||
const handlePublishMajor = (data: z.infer<typeof publishSchema>) => {
|
||||
publishMajor({
|
||||
variables: { input: { documentId, changelog: data.changelog } },
|
||||
onCompleted: onPublishCompleted,
|
||||
@@ -231,7 +208,7 @@ export function PublishDialog({
|
||||
});
|
||||
};
|
||||
|
||||
const handlePublishMinor = (data: z.infer<typeof schema>) => {
|
||||
const handlePublishMinor = (data: z.infer<typeof publishSchema>) => {
|
||||
publishMinor({
|
||||
variables: { input: { documentId, changelog: data.changelog } },
|
||||
onCompleted: onPublishCompleted,
|
||||
@@ -239,7 +216,7 @@ export function PublishDialog({
|
||||
});
|
||||
};
|
||||
|
||||
const onRequestApproval = (data: z.infer<typeof schema>) => {
|
||||
const onRequestApproval = (data: z.infer<typeof publishSchema>) => {
|
||||
requestApproval({
|
||||
variables: {
|
||||
input: {
|
||||
@@ -276,12 +253,16 @@ export function PublishDialog({
|
||||
<Dialog className="max-w-xl" ref={dialogRef} title={__("Publish document")}>
|
||||
<form
|
||||
onSubmit={e => void handleSubmit((data) => {
|
||||
if (actionRef.current === "publish") {
|
||||
handlePublishMajor(data);
|
||||
} else if (actionRef.current === "publish-minor") {
|
||||
const action = actionRef.current;
|
||||
actionRef.current = "publish";
|
||||
if (action === "publish-minor") {
|
||||
handlePublishMinor(data);
|
||||
} else {
|
||||
} else if (action === "request-approval") {
|
||||
onRequestApproval(data);
|
||||
} else if (data.approverIds.length > 0) {
|
||||
onRequestApproval(data);
|
||||
} else {
|
||||
handlePublishMajor(data);
|
||||
}
|
||||
})(e)}
|
||||
>
|
||||
@@ -303,63 +284,64 @@ export function PublishDialog({
|
||||
<p className="text-xs text-txt-danger mt-1">{errors.changelog.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{hasPendingApproval
|
||||
? (
|
||||
<div className="flex items-start gap-2 rounded-lg bg-bg-warning/10 border border-border-warning p-3">
|
||||
<IconWarning size={16} className="text-txt-warning shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-txt-warning">
|
||||
{__("An approval review is currently in progress. Publishing now will bypass the pending approval.")}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-txt-primary mb-1">
|
||||
{__("Request approval before publishing")}
|
||||
</div>
|
||||
<p className="text-xs text-txt-secondary mb-3">
|
||||
{__("Select approvers to review this document. The document will be published once all approvers have approved it. You can also publish directly without requiring approval.")}
|
||||
</p>
|
||||
<PeopleMultiSelectField
|
||||
name="approverIds"
|
||||
label={__("Approvers")}
|
||||
control={control}
|
||||
organizationId={organizationId}
|
||||
placeholder={__("Add approvers...")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-xs text-txt-secondary mb-3">
|
||||
{__("Approvers will receive an email and the document will be published as a major version once all have approved. Remove all approvers to publish directly as major.")}
|
||||
</p>
|
||||
<PeopleMultiSelectField
|
||||
name="approverIds"
|
||||
label={__("Approvers")}
|
||||
control={control}
|
||||
organizationId={organizationId}
|
||||
placeholder={__("Add approvers...")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
icon={IconUpload}
|
||||
onClick={() => { actionRef.current = "publish-minor"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Publish as minor")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
icon={IconUpload}
|
||||
onClick={() => { actionRef.current = "publish"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Publish now")}
|
||||
</Button>
|
||||
{!hasPendingApproval && (
|
||||
<Button
|
||||
type="submit"
|
||||
icon={IconSend}
|
||||
onClick={() => { actionRef.current = "request-approval"; }}
|
||||
disabled={isBusy || approverIds.length === 0}
|
||||
>
|
||||
{__("Request approval")}
|
||||
</Button>
|
||||
)}
|
||||
{hasApprovers
|
||||
? (
|
||||
<>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
icon={IconUpload}
|
||||
onClick={() => { actionRef.current = "publish-minor"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Publish as minor")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
icon={IconSend}
|
||||
onClick={() => { actionRef.current = "request-approval"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Request approval")}
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
icon={IconUpload}
|
||||
onClick={() => { actionRef.current = "publish-minor"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Publish as minor")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
icon={IconUpload}
|
||||
onClick={() => { actionRef.current = "publish"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Publish as major")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
@@ -156,9 +156,10 @@ export function PublishDocumentsDialog({
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-2 rounded-lg bg-bg-warning/10 border border-border-warning p-3">
|
||||
<IconWarning size={16} className="text-txt-warning shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-txt-warning">
|
||||
{__("This will publish the selected documents directly without requiring approval.")}
|
||||
</p>
|
||||
<div className="text-sm text-txt-warning space-y-1">
|
||||
<p>{__("Publishing as major will request approval for documents that have default approvers configured. Approvers will receive an email notification.")}</p>
|
||||
<p>{__("Documents already published and pending approval will be skipped.")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="changelog" className="text-sm font-medium text-txt-primary mb-1 block">
|
||||
|
||||
@@ -81,9 +81,8 @@ const versionFragment = graphql`
|
||||
|
||||
export function DocumentApprovalsPage(props: {
|
||||
queryRef: PreloadedQuery<DocumentApprovalsPageQuery>;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { queryRef, onRefetch } = props;
|
||||
const { queryRef } = props;
|
||||
|
||||
const { document, version } = usePreloadedQuery<DocumentApprovalsPageQuery>(
|
||||
documentApprovalsPageQuery,
|
||||
@@ -109,7 +108,6 @@ export function DocumentApprovalsPage(props: {
|
||||
<DocumentApprovalsPageContent
|
||||
approvalListRef={approvalListRef}
|
||||
versionFragmentRef={versionFragmentRef}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -118,9 +116,8 @@ export function DocumentApprovalsPage(props: {
|
||||
function DocumentApprovalsPageContent(props: {
|
||||
approvalListRef: Parameters<typeof DocumentApprovalList>[0]["versionFragmentRef"];
|
||||
versionFragmentRef: DocumentApprovalsPage_versionFragment$key;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { approvalListRef, versionFragmentRef, onRefetch } = props;
|
||||
const { approvalListRef, versionFragmentRef } = props;
|
||||
const { __, dateTimeFormat } = useTranslate();
|
||||
|
||||
const versionData = useFragment(versionFragment, versionFragmentRef);
|
||||
@@ -129,7 +126,7 @@ function DocumentApprovalsPageContent(props: {
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<DocumentApprovalList versionFragmentRef={approvalListRef} onRefetch={onRefetch} />
|
||||
<DocumentApprovalList versionFragmentRef={approvalListRef} />
|
||||
|
||||
{pastQuorums.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
@@ -137,8 +134,8 @@ function DocumentApprovalsPageContent(props: {
|
||||
{pastQuorums.map(({ node: quorum }) => (
|
||||
<div key={quorum.id} className="border border-border-solid rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge variant={quorum.status === "APPROVED" ? "success" : "danger"}>
|
||||
{quorum.status === "APPROVED" ? __("Approved") : __("Rejected")}
|
||||
<Badge variant={quorum.status === "APPROVED" ? "success" : quorum.status === "VOIDED" ? "neutral" : "danger"}>
|
||||
{quorum.status === "APPROVED" ? __("Approved") : quorum.status === "VOIDED" ? __("Voided") : __("Rejected")}
|
||||
</Badge>
|
||||
<span className="text-xs text-txt-secondary">
|
||||
{dateTimeFormat(quorum.createdAt)}
|
||||
@@ -159,8 +156,8 @@ function DocumentApprovalsPageContent(props: {
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto">
|
||||
<Badge variant={decision.state === "APPROVED" ? "success" : decision.state === "REJECTED" ? "danger" : "warning"}>
|
||||
{decision.state === "APPROVED" ? __("Approved") : decision.state === "REJECTED" ? __("Rejected") : __("Pending")}
|
||||
<Badge variant={decision.state === "APPROVED" ? "success" : decision.state === "REJECTED" ? "danger" : decision.state === "VOIDED" ? "neutral" : "warning"}>
|
||||
{decision.state === "APPROVED" ? __("Approved") : decision.state === "REJECTED" ? __("Rejected") : decision.state === "VOIDED" ? __("Voided") : __("Pending")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,9 +27,8 @@ function DocumentApprovalsPageQueryLoader() {
|
||||
throw new Error(":documentId missing in route params");
|
||||
}
|
||||
|
||||
const { onRefetch: parentRefetch, approvalRequestedAt }
|
||||
const { approvalRequestedAt }
|
||||
= useOutletContext<{
|
||||
onRefetch: () => void;
|
||||
approvalRequestedAt?: number;
|
||||
}>();
|
||||
|
||||
@@ -48,16 +47,9 @@ function DocumentApprovalsPageQueryLoader() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryRef) {
|
||||
loadQuery(
|
||||
{
|
||||
documentId: documentId,
|
||||
versionId: versionId ?? "",
|
||||
versionSpecified: !!versionId,
|
||||
},
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
loadQueryParams();
|
||||
}
|
||||
}, [queryRef, documentId, versionId, loadQuery]);
|
||||
}, [queryRef, loadQueryParams]);
|
||||
|
||||
// Reload approvals data whenever a new approval round is requested from the layout
|
||||
useEffect(() => {
|
||||
@@ -66,16 +58,11 @@ function DocumentApprovalsPageQueryLoader() {
|
||||
}
|
||||
}, [approvalRequestedAt, loadQueryParams]);
|
||||
|
||||
const onRefetch = useCallback(() => {
|
||||
parentRefetch();
|
||||
loadQueryParams();
|
||||
}, [parentRefetch, loadQueryParams]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <LinkCardSkeleton />;
|
||||
}
|
||||
|
||||
return <DocumentApprovalsPage queryRef={queryRef} onRefetch={onRefetch} />;
|
||||
return <DocumentApprovalsPage queryRef={queryRef} />;
|
||||
}
|
||||
|
||||
export default function DocumentApprovalsPageLoader() {
|
||||
|
||||
@@ -13,42 +13,27 @@
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
IconPlusSmall,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { Suspense, useState } from "react";
|
||||
import { Badge, Button, IconCrossLargeX, useConfirm } from "@probo/ui";
|
||||
import { useFragment, useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { DocumentApprovalList_addApproverMutation } from "#/__generated__/core/DocumentApprovalList_addApproverMutation.graphql";
|
||||
import type { DocumentApprovalList_versionFragment$key } from "#/__generated__/core/DocumentApprovalList_versionFragment.graphql";
|
||||
import { usePeople } from "#/hooks/graph/PeopleGraph";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import type { DocumentApprovalList_voidMutation } from "#/__generated__/core/DocumentApprovalList_voidMutation.graphql";
|
||||
|
||||
import { DocumentApprovalListItem } from "./DocumentApprovalListItem";
|
||||
|
||||
const versionFragment = graphql`
|
||||
fragment DocumentApprovalList_versionFragment on DocumentVersion {
|
||||
id
|
||||
canAddApprover: permission(action: "core:document-version:add-approver")
|
||||
approvalQuorums(first: 100, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
status
|
||||
decisions(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
|
||||
@connection(key: "DocumentApprovalList_decisions") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
approver {
|
||||
id
|
||||
}
|
||||
...DocumentApprovalListItemFragment
|
||||
}
|
||||
}
|
||||
@@ -59,20 +44,21 @@ const versionFragment = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const addApproverMutation = graphql`
|
||||
mutation DocumentApprovalList_addApproverMutation(
|
||||
$input: AddDocumentVersionApproverInput!
|
||||
$connections: [ID!]!
|
||||
const voidMutation = graphql`
|
||||
mutation DocumentApprovalList_voidMutation(
|
||||
$input: VoidDocumentVersionApprovalInput!
|
||||
) {
|
||||
addDocumentVersionApprover(input: $input) {
|
||||
approvalDecisionEdge @appendEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
approver {
|
||||
id
|
||||
}
|
||||
...DocumentApprovalListItemFragment
|
||||
}
|
||||
voidDocumentVersionApproval(input: $input) {
|
||||
documentVersion {
|
||||
id
|
||||
status
|
||||
major
|
||||
minor
|
||||
...DocumentApprovalList_versionFragment
|
||||
}
|
||||
approvalQuorum {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,33 +66,82 @@ const addApproverMutation = graphql`
|
||||
|
||||
export function DocumentApprovalList(props: {
|
||||
versionFragmentRef: DocumentApprovalList_versionFragment$key;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { versionFragmentRef, onRefetch } = props;
|
||||
const { versionFragmentRef } = props;
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const version = useFragment(versionFragment, versionFragmentRef);
|
||||
const dialogRef = useDialogRef();
|
||||
const canManage = version.canAddApprover;
|
||||
|
||||
const lastQuorum = version.approvalQuorums?.edges?.[0]?.node ?? null;
|
||||
const decisions = lastQuorum?.decisions;
|
||||
const edges = decisions?.edges ?? [];
|
||||
const existingApproverIds = edges.map(({ node }) => node.approver.id);
|
||||
const isPending = lastQuorum?.status === "PENDING";
|
||||
const edges = lastQuorum?.decisions?.edges ?? [];
|
||||
|
||||
const [voidApproval, isVoiding]
|
||||
= useMutation<DocumentApprovalList_voidMutation>(voidMutation);
|
||||
const confirm = useConfirm();
|
||||
|
||||
const handleVoid = () => {
|
||||
confirm(
|
||||
() =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
voidApproval({
|
||||
variables: {
|
||||
input: { documentVersionId: version.id },
|
||||
},
|
||||
onCompleted: (_, errors) => {
|
||||
if (errors?.length) {
|
||||
reject(new Error(errors[0].message));
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
onError: err => reject(err),
|
||||
});
|
||||
}),
|
||||
{
|
||||
message: __(
|
||||
"This will void the current approval request and return the version to draft. This action cannot be undone.",
|
||||
),
|
||||
label: __("Void approval"),
|
||||
variant: "danger",
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const statusVariant = {
|
||||
PENDING: "warning",
|
||||
APPROVED: "success",
|
||||
REJECTED: "danger",
|
||||
VOIDED: "neutral",
|
||||
} as const;
|
||||
|
||||
const statusLabel = {
|
||||
PENDING: __("Pending"),
|
||||
APPROVED: __("Approved"),
|
||||
REJECTED: __("Rejected"),
|
||||
VOIDED: __("Voided"),
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canManage && (
|
||||
<div className="flex justify-end pb-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconPlusSmall}
|
||||
onClick={() => dialogRef.current?.open()}
|
||||
>
|
||||
{__("Add approver")}
|
||||
</Button>
|
||||
{lastQuorum && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<Badge variant={statusVariant[lastQuorum.status]}>
|
||||
{statusLabel[lastQuorum.status]}
|
||||
</Badge>
|
||||
{isPending && (
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={IconCrossLargeX}
|
||||
onClick={handleVoid}
|
||||
disabled={isVoiding}
|
||||
>
|
||||
{__("Cancel")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{edges.length === 0
|
||||
? (
|
||||
<div className="text-sm text-txt-secondary text-center py-8">
|
||||
@@ -119,108 +154,10 @@ export function DocumentApprovalList(props: {
|
||||
<DocumentApprovalListItem
|
||||
key={node.id}
|
||||
fragmentRef={node}
|
||||
canManage={canManage}
|
||||
connectionId={decisions?.__id ?? ""}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Dialog ref={dialogRef} title={__("Add approver")}>
|
||||
<Suspense fallback={<DialogContent padded>{__("Loading...")}</DialogContent>}>
|
||||
<AddApproverDialogContent
|
||||
documentVersionId={version.id}
|
||||
existingApproverIds={existingApproverIds}
|
||||
connectionId={decisions?.__id ?? ""}
|
||||
onSuccess={() => {
|
||||
dialogRef.current?.close();
|
||||
onRefetch();
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddApproverDialogContent(props: {
|
||||
documentVersionId: string;
|
||||
existingApproverIds: string[];
|
||||
connectionId: string;
|
||||
onSuccess: () => void;
|
||||
}) {
|
||||
const { documentVersionId, existingApproverIds, connectionId, onSuccess } = props;
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const organizationId = useOrganizationId();
|
||||
const allPeople = usePeople(organizationId, { excludeContractEnded: true });
|
||||
const people = allPeople.filter(p => !existingApproverIds.includes(p.id));
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
|
||||
const [addApprover, isAdding] = useMutation<DocumentApprovalList_addApproverMutation>(
|
||||
addApproverMutation,
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!selectedId) return;
|
||||
void addApprover({
|
||||
variables: {
|
||||
input: {
|
||||
documentVersionId,
|
||||
approverId: selectedId,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted: (_data, errors) => {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: errors[0].message,
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Approver added"),
|
||||
description: __("The approver has been added successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
onSuccess();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: error.message,
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<DialogContent padded>
|
||||
<label htmlFor="add-approver-select" className="block text-sm font-medium mb-1">{__("Approver")}</label>
|
||||
<select
|
||||
id="add-approver-select"
|
||||
className="w-full rounded-md border border-border-solid bg-bg-primary px-3 py-2 text-sm"
|
||||
value={selectedId}
|
||||
onChange={e => setSelectedId(e.target.value)}
|
||||
>
|
||||
<option value="">{__("Select a person...")}</option>
|
||||
{people.map(p => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.fullName}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={!selectedId || isAdding}>
|
||||
{__("Add approver")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,14 +20,10 @@ import {
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconClock,
|
||||
IconTrashCan,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { useFragment, useMutation } from "react-relay";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { DocumentApprovalListItem_removeApproverMutation } from "#/__generated__/core/DocumentApprovalListItem_removeApproverMutation.graphql";
|
||||
import type { DocumentApprovalListItemFragment$key } from "#/__generated__/core/DocumentApprovalListItemFragment.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
@@ -52,43 +48,11 @@ const fragment = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const removeApproverMutation = graphql`
|
||||
mutation DocumentApprovalListItem_removeApproverMutation(
|
||||
$input: RemoveDocumentVersionApproverInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
removeDocumentVersionApprover(input: $input) {
|
||||
deletedApprovalDecisionId @deleteEdge(connections: $connections)
|
||||
documentVersion {
|
||||
id
|
||||
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
status
|
||||
decisions(first: 0) {
|
||||
totalCount
|
||||
}
|
||||
approvedDecisions: decisions(first: 0 filter: { states: [APPROVED] }) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function DocumentApprovalListItem(props: {
|
||||
fragmentRef: DocumentApprovalListItemFragment$key;
|
||||
canManage: boolean;
|
||||
connectionId: string;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { fragmentRef, canManage, connectionId, onRefetch } = props;
|
||||
const { fragmentRef } = props;
|
||||
const { __, dateTimeFormat } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const organizationId = useOrganizationId();
|
||||
|
||||
const decision = useFragment(fragment, fragmentRef);
|
||||
@@ -96,10 +60,7 @@ export function DocumentApprovalListItem(props: {
|
||||
const isPending = decision.state === "PENDING";
|
||||
const isApproved = decision.state === "APPROVED";
|
||||
const isRejected = decision.state === "REJECTED";
|
||||
|
||||
const [removeApprover, isRemoving] = useMutation<DocumentApprovalListItem_removeApproverMutation>(
|
||||
removeApproverMutation,
|
||||
);
|
||||
const isVoided = decision.state === "VOIDED";
|
||||
|
||||
const reviewUrl = `/organizations/${organizationId}/employee/approvals/${decision.documentVersion.document.id}`;
|
||||
|
||||
@@ -113,10 +74,12 @@ export function DocumentApprovalListItem(props: {
|
||||
{isApproved && <IconCircleCheck size={16} className="text-txt-accent" />}
|
||||
{isRejected && <IconCircleX size={16} className="text-txt-danger" />}
|
||||
{isPending && <IconClock size={16} />}
|
||||
{isVoided && <IconClock size={16} className="text-txt-secondary" />}
|
||||
<span>
|
||||
{isPending && sprintf(__("Requested on %s"), dateTimeFormat(decision.createdAt))}
|
||||
{isApproved && sprintf(__("Approved on %s"), dateTimeFormat(decision.decidedAt))}
|
||||
{isRejected && sprintf(__("Rejected on %s"), dateTimeFormat(decision.decidedAt))}
|
||||
{isVoided && sprintf(__("Requested on %s"), dateTimeFormat(decision.createdAt))}
|
||||
</span>
|
||||
</div>
|
||||
{decision.comment && (
|
||||
@@ -137,49 +100,12 @@ export function DocumentApprovalListItem(props: {
|
||||
{__("Review")}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
variant="quaternary"
|
||||
icon={isRemoving ? Spinner : IconTrashCan}
|
||||
disabled={isRemoving}
|
||||
onClick={() => {
|
||||
void removeApprover({
|
||||
variables: {
|
||||
input: {
|
||||
approvalDecisionId: decision.id,
|
||||
},
|
||||
connections: [connectionId],
|
||||
},
|
||||
onCompleted: (_data, errors) => {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: errors[0].message,
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
title: __("Approver removed"),
|
||||
description: __("The approver has been removed successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
onRefetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: error.message,
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{isPending && !decision.canApprove && !decision.canReject && !canManage && (
|
||||
{isPending && !decision.canApprove && !decision.canReject && (
|
||||
<Badge variant="warning">{__("Pending")}</Badge>
|
||||
)}
|
||||
{isVoided && (
|
||||
<Badge variant="neutral">{__("Voided")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||
// PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { formatDate, formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
@@ -173,6 +173,7 @@ function VersionRow({
|
||||
const state = approvalDecision?.state;
|
||||
const isApproved = state === "APPROVED";
|
||||
const isRejected = state === "REJECTED";
|
||||
const isVoided = state === "VOIDED";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -189,7 +190,9 @@ function VersionRow({
|
||||
? <IconCircleCheck size={20} className="text-txt-success" />
|
||||
: isRejected
|
||||
? <IconCircleX size={20} className="text-txt-danger" />
|
||||
: <IconRadioUnchecked size={20} className="text-txt-tertiary" />}
|
||||
: isVoided
|
||||
? <IconRadioUnchecked size={20} className="text-txt-secondary" />
|
||||
: <IconRadioUnchecked size={20} className="text-txt-tertiary" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
@@ -199,13 +202,7 @@ function VersionRow({
|
||||
)}
|
||||
>
|
||||
{versionData.publishedAt
|
||||
? `v${versionData.major}.${versionData.minor} - ${(() => {
|
||||
const date = new Date(versionData.publishedAt);
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const year = date.getFullYear();
|
||||
return `${day}/${month}/${year}`;
|
||||
})()}`
|
||||
? `v${versionData.major}.${versionData.minor} - ${formatDate(versionData.publishedAt)}`
|
||||
: `v${versionData.major}.${versionData.minor}`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -214,9 +211,11 @@ function VersionRow({
|
||||
? <Badge variant="success">{__("Approved")}</Badge>
|
||||
: isRejected
|
||||
? <Badge variant="danger">{__("Rejected")}</Badge>
|
||||
: isSelected
|
||||
? <Badge variant="info">{__("In review")}</Badge>
|
||||
: <Badge variant="warning">{__("Pending")}</Badge>}
|
||||
: isVoided
|
||||
? <Badge variant="neutral">{__("Voided")}</Badge>
|
||||
: isSelected
|
||||
? <Badge variant="info">{__("In review")}</Badge>
|
||||
: <Badge variant="warning">{__("Pending")}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -245,6 +244,20 @@ function ViewerDecision(props: {
|
||||
const isPending = decision.state === "PENDING";
|
||||
const isApproved = decision.state === "APPROVED";
|
||||
const isRejected = decision.state === "REJECTED";
|
||||
const isVoided = decision.state === "VOIDED";
|
||||
|
||||
if (isVoided) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-txt-secondary mb-4">
|
||||
<span>{__("Your approval is no longer required for this version.")}</span>
|
||||
</div>
|
||||
<Button onClick={onBack} className="h-10 w-full" variant="secondary">
|
||||
{__("Back to Documents")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!decision.canApprove && !decision.canReject) {
|
||||
return (
|
||||
@@ -484,10 +497,7 @@ function DocumentApproveContent({
|
||||
}, [selectedVersion?.id, exportPDF, toast, __]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bg-level-2 flex flex-col"
|
||||
style={{ top: "3rem", left: 0, right: 0, bottom: 0 }}
|
||||
>
|
||||
<div className="fixed inset-0 top-12 bg-level-2 flex flex-col">
|
||||
<div className="grid lg:grid-cols-2 min-h-0 h-full">
|
||||
<div className="w-full lg:w-[440px] mx-auto py-20 overflow-y-auto scrollbar-hide">
|
||||
<h1 className="text-2xl font-semibold mb-6">
|
||||
|
||||
@@ -18,7 +18,6 @@ import { Card, Tbody, Th, Thead, Tr } from "@probo/ui";
|
||||
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { EmployeeApprovalsPageQuery } from "#/__generated__/core/EmployeeApprovalsPageQuery.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { ApprovableDocumentRow } from "./_components/ApprovableDocumentRow";
|
||||
|
||||
@@ -46,7 +45,6 @@ export function EmployeeApprovalsPage(props: {
|
||||
}) {
|
||||
const { queryRef } = props;
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
|
||||
const {
|
||||
viewer: { approvableDocuments },
|
||||
@@ -79,7 +77,6 @@ export function EmployeeApprovalsPage(props: {
|
||||
<ApprovableDocumentRow
|
||||
key={document.id}
|
||||
fKey={document}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Badge, Td, Tr } from "@probo/ui";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import type { ApprovableDocumentRowFragment$key } from "#/__generated__/core/ApprovableDocumentRowFragment.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment ApprovableDocumentRowFragment on EmployeeDocument {
|
||||
@@ -42,26 +43,32 @@ const fragment = graphql`
|
||||
|
||||
export function ApprovableDocumentRow({
|
||||
fKey,
|
||||
organizationId,
|
||||
}: {
|
||||
fKey: ApprovableDocumentRowFragment$key;
|
||||
organizationId: string;
|
||||
}) {
|
||||
const document = useFragment<ApprovableDocumentRowFragment$key>(fragment, fKey);
|
||||
const lastVersion = document.lastVersion.edges[0].node;
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
const document = useFragment<ApprovableDocumentRowFragment$key>(fragment, fKey);
|
||||
|
||||
const lastVersionEdge = document.lastVersion.edges[0];
|
||||
if (!lastVersionEdge) return null;
|
||||
const lastVersion = lastVersionEdge.node;
|
||||
|
||||
const stateVariant = document.approvalState === "APPROVED"
|
||||
? "success"
|
||||
: document.approvalState === "REJECTED"
|
||||
? "danger"
|
||||
: "warning";
|
||||
: document.approvalState === "VOIDED"
|
||||
? "neutral"
|
||||
: "warning";
|
||||
|
||||
const stateLabel = document.approvalState === "APPROVED"
|
||||
? __("Approved")
|
||||
: document.approvalState === "REJECTED"
|
||||
? __("Rejected")
|
||||
: __("Pending");
|
||||
: document.approvalState === "VOIDED"
|
||||
? __("No longer required")
|
||||
: __("Pending");
|
||||
|
||||
return (
|
||||
<Tr to={`/organizations/${organizationId}/employee/approvals/${document.id}`}>
|
||||
|
||||
Reference in New Issue
Block a user