Add document approval workflow
Introduce a complete approval system for document publishing. Document versions can now require approval from selected reviewers before being published, with automatic publishing once all approvers have approved. - Add approval quorum and decision tables with backfill migration - Implement request approval, approve, and reject flows with electronic signature support for approve decisions - Add employee approvals page with dedicated tab and pending approvals view - Add changelog field to publish and request approval flows - Pre-select previous version's approvers in the publish dialog - Show quorum approvers in document list with 100 approver hard limit - Expose approval workflow through GraphQL, MCP, and CLI - Remove legacy default approvers feature entirely - Add comprehensive e2e test coverage for approval workflows Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Avatar, Badge, Button, Field, IconCrossLargeX, Option, Select } from "@probo/ui";
|
||||
import { Badge, Button, Field, IconCrossLargeX, Option, Select } from "@probo/ui";
|
||||
import { type ComponentProps, Suspense, useState } from "react";
|
||||
import { type Control, Controller, type FieldValues, type Path } from "react-hook-form";
|
||||
|
||||
@@ -104,10 +104,6 @@ function PeopleMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
>
|
||||
{availablePeople.map(person => (
|
||||
<Option key={person.id} value={person.id} className="flex gap-2">
|
||||
<Avatar
|
||||
name={person.fullName}
|
||||
size="s"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{person.fullName}</span>
|
||||
{person.emailAddress && (
|
||||
@@ -125,10 +121,6 @@ function PeopleMultiSelectWithQuery<T extends FieldValues = FieldValues>(
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedPeople.map(person => (
|
||||
<Badge key={person.id} variant="neutral" className="flex items-center gap-2">
|
||||
<Avatar
|
||||
name={person.fullName}
|
||||
size="s"
|
||||
/>
|
||||
<span>{person.fullName}</span>
|
||||
{!props.disabled && (
|
||||
<Button
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useFormWithSchema } from "../useFormWithSchema";
|
||||
export const documentSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
content: z.string().min(1, "Content is required"),
|
||||
approverIds: z.array(z.string()).min(1, "At least one approver is required"),
|
||||
documentType: z.enum(["OTHER", "GOVERNANCE", "POLICY", "PROCEDURE", "PLAN", "REGISTER", "RECORD", "REPORT", "TEMPLATE"]),
|
||||
classification: z.enum(["PUBLIC", "INTERNAL", "CONFIDENTIAL", "SECRET"]),
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ const documentFragment = graphql`
|
||||
latestPublishedVersion: versions(
|
||||
first: 1
|
||||
orderBy: { field: CREATED_AT, direction: DESC }
|
||||
filter: { status: PUBLISHED }
|
||||
filter: { statuses: [PUBLISHED] }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Breadcrumb, Button, IconCheckmark1, PageHeader, TabBadge, TabLink, Tabs } from "@probo/ui";
|
||||
import { Breadcrumb, Button, IconUpload, PageHeader, TabBadge, TabLink, Tabs } from "@probo/ui";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
import { Outlet, useParams } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { DocumentLayoutQuery } from "#/__generated__/core/DocumentLayoutQuery.graphql";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import { DocumentActionsDropdownn } from "./_components/DocumentActionsDropdown";
|
||||
import { DocumentLayoutDrawer } from "./_components/DocumentLayoutDrawer";
|
||||
import { DocumentTitleForm } from "./_components/DocumentTitleForm";
|
||||
import { DocumentVersionsDropdown } from "./_components/DocumentVersionsDropdown";
|
||||
import { PublishDialog, type PublishDialogRef } from "./_components/PublishDialog";
|
||||
|
||||
export const documentLayoutQuery = graphql`
|
||||
query DocumentLayoutQuery($documentId: ID! $versionId: ID! $versionSpecified: Boolean!) {
|
||||
@@ -29,6 +30,20 @@ export const documentLayoutQuery = graphql`
|
||||
signedSignatures: signatures(first: 0 filter: { states: [SIGNED], activeContract: true }) {
|
||||
totalCount
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
document: node(id: $documentId) {
|
||||
@@ -38,6 +53,7 @@ export const documentLayoutQuery = graphql`
|
||||
title
|
||||
status
|
||||
canPublish: permission(action: "core:document-version:publish")
|
||||
...PublishDialog_documentFragment
|
||||
controlInfo: controls(first: 0) {
|
||||
totalCount
|
||||
}
|
||||
@@ -58,6 +74,20 @@ export const documentLayoutQuery = graphql`
|
||||
signedSignatures: signatures(first: 0 filter: { states: [SIGNED], activeContract: true }) {
|
||||
totalCount
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,18 +96,6 @@ export const documentLayoutQuery = graphql`
|
||||
}
|
||||
`;
|
||||
|
||||
const publishDocumentVersionMutation = graphql`
|
||||
mutation DocumentLayout_publishVersionMutation(
|
||||
$input: PublishDocumentVersionInput!
|
||||
) {
|
||||
publishDocumentVersion(input: $input) {
|
||||
document {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQuery>; onRefetch: () => void }) {
|
||||
const { queryRef, onRefetch } = props;
|
||||
|
||||
@@ -86,6 +104,14 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const publishDialogRef = useRef<PublishDialogRef>(null);
|
||||
const [approvalRequestedAt, setApprovalRequestedAt] = useState(0);
|
||||
|
||||
const handlePublishOrApproval = useCallback(() => {
|
||||
onRefetch();
|
||||
setApprovalRequestedAt(Date.now());
|
||||
}, [onRefetch]);
|
||||
|
||||
const { document, version } = usePreloadedQuery<DocumentLayoutQuery>(documentLayoutQuery, queryRef);
|
||||
if (document.__typename !== "Document" || (version && version.__typename !== "DocumentVersion")) {
|
||||
throw new Error("invalid node type");
|
||||
@@ -99,23 +125,10 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
// It is ok to cas as NonNullable here since we know we have either version or lastVersion
|
||||
const currentVersion = version ?? lastVersion as NonNullable<typeof version | typeof lastVersion>;
|
||||
const isDraft = currentVersion.status === "DRAFT";
|
||||
|
||||
const [publishDocumentVersion, isPublishing] = useMutationWithToasts(
|
||||
publishDocumentVersionMutation,
|
||||
{
|
||||
successMessage: __("Document published successfully."),
|
||||
errorMessage: __("Failed to publish document"),
|
||||
},
|
||||
);
|
||||
|
||||
const handlePublish = async () => {
|
||||
await publishDocumentVersion({
|
||||
variables: {
|
||||
input: { documentId: document.id },
|
||||
},
|
||||
onSuccess: onRefetch,
|
||||
});
|
||||
};
|
||||
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}`
|
||||
@@ -140,9 +153,8 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
<div className="flex gap-2">
|
||||
{isDraft && document.canPublish && (
|
||||
<Button
|
||||
onClick={() => void handlePublish()}
|
||||
icon={IconCheckmark1}
|
||||
disabled={isPublishing}
|
||||
icon={IconUpload}
|
||||
onClick={() => publishDialogRef.current?.open()}
|
||||
>
|
||||
{__("Publish")}
|
||||
</Button>
|
||||
@@ -166,7 +178,17 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
{__("Controls")}
|
||||
<TabBadge>{document.controlInfo.totalCount}</TabBadge>
|
||||
</TabLink>
|
||||
{!isDraft && (
|
||||
{hasApprovals && (
|
||||
<TabLink to={`${urlPrefix}/approvals`}>
|
||||
{__("Approvals")}
|
||||
<TabBadge>
|
||||
{lastQuorum?.status === "REJECTED"
|
||||
? __("Rejected")
|
||||
: `${lastQuorum?.approvedDecisions.totalCount ?? 0}/${lastQuorum?.decisions.totalCount ?? 0}`}
|
||||
</TabBadge>
|
||||
</TabLink>
|
||||
)}
|
||||
{isPublished && (
|
||||
<TabLink to={`${urlPrefix}/signatures`}>
|
||||
{__("Signatures")}
|
||||
<TabBadge>
|
||||
@@ -178,10 +200,18 @@ export function DocumentLayout(props: { queryRef: PreloadedQuery<DocumentLayoutQ
|
||||
)}
|
||||
</Tabs>
|
||||
|
||||
<Outlet />
|
||||
<Outlet context={{ onRefetch, approvalRequestedAt }} />
|
||||
</div>
|
||||
|
||||
<DocumentLayoutDrawer documentFragmentRef={document} versionFragmentRef={currentVersion} />
|
||||
|
||||
<PublishDialog
|
||||
ref={publishDialogRef}
|
||||
documentId={document.id}
|
||||
documentFragmentRef={document}
|
||||
hasPendingApproval={hasPendingApproval}
|
||||
onSuccess={handlePublishOrApproval}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ 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 { documentSchema, useDocumentForm } from "#/hooks/forms/useDocumentForm";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
@@ -148,18 +147,6 @@ export function CreateDocumentDialog({ trigger, connection }: Props) {
|
||||
</ControlledField>
|
||||
</PropertyRow>
|
||||
|
||||
<PropertyRow
|
||||
id="approverIds"
|
||||
label={__("Approvers")}
|
||||
error={errors.approverIds?.message}
|
||||
>
|
||||
<PeopleMultiSelectField
|
||||
name="approverIds"
|
||||
control={control}
|
||||
organizationId={organizationId}
|
||||
placeholder={__("Add approvers...")}
|
||||
/>
|
||||
</PropertyRow>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { documentClassifications, documentTypes, formatDate, getDocumentClassificationLabel, getDocumentTypeLabel } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Avatar, Badge, Button, Drawer, IconCheckmark1, IconCrossLargeX, IconPencil, PropertyRow } from "@probo/ui";
|
||||
import { Badge, Button, Drawer, IconCheckmark1, IconCrossLargeX, IconPencil, PropertyRow } from "@probo/ui";
|
||||
import { useState } from "react";
|
||||
import { useFragment } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
@@ -12,10 +12,8 @@ import type { DocumentLayoutDrawerMutation } from "#/__generated__/core/Document
|
||||
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";
|
||||
|
||||
const documentFragment = graphql`
|
||||
fragment DocumentLayoutDrawer_documentFragment on Document {
|
||||
@@ -24,14 +22,6 @@ const documentFragment = graphql`
|
||||
status
|
||||
archivedAt
|
||||
canUpdate: permission(action: "core:document:update")
|
||||
approvers(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -53,21 +43,12 @@ const updateDocumentMutation = graphql`
|
||||
id
|
||||
documentType
|
||||
classification
|
||||
approvers(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const schema = z.object({
|
||||
approverIds: z.array(z.string()).min(1, "At least one approver is required"),
|
||||
documentType: z.enum(documentTypes),
|
||||
classification: z.enum(documentClassifications),
|
||||
});
|
||||
@@ -78,10 +59,8 @@ export function DocumentLayoutDrawer(props: {
|
||||
}) {
|
||||
const { documentFragmentRef, versionFragmentRef } = props;
|
||||
|
||||
const organizationId = useOrganizationId();
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const [isEditingApprover, setIsEditingApprover] = useState(false);
|
||||
const [isEditingType, setIsEditingType] = useState(false);
|
||||
const [isEditingClassification, setIsEditingClassification] = useState(false);
|
||||
|
||||
@@ -91,13 +70,10 @@ export function DocumentLayoutDrawer(props: {
|
||||
const isDraft = version.status === "DRAFT";
|
||||
const canEdit = document.canUpdate;
|
||||
|
||||
const approvers = document.approvers.edges.map(e => e.node);
|
||||
|
||||
const { control, handleSubmit, reset } = useFormWithSchema(
|
||||
schema,
|
||||
{
|
||||
defaultValues: {
|
||||
approverIds: approvers.map(a => a.id),
|
||||
documentType: document.documentType,
|
||||
classification: version.classification,
|
||||
},
|
||||
@@ -113,20 +89,6 @@ export function DocumentLayoutDrawer(props: {
|
||||
},
|
||||
);
|
||||
|
||||
const handleUpdateApprover = async (data: { approverIds: string[] }) => {
|
||||
await updateDocument({
|
||||
variables: {
|
||||
input: {
|
||||
id: document.id,
|
||||
approverIds: data.approverIds,
|
||||
},
|
||||
},
|
||||
onSuccess: () => {
|
||||
setIsEditingApprover(false);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleUpdateDocumentType = async (data: {
|
||||
documentType: (typeof documentTypes)[number];
|
||||
}) => {
|
||||
@@ -164,42 +126,6 @@ export function DocumentLayoutDrawer(props: {
|
||||
<div className="text-base text-txt-primary font-medium mb-4">
|
||||
{__("Properties")}
|
||||
</div>
|
||||
<PropertyRow label={__("Approvers")}>
|
||||
{isEditingApprover
|
||||
? (
|
||||
<EditablePropertyContent
|
||||
onSave={() => void handleSubmit(handleUpdateApprover)()}
|
||||
onCancel={() => {
|
||||
setIsEditingApprover(false);
|
||||
reset();
|
||||
}}
|
||||
disabled={isUpdatingDocument}
|
||||
>
|
||||
<PeopleMultiSelectField
|
||||
name="approverIds"
|
||||
control={control}
|
||||
organizationId={organizationId}
|
||||
selectedPeople={approvers}
|
||||
placeholder={__("Add approvers...")}
|
||||
/>
|
||||
</EditablePropertyContent>
|
||||
)
|
||||
: (
|
||||
<ReadOnlyPropertyContent
|
||||
onEdit={() => setIsEditingApprover(true)}
|
||||
canEdit={canEdit}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{approvers.map(approver => (
|
||||
<Badge key={approver.id} variant="highlight" size="md" className="gap-2">
|
||||
<Avatar name={approver.fullName} />
|
||||
{approver.fullName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</ReadOnlyPropertyContent>
|
||||
)}
|
||||
</PropertyRow>
|
||||
<PropertyRow label={__("Type")}>
|
||||
{isEditingType
|
||||
? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { documentTypes, getDocumentTypeLabel, sprintf } from "@probo/helpers";
|
||||
import { useList } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCheckmark1, IconCrossLargeX, IconSignature, IconTrashCan, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
|
||||
import { Button, Card, Checkbox, IconArchive, IconArrowDown, IconCrossLargeX, IconSignature, IconTrashCan, IconUpload, Option, Select, Tbody, Th, Thead, Tr, useConfirm } from "@probo/ui";
|
||||
import { type ComponentProps, use, useEffect, useRef, useState, useTransition } from "react";
|
||||
import { usePaginationFragment } from "react-relay";
|
||||
import { ConnectionHandler, graphql } from "relay-runtime";
|
||||
@@ -295,7 +295,7 @@ export function DocumentList(props: {
|
||||
onChange={() => reset(documents.map(d => d.id))}
|
||||
/>
|
||||
</Th>
|
||||
<SortableTh field="TITLE" className="min-w-0" onOrderChange={handleOrderChange}>
|
||||
<SortableTh field="TITLE" className="min-w-0 pr-12" onOrderChange={handleOrderChange}>
|
||||
{__("Name")}
|
||||
</SortableTh>
|
||||
<Th className="w-24">{__("Status")}</Th>
|
||||
@@ -306,13 +306,14 @@ export function DocumentList(props: {
|
||||
<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-20">{__("Signatures")}</Th>
|
||||
{hasAnyAction && <Th className="w-18"></Th>}
|
||||
</Tr>
|
||||
)
|
||||
: (
|
||||
<Tr>
|
||||
<Th colspan={10} compact>
|
||||
<Th colspan={hasAnyAction ? 11 : 10} compact>
|
||||
<div className="flex justify-between items-center h-8">
|
||||
<div className="flex gap-2 items-center">
|
||||
{sprintf(__("%s documents selected"), selection.length)}
|
||||
@@ -357,7 +358,10 @@ export function DocumentList(props: {
|
||||
<>
|
||||
{canUpdateAny && (
|
||||
<PublishDocumentsDialog documentIds={selection} onSave={clear}>
|
||||
<Button icon={IconCheckmark1} className="py-0.5 px-2 text-xs h-6 min-h-6">
|
||||
<Button
|
||||
icon={IconUpload}
|
||||
className="py-0.5 px-2 text-xs h-6 min-h-6"
|
||||
>
|
||||
{__("Publish")}
|
||||
</Button>
|
||||
</PublishDocumentsDialog>
|
||||
|
||||
@@ -17,20 +17,32 @@ const fragment = graphql`
|
||||
classification
|
||||
updatedAt
|
||||
canDelete: permission(action: "core:document:delete")
|
||||
approvers(first: 100) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
lastVersion: versions(first: 1 orderBy: { field: CREATED_AT direction: DESC }) {
|
||||
recentVersions: versions(first: 2 orderBy: { field: CREATED_AT direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
status
|
||||
version
|
||||
approvalQuorums(first: 1, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
status
|
||||
decisions(first: 20) {
|
||||
totalCount
|
||||
edges {
|
||||
node {
|
||||
approver {
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
approvedDecisions: decisions(first: 0 filter: { states: [APPROVED] }) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
signatures(first: 0 filter: { activeContract: true }) {
|
||||
totalCount
|
||||
}
|
||||
@@ -74,11 +86,22 @@ export function DocumentListItem(props: {
|
||||
fragment,
|
||||
fragmentRef,
|
||||
);
|
||||
const lastVersion = document.lastVersion.edges[0].node;
|
||||
const lastVersion = document.recentVersions.edges[0].node;
|
||||
const approverQuorum = lastVersion.approvalQuorums?.edges?.[0]?.node
|
||||
?? document.recentVersions.edges[1]?.node.approvalQuorums?.edges?.[0]?.node;
|
||||
|
||||
const isDraft = lastVersion.status === "DRAFT";
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const statusVariant = {
|
||||
DRAFT: "neutral",
|
||||
PUBLISHED: "success",
|
||||
} as const;
|
||||
|
||||
const statusLabel = {
|
||||
DRAFT: __("Draft"),
|
||||
PUBLISHED: __("Published"),
|
||||
} as const;
|
||||
|
||||
const [deleteDocument] = useMutationWithToasts<DocumentListItem_deleteMutation>(
|
||||
deleteDocumentMutation,
|
||||
{
|
||||
@@ -119,8 +142,8 @@ export function DocumentListItem(props: {
|
||||
<div className="flex gap-4 items-center">{document.title}</div>
|
||||
</Td>
|
||||
<Td className="w-24">
|
||||
<Badge variant={isDraft ? "neutral" : "success"}>
|
||||
{isDraft ? __("Draft") : __("Published")}
|
||||
<Badge variant={statusVariant[lastVersion.status]}>
|
||||
{statusLabel[lastVersion.status]}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td className="w-20">
|
||||
@@ -134,9 +157,24 @@ export function DocumentListItem(props: {
|
||||
{getDocumentClassificationLabel(__, document.classification)}
|
||||
</Td>
|
||||
<Td className="w-60">
|
||||
{document.approvers.edges.map(({ node }) => node.fullName).join(", ")}
|
||||
{(() => {
|
||||
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}`
|
||||
: "—";
|
||||
})()}
|
||||
</Td>
|
||||
<Td className="w-20">
|
||||
{lastVersion.signedSignatures.totalCount}
|
||||
/
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { formatError } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
IconSend,
|
||||
IconUpload,
|
||||
IconWarning,
|
||||
Textarea,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type Ref, useImperativeHandle, useRef } from "react";
|
||||
import { useFragment, useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { PublishDialog_documentFragment$key } from "#/__generated__/core/PublishDialog_documentFragment.graphql";
|
||||
import type { PublishDialog_publishMutation } from "#/__generated__/core/PublishDialog_publishMutation.graphql";
|
||||
import type { PublishDialog_requestApprovalMutation } from "#/__generated__/core/PublishDialog_requestApprovalMutation.graphql";
|
||||
import { PeopleMultiSelectField } from "#/components/form/PeopleMultiSelectField";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
export type PublishDialogRef = {
|
||||
open: () => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const publishMutation = graphql`
|
||||
mutation PublishDialog_publishMutation($input: PublishDocumentVersionInput!) {
|
||||
publishDocumentVersion(input: $input) {
|
||||
document {
|
||||
id
|
||||
status
|
||||
}
|
||||
documentVersion {
|
||||
id
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const requestApprovalMutation = graphql`
|
||||
mutation PublishDialog_requestApprovalMutation(
|
||||
$input: RequestDocumentVersionApprovalInput!
|
||||
) {
|
||||
requestDocumentVersionApproval(input: $input) {
|
||||
approvalQuorum {
|
||||
id
|
||||
status
|
||||
decisions(first: 0) {
|
||||
totalCount
|
||||
}
|
||||
approvedDecisions: decisions(first: 0 filter: { states: [APPROVED] }) {
|
||||
totalCount
|
||||
}
|
||||
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 PublishDialog({
|
||||
ref,
|
||||
documentId,
|
||||
documentFragmentRef,
|
||||
hasPendingApproval,
|
||||
onSuccess,
|
||||
}: Props) {
|
||||
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({
|
||||
changelog: z.string().min(1, __("Changelog is required")),
|
||||
approverIds: z.array(z.string()),
|
||||
});
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
register,
|
||||
reset,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useFormWithSchema(schema, {
|
||||
defaultValues: {
|
||||
changelog: "",
|
||||
approverIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => {
|
||||
reset({
|
||||
changelog: "",
|
||||
approverIds: previousApproverIds,
|
||||
});
|
||||
dialogRef.current?.open();
|
||||
},
|
||||
}));
|
||||
|
||||
const [publishVersion, isPublishing] = useMutation<PublishDialog_publishMutation>(publishMutation);
|
||||
const [requestApproval, isRequesting] = useMutation<PublishDialog_requestApprovalMutation>(requestApprovalMutation);
|
||||
|
||||
const isBusy = isPublishing || isRequesting;
|
||||
const approverIds = watch("approverIds");
|
||||
const actionRef = useRef<"publish" | "request-approval">("publish");
|
||||
|
||||
const handlePublish = (data: z.infer<typeof schema>) => {
|
||||
publishVersion({
|
||||
variables: { input: { documentId, changelog: data.changelog } },
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to publish document"), errors),
|
||||
variant: "error",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Document published successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
onSuccess();
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onRequestApproval = (data: z.infer<typeof schema>) => {
|
||||
requestApproval({
|
||||
variables: {
|
||||
input: {
|
||||
documentId,
|
||||
approverIds: data.approverIds,
|
||||
changelog: data.changelog,
|
||||
},
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to request approval"), errors),
|
||||
variant: "error",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Approval requested successfully."),
|
||||
variant: "success",
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
reset();
|
||||
onSuccess();
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({ title: __("Error"), description: error.message, variant: "error" });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog className="max-w-xl" ref={dialogRef} title={__("Publish document")}>
|
||||
<form
|
||||
onSubmit={e => void handleSubmit((data) => {
|
||||
if (actionRef.current === "publish") {
|
||||
handlePublish(data);
|
||||
} else {
|
||||
onRequestApproval(data);
|
||||
}
|
||||
})(e)}
|
||||
>
|
||||
<DialogContent padded>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="changelog" className="text-sm font-medium text-txt-primary mb-1 block">
|
||||
{__("Changelog")}
|
||||
</label>
|
||||
<Textarea
|
||||
id="changelog"
|
||||
aria-label={__("Changelog")}
|
||||
required
|
||||
autogrow
|
||||
placeholder={__("Describe what changed in this version...")}
|
||||
{...register("changelog")}
|
||||
/>
|
||||
{errors.changelog?.message && (
|
||||
<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>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
{hasPendingApproval
|
||||
? (
|
||||
<Button
|
||||
type="submit"
|
||||
icon={IconUpload}
|
||||
onClick={() => { actionRef.current = "publish"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Publish now")}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="secondary"
|
||||
icon={IconUpload}
|
||||
onClick={() => { actionRef.current = "publish"; }}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{__("Publish now")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
icon={IconSend}
|
||||
onClick={() => { actionRef.current = "request-approval"; }}
|
||||
disabled={isBusy || approverIds.length === 0}
|
||||
>
|
||||
{__("Request approval")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { formatError, sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
Field,
|
||||
IconWarning,
|
||||
Textarea,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { type ReactNode } from "react";
|
||||
import { useMutation } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { PublishDocumentsDialogMutation } from "#/__generated__/core/PublishDocumentsDialogMutation.graphql";
|
||||
import { useFormWithSchema } from "#/hooks/useFormWithSchema";
|
||||
import { useMutationWithToasts } from "#/hooks/useMutationWithToasts";
|
||||
|
||||
type Props = {
|
||||
documentIds: string[];
|
||||
@@ -28,11 +29,12 @@ const documentsPublishMutation = graphql`
|
||||
$input: BulkPublishDocumentVersionsInput!
|
||||
) {
|
||||
bulkPublishDocumentVersions(input: $input) {
|
||||
documentEdges {
|
||||
node {
|
||||
id
|
||||
...DocumentListItemFragment
|
||||
}
|
||||
documentVersions {
|
||||
id
|
||||
}
|
||||
documents {
|
||||
id
|
||||
...DocumentListItemFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,49 +46,56 @@ export function PublishDocumentsDialog({
|
||||
onSave,
|
||||
}: Props) {
|
||||
const { __ } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const dialogRef = useDialogRef();
|
||||
|
||||
const schema = z.object({
|
||||
changelog: z.string().min(1, __("Changelog is required")),
|
||||
});
|
||||
|
||||
const [publishMutation]
|
||||
= useMutationWithToasts<PublishDocumentsDialogMutation>(
|
||||
documentsPublishMutation,
|
||||
{
|
||||
successMessage: (response) => {
|
||||
const actualPublishedCount
|
||||
= response.bulkPublishDocumentVersions.documentEdges.length;
|
||||
return sprintf(__("%s documents published"), actualPublishedCount);
|
||||
},
|
||||
errorMessage: sprintf(
|
||||
__("Failed to publish %s documents"),
|
||||
documentIds.length,
|
||||
),
|
||||
},
|
||||
);
|
||||
const [publishMutation, isPublishing] = useMutation<PublishDocumentsDialogMutation>(documentsPublishMutation);
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
formState: { isSubmitting, errors },
|
||||
formState: { errors },
|
||||
} = useFormWithSchema(schema, {
|
||||
defaultValues: {
|
||||
changelog: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data: z.infer<typeof schema>) => {
|
||||
await publishMutation({
|
||||
const onSubmit = (data: z.infer<typeof schema>) => {
|
||||
publishMutation({
|
||||
variables: {
|
||||
input: {
|
||||
documentIds,
|
||||
changelog: data.changelog,
|
||||
},
|
||||
},
|
||||
onSuccess: () => {
|
||||
dialogRef.current?.close();
|
||||
onSave();
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to publish documents"), errors),
|
||||
variant: "error",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: sprintf(__("%s documents published"), documentIds.length),
|
||||
variant: "success",
|
||||
});
|
||||
dialogRef.current?.close();
|
||||
onSave();
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: error.message,
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -96,22 +105,37 @@ export function PublishDocumentsDialog({
|
||||
className="max-w-xl"
|
||||
ref={dialogRef}
|
||||
trigger={children}
|
||||
title={<Breadcrumb items={[__("Documents"), __("Publish documents")]} />}
|
||||
title={__("Publish documents")}
|
||||
>
|
||||
<form onSubmit={e => void handleSubmit(onSubmit)(e)}>
|
||||
<DialogContent padded>
|
||||
<Field
|
||||
id="changelog"
|
||||
aria-label={__("Changelog")}
|
||||
required
|
||||
variant="title"
|
||||
placeholder={__("Changelog")}
|
||||
{...register("changelog")}
|
||||
error={errors.changelog?.message}
|
||||
/>
|
||||
<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>
|
||||
<div>
|
||||
<label htmlFor="changelog" className="text-sm font-medium text-txt-primary mb-1 block">
|
||||
{__("Changelog")}
|
||||
</label>
|
||||
<Textarea
|
||||
id="changelog"
|
||||
aria-label={__("Changelog")}
|
||||
required
|
||||
autogrow
|
||||
placeholder={__("Describe what changed in this version...")}
|
||||
{...register("changelog")}
|
||||
/>
|
||||
{errors.changelog?.message && (
|
||||
<p className="text-xs text-txt-danger mt-1">{errors.changelog.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
<Button type="submit" disabled={isPublishing}>
|
||||
{sprintf(__("Publish %s documents"), documentIds.length)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Badge, Spinner } from "@probo/ui";
|
||||
import { Suspense } from "react";
|
||||
import { type PreloadedQuery, useFragment, usePreloadedQuery } from "react-relay";
|
||||
import { graphql } from "relay-runtime";
|
||||
|
||||
import type { DocumentApprovalsPage_versionFragment$key } from "#/__generated__/core/DocumentApprovalsPage_versionFragment.graphql";
|
||||
import type { DocumentApprovalsPageQuery } from "#/__generated__/core/DocumentApprovalsPageQuery.graphql";
|
||||
|
||||
import { DocumentApprovalList } from "./_components/DocumentApprovalList";
|
||||
|
||||
export const documentApprovalsPageQuery = graphql`
|
||||
query DocumentApprovalsPageQuery($documentId: ID! $versionId: ID! $versionSpecified: Boolean!) {
|
||||
# We use this on /documents/:documentId
|
||||
document: node(id: $documentId) @skip(if: $versionSpecified) {
|
||||
__typename
|
||||
... on Document {
|
||||
lastVersion: versions(
|
||||
first: 1
|
||||
orderBy: { field: CREATED_AT, direction: DESC }
|
||||
) {
|
||||
edges {
|
||||
node {
|
||||
...DocumentApprovalList_versionFragment
|
||||
...DocumentApprovalsPage_versionFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# We use this on /documents/:documentId/versions/:versionId
|
||||
version: node(id: $versionId) @include(if: $versionSpecified) {
|
||||
__typename
|
||||
...DocumentApprovalList_versionFragment
|
||||
...DocumentApprovalsPage_versionFragment
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const versionFragment = graphql`
|
||||
fragment DocumentApprovalsPage_versionFragment on DocumentVersion {
|
||||
approvalQuorums(first: 100, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
status
|
||||
createdAt
|
||||
decisions(first: 100, orderBy: { field: CREATED_AT, direction: ASC }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
approver {
|
||||
fullName
|
||||
}
|
||||
state
|
||||
comment
|
||||
decidedAt
|
||||
createdAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function DocumentApprovalsPage(props: {
|
||||
queryRef: PreloadedQuery<DocumentApprovalsPageQuery>;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { queryRef, onRefetch } = props;
|
||||
|
||||
const { document, version } = usePreloadedQuery<DocumentApprovalsPageQuery>(
|
||||
documentApprovalsPageQuery,
|
||||
queryRef,
|
||||
);
|
||||
|
||||
if ((version && version.__typename !== "DocumentVersion") || (document && document.__typename !== "Document")) {
|
||||
throw new Error("invalid type for node");
|
||||
}
|
||||
if (!document && !version) {
|
||||
throw new Error("no document or version specified");
|
||||
}
|
||||
|
||||
const lastVersionNode = document?.lastVersion.edges[0]?.node;
|
||||
const approvalListRef = version ?? lastVersionNode;
|
||||
const versionFragmentRef = (version ?? lastVersionNode) as DocumentApprovalsPage_versionFragment$key | null;
|
||||
if (!approvalListRef || !versionFragmentRef) {
|
||||
throw new Error("no version found");
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner centered />}>
|
||||
<DocumentApprovalsPageContent
|
||||
approvalListRef={approvalListRef}
|
||||
versionFragmentRef={versionFragmentRef}
|
||||
onRefetch={onRefetch}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentApprovalsPageContent(props: {
|
||||
approvalListRef: Parameters<typeof DocumentApprovalList>[0]["versionFragmentRef"];
|
||||
versionFragmentRef: DocumentApprovalsPage_versionFragment$key;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { approvalListRef, versionFragmentRef, onRefetch } = props;
|
||||
const { __, dateTimeFormat } = useTranslate();
|
||||
|
||||
const versionData = useFragment(versionFragment, versionFragmentRef);
|
||||
const quorumEdges = versionData.approvalQuorums?.edges ?? [];
|
||||
const pastQuorums = quorumEdges.slice(1);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<DocumentApprovalList versionFragmentRef={approvalListRef} onRefetch={onRefetch} />
|
||||
|
||||
{pastQuorums.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-txt-secondary">{__("Previous approval requests")}</h3>
|
||||
{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>
|
||||
<span className="text-xs text-txt-secondary">
|
||||
{dateTimeFormat(quorum.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border-solid">
|
||||
{quorum.decisions.edges.map(({ node: decision }) => (
|
||||
<div key={decision.id} className="flex items-center gap-3 py-2">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-sm text-txt-primary font-medium">
|
||||
{decision.approver.fullName}
|
||||
</div>
|
||||
<div className="text-xs text-txt-secondary">
|
||||
{decision.decidedAt && (decision.state === "APPROVED" || decision.state === "REJECTED") && dateTimeFormat(decision.decidedAt)}
|
||||
</div>
|
||||
{decision.comment && (
|
||||
<div className="text-xs text-txt-secondary italic">{decision.comment}</div>
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Suspense, useCallback, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useOutletContext, useParams } from "react-router";
|
||||
|
||||
import type { DocumentApprovalsPageQuery } from "#/__generated__/core/DocumentApprovalsPageQuery.graphql";
|
||||
import { LinkCardSkeleton } from "#/components/skeletons/LinkCardSkeleton";
|
||||
|
||||
import { DocumentApprovalsPage, documentApprovalsPageQuery } from "./DocumentApprovalsPage";
|
||||
|
||||
function DocumentApprovalsPageQueryLoader() {
|
||||
const { documentId, versionId } = useParams();
|
||||
if (!documentId) {
|
||||
throw new Error(":documentId missing in route params");
|
||||
}
|
||||
|
||||
const { onRefetch: parentRefetch, approvalRequestedAt }
|
||||
= useOutletContext<{
|
||||
onRefetch: () => void;
|
||||
approvalRequestedAt?: number;
|
||||
}>();
|
||||
|
||||
const [queryRef, loadQuery] = useQueryLoader<DocumentApprovalsPageQuery>(documentApprovalsPageQuery);
|
||||
|
||||
const loadQueryParams = useCallback(() => {
|
||||
loadQuery(
|
||||
{
|
||||
documentId: documentId,
|
||||
versionId: versionId ?? "",
|
||||
versionSpecified: !!versionId,
|
||||
},
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
}, [documentId, versionId, loadQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queryRef) {
|
||||
loadQuery(
|
||||
{
|
||||
documentId: documentId,
|
||||
versionId: versionId ?? "",
|
||||
versionSpecified: !!versionId,
|
||||
},
|
||||
{ fetchPolicy: "network-only" },
|
||||
);
|
||||
}
|
||||
}, [queryRef, documentId, versionId, loadQuery]);
|
||||
|
||||
// Reload approvals data whenever a new approval round is requested from the layout
|
||||
useEffect(() => {
|
||||
if (approvalRequestedAt) {
|
||||
loadQueryParams();
|
||||
}
|
||||
}, [approvalRequestedAt, loadQueryParams]);
|
||||
|
||||
const onRefetch = useCallback(() => {
|
||||
parentRefetch();
|
||||
loadQueryParams();
|
||||
}, [parentRefetch, loadQueryParams]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <LinkCardSkeleton />;
|
||||
}
|
||||
|
||||
return <DocumentApprovalsPage queryRef={queryRef} onRefetch={onRefetch} />;
|
||||
}
|
||||
|
||||
export default function DocumentApprovalsPageLoader() {
|
||||
return (
|
||||
<Suspense fallback={<LinkCardSkeleton />}>
|
||||
<DocumentApprovalsPageQueryLoader />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
IconPlusSmall,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { Suspense, useState } from "react";
|
||||
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 { 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 {
|
||||
decisions(first: 100, orderBy: { field: CREATED_AT, direction: ASC })
|
||||
@connection(key: "DocumentApprovalList_decisions") {
|
||||
__id
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
approver {
|
||||
id
|
||||
}
|
||||
...DocumentApprovalListItemFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const addApproverMutation = graphql`
|
||||
mutation DocumentApprovalList_addApproverMutation(
|
||||
$input: AddDocumentVersionApproverInput!
|
||||
$connections: [ID!]!
|
||||
) {
|
||||
addDocumentVersionApprover(input: $input) {
|
||||
approvalDecisionEdge @appendEdge(connections: $connections) {
|
||||
node {
|
||||
id
|
||||
approver {
|
||||
id
|
||||
}
|
||||
...DocumentApprovalListItemFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function DocumentApprovalList(props: {
|
||||
versionFragmentRef: DocumentApprovalList_versionFragment$key;
|
||||
onRefetch: () => void;
|
||||
}) {
|
||||
const { versionFragmentRef, onRefetch } = 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);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{canManage && (
|
||||
<div className="flex justify-end pb-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon={IconPlusSmall}
|
||||
onClick={() => dialogRef.current?.open()}
|
||||
>
|
||||
{__("Add approver")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{edges.length === 0
|
||||
? (
|
||||
<div className="text-sm text-txt-secondary text-center py-8">
|
||||
{__("No approval decisions yet.")}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="divide-y divide-border-solid">
|
||||
{edges.map(({ node }) => (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconClock,
|
||||
IconTrashCan,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { useFragment, useMutation } 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";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment DocumentApprovalListItemFragment on DocumentVersionApprovalDecision {
|
||||
id
|
||||
approver {
|
||||
fullName
|
||||
}
|
||||
state
|
||||
comment
|
||||
decidedAt
|
||||
createdAt
|
||||
canApprove: permission(action: "core:document-version:approve")
|
||||
canReject: permission(action: "core:document-version:reject")
|
||||
documentVersion {
|
||||
id
|
||||
document {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
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 { __, dateTimeFormat } = useTranslate();
|
||||
const { toast } = useToast();
|
||||
const organizationId = useOrganizationId();
|
||||
|
||||
const decision = useFragment(fragment, fragmentRef);
|
||||
|
||||
const isPending = decision.state === "PENDING";
|
||||
const isApproved = decision.state === "APPROVED";
|
||||
const isRejected = decision.state === "REJECTED";
|
||||
|
||||
const [removeApprover, isRemoving] = useMutation<DocumentApprovalListItem_removeApproverMutation>(
|
||||
removeApproverMutation,
|
||||
);
|
||||
|
||||
const reviewUrl = `/organizations/${organizationId}/employee/approvals/${decision.documentVersion.document.id}`;
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-center py-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-txt-primary font-medium">
|
||||
{decision.approver.fullName}
|
||||
</div>
|
||||
<div className="text-xs text-txt-secondary flex items-center gap-1">
|
||||
{isApproved && <IconCircleCheck size={16} className="text-txt-accent" />}
|
||||
{isRejected && <IconCircleX size={16} className="text-txt-danger" />}
|
||||
{isPending && <IconClock size={16} />}
|
||||
<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))}
|
||||
</span>
|
||||
</div>
|
||||
{decision.comment && (
|
||||
<div className="text-xs text-txt-secondary italic">
|
||||
{decision.comment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{isApproved && (
|
||||
<Badge variant="success">{__("Approved")}</Badge>
|
||||
)}
|
||||
{isRejected && (
|
||||
<Badge variant="danger">{__("Rejected")}</Badge>
|
||||
)}
|
||||
{isPending && (decision.canApprove || decision.canReject) && (
|
||||
<Button variant="secondary" to={reviewUrl} target="_blank">
|
||||
{__("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 && (
|
||||
<Badge variant="warning">{__("Pending")}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
import { formatError, type GraphQLError } from "@probo/helpers";
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconRadioUnchecked,
|
||||
Spinner,
|
||||
Textarea,
|
||||
useDialogRef,
|
||||
useToast,
|
||||
} from "@probo/ui";
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type PreloadedQuery,
|
||||
useFragment,
|
||||
useMutation,
|
||||
usePreloadedQuery,
|
||||
} from "react-relay";
|
||||
import { useNavigate } from "react-router";
|
||||
import { graphql } from "relay-runtime";
|
||||
import { useWindowSize } from "usehooks-ts";
|
||||
|
||||
import type { DocumentApprovePage_approveMutation } from "#/__generated__/core/DocumentApprovePage_approveMutation.graphql";
|
||||
import type { DocumentApprovePage_rejectMutation } from "#/__generated__/core/DocumentApprovePage_rejectMutation.graphql";
|
||||
import type { DocumentApprovePageDecisionFragment$key } from "#/__generated__/core/DocumentApprovePageDecisionFragment.graphql";
|
||||
import type { DocumentApprovePageDocumentFragment$key } from "#/__generated__/core/DocumentApprovePageDocumentFragment.graphql";
|
||||
import type { DocumentApprovePageExportPDFMutation } from "#/__generated__/core/DocumentApprovePageExportPDFMutation.graphql";
|
||||
import type { DocumentApprovePageQuery } from "#/__generated__/core/DocumentApprovePageQuery.graphql";
|
||||
import type { DocumentApprovePageVersionRowFragment$key } from "#/__generated__/core/DocumentApprovePageVersionRowFragment.graphql";
|
||||
import { PDFPreview } from "#/components/documents/PDFPreview";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
export const documentApprovePageQuery = graphql`
|
||||
query DocumentApprovePageQuery($documentId: ID!) {
|
||||
viewer @required(action: THROW) {
|
||||
approvableDocument(id: $documentId) {
|
||||
...DocumentApprovePageDocumentFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const documentFragment = graphql`
|
||||
fragment DocumentApprovePageDocumentFragment on EmployeeDocument {
|
||||
id
|
||||
title
|
||||
versions(first: 100, orderBy: { field: CREATED_AT, direction: DESC })
|
||||
@required(action: THROW) {
|
||||
edges @required(action: THROW) {
|
||||
node @required(action: THROW) {
|
||||
id
|
||||
...DocumentApprovePageVersionRowFragment
|
||||
approvalDecision {
|
||||
...DocumentApprovePageDecisionFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const versionRowFragment = graphql`
|
||||
fragment DocumentApprovePageVersionRowFragment on EmployeeDocumentVersion {
|
||||
id
|
||||
version
|
||||
publishedAt
|
||||
approvalDecision {
|
||||
state
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const decisionFragment = graphql`
|
||||
fragment DocumentApprovePageDecisionFragment on DocumentVersionApprovalDecision {
|
||||
id
|
||||
state
|
||||
canApprove: permission(action: "core:document-version:approve")
|
||||
canReject: permission(action: "core:document-version:reject")
|
||||
documentVersion {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const approveDocumentVersionMutation = graphql`
|
||||
mutation DocumentApprovePage_approveMutation(
|
||||
$input: ApproveDocumentVersionInput!
|
||||
) {
|
||||
approveDocumentVersion(input: $input) {
|
||||
approvalDecision {
|
||||
id
|
||||
state
|
||||
decidedAt
|
||||
comment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const rejectDocumentVersionMutation = graphql`
|
||||
mutation DocumentApprovePage_rejectMutation(
|
||||
$input: RejectDocumentVersionInput!
|
||||
) {
|
||||
rejectDocumentVersion(input: $input) {
|
||||
approvalDecision {
|
||||
id
|
||||
state
|
||||
decidedAt
|
||||
comment
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const exportPDFMutation = graphql`
|
||||
mutation DocumentApprovePageExportPDFMutation(
|
||||
$input: ExportDocumentVersionPDFInput!
|
||||
) {
|
||||
exportDocumentVersionPDF(input: $input) {
|
||||
data
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function DocumentApprovePage(props: {
|
||||
queryRef: PreloadedQuery<DocumentApprovePageQuery>;
|
||||
}) {
|
||||
const { queryRef } = props;
|
||||
const data = usePreloadedQuery<DocumentApprovePageQuery>(
|
||||
documentApprovePageQuery,
|
||||
queryRef,
|
||||
);
|
||||
|
||||
const document = data.viewer.approvableDocument;
|
||||
if (!document) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <DocumentApproveContent fKey={document} />;
|
||||
}
|
||||
|
||||
function VersionRow({
|
||||
fKey,
|
||||
isSelected,
|
||||
onSelect,
|
||||
}: {
|
||||
fKey: DocumentApprovePageVersionRowFragment$key;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const versionData = useFragment(versionRowFragment, fKey);
|
||||
const approvalDecision = versionData.approvalDecision;
|
||||
const state = approvalDecision?.state;
|
||||
const isApproved = state === "APPROVED";
|
||||
const isRejected = state === "REJECTED";
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 py-3 px-4 transition-colors cursor-pointer",
|
||||
isSelected
|
||||
? "bg-blue-50 border-l-4 border-blue-500"
|
||||
: "bg-transparent hover:bg-level-1",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-level-2 flex-shrink-0">
|
||||
{isApproved
|
||||
? <IconCircleCheck size={20} className="text-txt-success" />
|
||||
: isRejected
|
||||
? <IconCircleX size={20} className="text-txt-danger" />
|
||||
: <IconRadioUnchecked size={20} className="text-txt-tertiary" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={clsx(
|
||||
"text-sm font-medium truncate",
|
||||
(isApproved || isRejected) ? "text-txt-tertiary" : "text-txt-primary",
|
||||
)}
|
||||
>
|
||||
{versionData.publishedAt
|
||||
? `v${versionData.version} - ${(() => {
|
||||
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.version}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
{isApproved
|
||||
? <Badge variant="success">{__("Approved")}</Badge>
|
||||
: isRejected
|
||||
? <Badge variant="danger">{__("Rejected")}</Badge>
|
||||
: isSelected
|
||||
? <Badge variant="info">{__("In review")}</Badge>
|
||||
: <Badge variant="warning">{__("Pending")}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ViewerDecision(props: {
|
||||
fragmentRef: DocumentApprovePageDecisionFragment$key;
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const { fragmentRef, onBack } = props;
|
||||
const { __ } = useTranslate();
|
||||
const decision = useFragment(decisionFragment, fragmentRef);
|
||||
const rejectDialogRef = useDialogRef();
|
||||
const [rejectComment, setRejectComment] = useState("");
|
||||
const { toast } = useToast();
|
||||
|
||||
const [approveVersion, isApproving] = useMutation<DocumentApprovePage_approveMutation>(
|
||||
approveDocumentVersionMutation,
|
||||
);
|
||||
|
||||
const [rejectVersion, isRejecting] = useMutation<DocumentApprovePage_rejectMutation>(
|
||||
rejectDocumentVersionMutation,
|
||||
);
|
||||
|
||||
const isPending = decision.state === "PENDING";
|
||||
const isApproved = decision.state === "APPROVED";
|
||||
const isRejected = decision.state === "REJECTED";
|
||||
|
||||
if (!decision.canApprove && !decision.canReject) {
|
||||
return (
|
||||
<Button onClick={onBack} className="h-10 w-full" variant="secondary">
|
||||
{__("Back to Documents")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isApproved) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-txt-accent mb-4">
|
||||
<IconCircleCheck size={20} />
|
||||
<span>{__("You have approved this document.")}</span>
|
||||
</div>
|
||||
<Button onClick={onBack} className="h-10 w-full" variant="secondary">
|
||||
{__("Back to Documents")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (isRejected) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm text-txt-danger mb-4">
|
||||
<IconCircleX size={20} />
|
||||
<span>{__("You have rejected this document.")}</span>
|
||||
</div>
|
||||
<Button onClick={onBack} className="h-10 w-full" variant="secondary">
|
||||
{__("Back to Documents")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isPending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
{decision.canReject && (
|
||||
<Button
|
||||
variant="danger"
|
||||
className="flex-1"
|
||||
disabled={isApproving || isRejecting}
|
||||
onClick={() => rejectDialogRef.current?.open()}
|
||||
>
|
||||
{__("Reject")}
|
||||
</Button>
|
||||
)}
|
||||
{decision.canApprove && (
|
||||
<Button
|
||||
className="flex-1"
|
||||
disabled={isApproving || isRejecting}
|
||||
icon={isApproving ? Spinner : undefined}
|
||||
onClick={() => {
|
||||
approveVersion({
|
||||
variables: {
|
||||
input: {
|
||||
documentVersionId: decision.documentVersion.id,
|
||||
},
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to approve document"), errors),
|
||||
variant: "error",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Document approved successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: error.message,
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{__("Approve")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-txt-tertiary">
|
||||
{__("By clicking Approve, I consent to approve this document electronically and agree that my electronic signature has the same legal validity as a handwritten signature.")}
|
||||
</p>
|
||||
<Button onClick={onBack} className="w-full" variant="secondary">
|
||||
{__("Back to Documents")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Dialog ref={rejectDialogRef} title={__("Reject Document")}>
|
||||
<DialogContent padded>
|
||||
<p className="text-sm text-txt-secondary mb-4">
|
||||
{__("Please provide a reason for rejecting this document. The document will be sent back to draft status.")}
|
||||
</p>
|
||||
<Textarea
|
||||
placeholder={__("Reason for rejection...")}
|
||||
value={rejectComment}
|
||||
onChange={e => setRejectComment(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={isRejecting}
|
||||
icon={isRejecting ? Spinner : undefined}
|
||||
onClick={() => {
|
||||
rejectVersion({
|
||||
variables: {
|
||||
input: {
|
||||
documentVersionId: decision.documentVersion.id,
|
||||
comment: rejectComment || undefined,
|
||||
},
|
||||
},
|
||||
onCompleted(_, errors) {
|
||||
if (errors?.length) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(__("Failed to reject document"), errors),
|
||||
variant: "error",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: __("Success"),
|
||||
description: __("Document rejected"),
|
||||
variant: "success",
|
||||
});
|
||||
rejectDialogRef.current?.close();
|
||||
}
|
||||
},
|
||||
onError(error) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: error.message,
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{__("Reject Document")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentApproveContent({
|
||||
fKey,
|
||||
}: {
|
||||
fKey: DocumentApprovePageDocumentFragment$key;
|
||||
}) {
|
||||
const { __ } = useTranslate();
|
||||
const navigate = useNavigate();
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = width < 1100;
|
||||
const isDesktop = !isMobile;
|
||||
const organizationId = useOrganizationId();
|
||||
const { toast } = useToast();
|
||||
|
||||
const documentData = useFragment(documentFragment, fKey);
|
||||
const versions = documentData.versions.edges.map(({ node }) => node);
|
||||
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<
|
||||
string | undefined
|
||||
>(() => versions[0]?.id);
|
||||
|
||||
const selectedVersion = versions.find(v => v?.id === selectedVersionId);
|
||||
|
||||
usePageTitle(__("Review and Approve Document"));
|
||||
|
||||
const [exportPDF] = useMutation<DocumentApprovePageExportPDFMutation>(
|
||||
exportPDFMutation,
|
||||
);
|
||||
|
||||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const pdfUrlRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedVersion?.id) return;
|
||||
|
||||
exportPDF({
|
||||
variables: {
|
||||
input: {
|
||||
documentVersionId: selectedVersion.id,
|
||||
withWatermark: true,
|
||||
withSignatures: false,
|
||||
},
|
||||
},
|
||||
onCompleted: (data, errors): void => {
|
||||
if (errors) {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to load PDF"),
|
||||
errors as GraphQLError[],
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data.exportDocumentVersionPDF?.data) {
|
||||
const dataUrl = data.exportDocumentVersionPDF.data;
|
||||
pdfUrlRef.current = dataUrl;
|
||||
setPdfUrl(dataUrl);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: __("Error"),
|
||||
description: formatError(
|
||||
__("Failed to load PDF"),
|
||||
error as GraphQLError,
|
||||
),
|
||||
variant: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
pdfUrlRef.current = null;
|
||||
};
|
||||
}, [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="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">
|
||||
{documentData.title || ""}
|
||||
</h1>
|
||||
|
||||
<Card className="mb-6 overflow-hidden">
|
||||
<div className="divide-y divide-border-solid">
|
||||
{versions.map(version => (
|
||||
<VersionRow
|
||||
key={version.id}
|
||||
fKey={version}
|
||||
isSelected={version.id === selectedVersionId}
|
||||
onSelect={() => setSelectedVersionId(version.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<p className="text-txt-secondary text-sm mb-6">
|
||||
{__("Please review the document carefully before making your decision.")}
|
||||
</p>
|
||||
|
||||
<div className="min-h-[60px]">
|
||||
{(() => {
|
||||
const decision = selectedVersion?.approvalDecision;
|
||||
return decision
|
||||
? (
|
||||
<ViewerDecision
|
||||
fragmentRef={decision}
|
||||
onBack={() =>
|
||||
void navigate(`/organizations/${organizationId}/employee/approvals`)}
|
||||
/>
|
||||
)
|
||||
: null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isDesktop && (
|
||||
<div className="bg-subtle h-full border-l border-border-solid min-h-0">
|
||||
{pdfUrl && (
|
||||
<PDFPreview src={pdfUrl} name={documentData.title || ""} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Spinner } from "@probo/ui";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
import { useParams } from "react-router";
|
||||
|
||||
import type { DocumentApprovePageQuery } from "#/__generated__/core/DocumentApprovePageQuery.graphql";
|
||||
|
||||
import {
|
||||
DocumentApprovePage,
|
||||
documentApprovePageQuery,
|
||||
} from "./DocumentApprovePage";
|
||||
|
||||
function DocumentApprovePageQueryLoader() {
|
||||
const { documentId } = useParams();
|
||||
if (!documentId) {
|
||||
throw new Error(":documentId missing in route params");
|
||||
}
|
||||
|
||||
const [queryRef, loadQuery]
|
||||
= useQueryLoader<DocumentApprovePageQuery>(documentApprovePageQuery);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuery({ documentId });
|
||||
}, [loadQuery, documentId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<DocumentApprovePage queryRef={queryRef} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocumentApprovePageLoader() {
|
||||
return (
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<DocumentApprovePageQueryLoader />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sprintf } from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Avatar, Badge, Button, IconCircleCheck, IconClock } from "@probo/ui";
|
||||
import { Badge, Button, IconCircleCheck, IconClock } from "@probo/ui";
|
||||
import { useFragment } from "react-relay";
|
||||
import { type DataID, graphql } from "relay-runtime";
|
||||
|
||||
@@ -53,7 +53,6 @@ export function DocumentSignatureListItem(props: {
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-center py-3">
|
||||
<Avatar size="l" name={signature.signedBy.fullName} />
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-txt-primary font-medium">
|
||||
{signature.signedBy.fullName}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Avatar, Button } from "@probo/ui";
|
||||
import { Button } from "@probo/ui";
|
||||
import { useFragment } from "react-relay";
|
||||
import { type DataID, graphql } from "relay-runtime";
|
||||
|
||||
@@ -77,7 +77,6 @@ export function DocumentSignaturePlaceholder(props: {
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 items-center py-3">
|
||||
<Avatar size="l" name={person.fullName} />
|
||||
<div className="space-y-1">
|
||||
<div className="text-sm text-txt-primary font-medium">
|
||||
{person.fullName}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
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";
|
||||
|
||||
export const employeeApprovalsPageQuery = graphql`
|
||||
query EmployeeApprovalsPageQuery($organizationId: ID!) {
|
||||
viewer @required(action: THROW) {
|
||||
approvableDocuments(
|
||||
organizationId: $organizationId
|
||||
first: 1000
|
||||
orderBy: { field: CREATED_AT, direction: DESC }
|
||||
) @required(action: THROW) {
|
||||
edges @required(action: THROW) {
|
||||
node @required(action: THROW) {
|
||||
id
|
||||
...ApprovableDocumentRowFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export function EmployeeApprovalsPage(props: {
|
||||
queryRef: PreloadedQuery<EmployeeApprovalsPageQuery>;
|
||||
}) {
|
||||
const { queryRef } = props;
|
||||
const { __ } = useTranslate();
|
||||
const organizationId = useOrganizationId();
|
||||
|
||||
const {
|
||||
viewer: { approvableDocuments },
|
||||
} = usePreloadedQuery<EmployeeApprovalsPageQuery>(
|
||||
employeeApprovalsPageQuery,
|
||||
queryRef,
|
||||
);
|
||||
|
||||
const documents = approvableDocuments.edges.map(edge => edge.node);
|
||||
|
||||
usePageTitle(__("Documents"));
|
||||
|
||||
return (
|
||||
<>
|
||||
{documents.length > 0
|
||||
? (
|
||||
<Card>
|
||||
<table className="w-full table-fixed">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th className="text-left">{__("Name")}</Th>
|
||||
<Th className="w-48 text-left">{__("Type")}</Th>
|
||||
<Th className="w-36 text-left">{__("Classification")}</Th>
|
||||
<Th className="w-40 text-left">{__("Last update")}</Th>
|
||||
<Th className="w-32 text-left">{__("Approved")}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{documents.map(document => (
|
||||
<ApprovableDocumentRow
|
||||
key={document.id}
|
||||
fKey={document}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
))}
|
||||
</Tbody>
|
||||
</table>
|
||||
</Card>
|
||||
)
|
||||
: (
|
||||
<Card padded>
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{__("No documents yet")}
|
||||
</h3>
|
||||
<p className="text-txt-tertiary mb-4">
|
||||
{__("No documents have been requested for your approval.")}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Skeleton } from "@probo/ui";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { useQueryLoader } from "react-relay";
|
||||
|
||||
import type { EmployeeApprovalsPageQuery } from "#/__generated__/core/EmployeeApprovalsPageQuery.graphql";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
|
||||
import {
|
||||
EmployeeApprovalsPage,
|
||||
employeeApprovalsPageQuery,
|
||||
} from "./EmployeeApprovalsPage";
|
||||
|
||||
function EmployeeApprovalsPageQueryLoader() {
|
||||
const organizationId = useOrganizationId();
|
||||
const [queryRef, loadQuery] = useQueryLoader<EmployeeApprovalsPageQuery>(
|
||||
employeeApprovalsPageQuery,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuery({
|
||||
organizationId,
|
||||
});
|
||||
}, [loadQuery, organizationId]);
|
||||
|
||||
if (!queryRef) {
|
||||
return <Skeleton className="w-full h-64" />;
|
||||
}
|
||||
|
||||
return <EmployeeApprovalsPage queryRef={queryRef} />;
|
||||
}
|
||||
|
||||
export default function EmployeeApprovalsPageLoader() {
|
||||
return (
|
||||
<Suspense fallback={<Skeleton className="w-full h-64" />}>
|
||||
<EmployeeApprovalsPageQueryLoader />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export const employeeDocumentSignaturePageQuery = graphql`
|
||||
`;
|
||||
|
||||
const documentFragment = graphql`
|
||||
fragment EmployeeDocumentSignaturePageDocumentFragment on SignableDocument {
|
||||
fragment EmployeeDocumentSignaturePageDocumentFragment on EmployeeDocument {
|
||||
id
|
||||
title
|
||||
# eslint-disable-next-line relay/unused-fields
|
||||
@@ -159,7 +159,7 @@ function DocumentSignatureContent({
|
||||
description: __("Document signed successfully"),
|
||||
variant: "success",
|
||||
});
|
||||
void navigate(`/organizations/${organizationId}/employee`);
|
||||
void navigate(`/organizations/${organizationId}/employee/signatures`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
@@ -256,7 +256,7 @@ function DocumentSignatureContent({
|
||||
isSigning={isSigning}
|
||||
onSign={handleSign}
|
||||
onBack={() =>
|
||||
void navigate(`/organizations/${organizationId}/employee`)}
|
||||
void navigate(`/organizations/${organizationId}/employee/signatures`)}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useParams } from "react-router";
|
||||
|
||||
import type { EmployeeDocumentSignaturePageQuery } from "#/__generated__/core/EmployeeDocumentSignaturePageQuery.graphql";
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
|
||||
|
||||
import {
|
||||
EmployeeDocumentSignaturePage,
|
||||
@@ -39,8 +38,6 @@ function EmployeeDocumentSignaturePageQueryLoader() {
|
||||
|
||||
export default function EmployeeDocumentSignaturePageLoader() {
|
||||
return (
|
||||
<CoreRelayProvider>
|
||||
<EmployeeDocumentSignaturePageQueryLoader />
|
||||
</CoreRelayProvider>
|
||||
<EmployeeDocumentSignaturePageQueryLoader />
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { usePageTitle } from "@probo/hooks";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Card, PageHeader, Tbody, Th, Thead, Tr } from "@probo/ui";
|
||||
import { Card, Tbody, Th, Thead, Tr } from "@probo/ui";
|
||||
import { graphql, type PreloadedQuery, usePreloadedQuery } from "react-relay";
|
||||
|
||||
import type { EmployeeDocumentsPageQuery } from "#/__generated__/core/EmployeeDocumentsPageQuery.graphql";
|
||||
@@ -46,19 +46,18 @@ export function EmployeeDocumentsPage(props: {
|
||||
usePageTitle(__("Documents"));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={__("Documents")} />
|
||||
<>
|
||||
{documents.length > 0
|
||||
? (
|
||||
<Card>
|
||||
<table className="w-full">
|
||||
<table className="w-full table-fixed">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th className="min-w-0 pr-12">{__("Name")}</Th>
|
||||
<Th className="w-48">{__("Type")}</Th>
|
||||
<Th className="w-36">{__("Classification")}</Th>
|
||||
<Th className="w-40">{__("Last update")}</Th>
|
||||
<Th className="w-32">{__("Signed")}</Th>
|
||||
<Th className="text-left">{__("Name")}</Th>
|
||||
<Th className="w-48 text-left">{__("Type")}</Th>
|
||||
<Th className="w-36 text-left">{__("Classification")}</Th>
|
||||
<Th className="w-40 text-left">{__("Last update")}</Th>
|
||||
<Th className="w-32 text-left">{__("Signed")}</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
@@ -85,6 +84,6 @@ export function EmployeeDocumentsPage(props: {
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useQueryLoader } from "react-relay";
|
||||
import type { EmployeeDocumentsPageQuery } from "#/__generated__/core/EmployeeDocumentsPageQuery.graphql";
|
||||
import { PageSkeleton } from "#/components/skeletons/PageSkeleton";
|
||||
import { useOrganizationId } from "#/hooks/useOrganizationId";
|
||||
import { CoreRelayProvider } from "#/providers/CoreRelayProvider";
|
||||
|
||||
import {
|
||||
EmployeeDocumentsPage,
|
||||
@@ -32,10 +31,8 @@ function EmployeeDocumentsPageQueryLoader() {
|
||||
|
||||
export default function EmployeeDocumentsPageLoader() {
|
||||
return (
|
||||
<CoreRelayProvider>
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<EmployeeDocumentsPageQueryLoader />
|
||||
</Suspense>
|
||||
</CoreRelayProvider>
|
||||
<Suspense fallback={<PageSkeleton />}>
|
||||
<EmployeeDocumentsPageQueryLoader />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { PageHeader, TabLink, Tabs } from "@probo/ui";
|
||||
import { Outlet } from "react-router";
|
||||
|
||||
export default function EmployeeTabsLayout() {
|
||||
const { __ } = useTranslate();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title={__("Documents")} />
|
||||
<Tabs>
|
||||
<TabLink to="signatures" end>
|
||||
{__("Signatures")}
|
||||
</TabLink>
|
||||
<TabLink to="approvals" end>
|
||||
{__("Approvals")}
|
||||
</TabLink>
|
||||
</Tabs>
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
formatDate,
|
||||
getDocumentClassificationLabel,
|
||||
getDocumentTypeLabel,
|
||||
} from "@probo/helpers";
|
||||
import { useTranslate } from "@probo/i18n";
|
||||
import { Badge, Td, Tr } from "@probo/ui";
|
||||
import { graphql, useFragment } from "react-relay";
|
||||
|
||||
import type { ApprovableDocumentRowFragment$key } from "#/__generated__/core/ApprovableDocumentRowFragment.graphql";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment ApprovableDocumentRowFragment on EmployeeDocument {
|
||||
id
|
||||
title
|
||||
documentType
|
||||
classification
|
||||
approvalState
|
||||
updatedAt
|
||||
}
|
||||
`;
|
||||
|
||||
export function ApprovableDocumentRow({
|
||||
fKey,
|
||||
organizationId,
|
||||
}: {
|
||||
fKey: ApprovableDocumentRowFragment$key;
|
||||
organizationId: string;
|
||||
}) {
|
||||
const document = useFragment<ApprovableDocumentRowFragment$key>(fragment, fKey);
|
||||
const { __ } = useTranslate();
|
||||
|
||||
const stateVariant = document.approvalState === "APPROVED"
|
||||
? "success"
|
||||
: document.approvalState === "REJECTED"
|
||||
? "danger"
|
||||
: "warning";
|
||||
|
||||
const stateLabel = document.approvalState === "APPROVED"
|
||||
? __("Approved")
|
||||
: document.approvalState === "REJECTED"
|
||||
? __("Rejected")
|
||||
: __("Pending");
|
||||
|
||||
return (
|
||||
<Tr to={`/organizations/${organizationId}/employee/approvals/${document.id}`}>
|
||||
<Td>{document.title}</Td>
|
||||
<Td className="w-48">
|
||||
{getDocumentTypeLabel(__, document.documentType)}
|
||||
</Td>
|
||||
<Td className="w-36">
|
||||
<Badge variant="neutral">
|
||||
{getDocumentClassificationLabel(__, document.classification)}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td className="w-40">{formatDate(document.updatedAt)}</Td>
|
||||
<Td className="w-32">
|
||||
<Badge variant={stateVariant}>
|
||||
{stateLabel}
|
||||
</Badge>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { graphql, useFragment } from "react-relay";
|
||||
import type { DocumentRowFragment$key } from "#/__generated__/core/DocumentRowFragment.graphql";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment DocumentRowFragment on SignableDocument {
|
||||
fragment DocumentRowFragment on EmployeeDocument {
|
||||
id
|
||||
title
|
||||
documentType
|
||||
@@ -31,8 +31,8 @@ export function DocumentRow({
|
||||
const { __ } = useTranslate();
|
||||
|
||||
return (
|
||||
<Tr to={`/organizations/${organizationId}/employee/${document.id}`}>
|
||||
<Td className="min-w-0 pr-12">{document.title}</Td>
|
||||
<Tr to={`/organizations/${organizationId}/employee/signatures/${document.id}`}>
|
||||
<Td>{document.title}</Td>
|
||||
<Td className="w-48">
|
||||
{getDocumentTypeLabel(__, document.documentType)}
|
||||
</Td>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { graphql, useFragment } from "react-relay";
|
||||
import type { VersionActionsFragment$key } from "#/__generated__/core/VersionActionsFragment.graphql";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment VersionActionsFragment on DocumentVersion {
|
||||
fragment VersionActionsFragment on EmployeeDocumentVersion {
|
||||
id
|
||||
signed
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { graphql, useFragment } from "react-relay";
|
||||
import type { VersionRowFragment$key } from "#/__generated__/core/VersionRowFragment.graphql";
|
||||
|
||||
const fragment = graphql`
|
||||
fragment VersionRowFragment on DocumentVersion {
|
||||
fragment VersionRowFragment on EmployeeDocumentVersion {
|
||||
# eslint-disable-next-line relay/unused-fields
|
||||
id
|
||||
version
|
||||
|
||||
@@ -136,18 +136,55 @@ const routes = [
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
loader: ({ params: { organizationId } }) => {
|
||||
// eslint-disable-next-line
|
||||
throw redirect(`/organizations/${organizationId}/employee/signatures`);
|
||||
},
|
||||
Component: () => null,
|
||||
},
|
||||
{
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("./pages/organizations/employee/EmployeeDocumentsPageLoader"),
|
||||
() => import("./pages/organizations/employee/EmployeeTabsLayout"),
|
||||
),
|
||||
children: [
|
||||
{
|
||||
path: "signatures",
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("./pages/organizations/employee/EmployeeDocumentsPageLoader"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "approvals",
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("./pages/organizations/employee/EmployeeApprovalsPageLoader"),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: ":documentId",
|
||||
loader: ({ params: { organizationId, documentId } }) => {
|
||||
// eslint-disable-next-line
|
||||
throw redirect(`/organizations/${organizationId}/employee/signatures/${documentId}`);
|
||||
},
|
||||
Component: () => null,
|
||||
},
|
||||
{
|
||||
path: "signatures/:documentId",
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("./pages/organizations/employee/EmployeeDocumentSignaturePageLoader"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "approvals/:documentId",
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("./pages/organizations/documents/approve/DocumentApprovePageLoader"),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -40,6 +40,14 @@ const documentTabs = (prefix: string) => {
|
||||
import("#/pages/organizations/documents/controls/DocumentControlsPageLoader"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: `${prefix}approvals`,
|
||||
Fallback: LinkCardSkeleton,
|
||||
Component: lazy(
|
||||
() =>
|
||||
import("#/pages/organizations/documents/approvals/DocumentApprovalsPageLoader"),
|
||||
),
|
||||
},
|
||||
{
|
||||
path: `${prefix}signatures`,
|
||||
Fallback: LinkCardSkeleton,
|
||||
@@ -61,6 +69,9 @@ export const documentsRoutes = [
|
||||
path: "documents/:documentId",
|
||||
Fallback: PageSkeleton,
|
||||
Component: lazy(() => import("#/pages/organizations/documents/DocumentLayoutLoader")),
|
||||
children: [...documentTabs(""), ...documentTabs("versions/:versionId/")],
|
||||
children: [
|
||||
...documentTabs(""),
|
||||
...documentTabs("versions/:versionId/"),
|
||||
],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
|
||||
Reference in New Issue
Block a user