mirror of
https://github.com/docmost/docmost.git
synced 2026-05-15 21:24:09 +08:00
bd68e47e03
* feat: page verification workflow * feat: refactor page-verification * sync * fix type * fix * fix * notification icon * use full word * accept .license file * - update templates - update migration and notification * fix copy * update audit labels * sync * add space name
634 lines
18 KiB
TypeScript
634 lines
18 KiB
TypeScript
import { useState } from "react";
|
|
import {
|
|
Button,
|
|
Center,
|
|
Checkbox,
|
|
Divider,
|
|
Group,
|
|
Loader,
|
|
Stack,
|
|
Text,
|
|
Textarea,
|
|
} from "@mantine/core";
|
|
import { modals } from "@mantine/modals";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
useMarkObsoleteMutation,
|
|
usePageVerificationInfoQuery,
|
|
useRejectApprovalMutation,
|
|
useRemoveVerificationMutation,
|
|
useSubmitForApprovalMutation,
|
|
useUpdateVerificationMutation,
|
|
useVerifyPageMutation,
|
|
} from "@/ee/page-verification/queries/page-verification-query";
|
|
import {
|
|
ExpirationMode,
|
|
IPageVerificationInfo,
|
|
PeriodUnit,
|
|
} from "@/ee/page-verification/types/page-verification.types";
|
|
import { useTimeAgo } from "@/hooks/use-time-ago";
|
|
import { VerifierList } from "./verifier-list";
|
|
import {
|
|
ExpirationFields,
|
|
PERIOD_AMOUNT_MIN,
|
|
PERIOD_UNIT_MAX_AMOUNT,
|
|
toLocalDateString,
|
|
} from "./expiration-fields";
|
|
import { VerifierPicker } from "./verifier-picker";
|
|
import { MAX_VERIFIERS } from "./user-option";
|
|
|
|
type ManageVerificationFormProps = {
|
|
pageId: string;
|
|
onClose: () => void;
|
|
};
|
|
|
|
export function ManageVerificationForm({
|
|
pageId,
|
|
onClose,
|
|
}: ManageVerificationFormProps) {
|
|
const { data: info, isLoading } = usePageVerificationInfoQuery(pageId);
|
|
|
|
if (isLoading || !info) {
|
|
return (
|
|
<Center py="xl">
|
|
<Loader size="sm" />
|
|
</Center>
|
|
);
|
|
}
|
|
|
|
if (info.type === "qms") {
|
|
return <QmsManageContent pageId={pageId} info={info} onClose={onClose} />;
|
|
}
|
|
|
|
return (
|
|
<ExpiringManageContent pageId={pageId} info={info} onClose={onClose} />
|
|
);
|
|
}
|
|
|
|
type ManageContentProps = {
|
|
pageId: string;
|
|
info: IPageVerificationInfo;
|
|
onClose: () => void;
|
|
};
|
|
|
|
function ExpiringManageContent({ pageId, info, onClose }: ManageContentProps) {
|
|
const { t } = useTranslation();
|
|
const verifyMutation = useVerifyPageMutation();
|
|
const removeMutation = useRemoveVerificationMutation();
|
|
const updateMutation = useUpdateVerificationMutation();
|
|
const [confirmed, setConfirmed] = useState(false);
|
|
|
|
const initialMode: ExpirationMode = (info.mode as ExpirationMode) ?? "period";
|
|
const initialPeriodAmount = info.periodAmount ?? 1;
|
|
const initialPeriodUnit: PeriodUnit =
|
|
(info.periodUnit as PeriodUnit) ?? "month";
|
|
const initialFixedDate =
|
|
initialMode === "fixed" && info.expiresAt
|
|
? toLocalDateString(info.expiresAt)
|
|
: "";
|
|
|
|
const [mode, setMode] = useState<ExpirationMode>(initialMode);
|
|
const [periodAmount, setPeriodAmount] = useState<number>(initialPeriodAmount);
|
|
const [periodUnit, setPeriodUnit] = useState<PeriodUnit>(initialPeriodUnit);
|
|
const [fixedDate, setFixedDate] = useState<string>(initialFixedDate);
|
|
|
|
const verifiedAtAgo = useTimeAgo(info.verifiedAt ?? new Date().toISOString());
|
|
|
|
const hasExpirationChange =
|
|
mode !== initialMode ||
|
|
(mode === "period" &&
|
|
(periodAmount !== initialPeriodAmount ||
|
|
periodUnit !== initialPeriodUnit)) ||
|
|
(mode === "fixed" && fixedDate !== initialFixedDate);
|
|
|
|
const periodValid =
|
|
mode !== "period" ||
|
|
(Number.isInteger(periodAmount) &&
|
|
periodAmount >= PERIOD_AMOUNT_MIN &&
|
|
periodAmount <= PERIOD_UNIT_MAX_AMOUNT[periodUnit]);
|
|
const fixedDateValid =
|
|
mode !== "fixed" ||
|
|
(!!fixedDate && new Date(fixedDate).getTime() > Date.now());
|
|
const canSaveExpiration = hasExpirationChange && periodValid && fixedDateValid;
|
|
|
|
const storedFixedExpired =
|
|
info.mode === "fixed" &&
|
|
!!info.expiresAt &&
|
|
new Date(info.expiresAt).getTime() <= Date.now();
|
|
|
|
const existingVerifierIds = info.verifiers?.map((v) => v.id) ?? [];
|
|
|
|
const handleVerify = () => {
|
|
verifyMutation.mutate(pageId, {
|
|
onSuccess: () => {
|
|
setConfirmed(false);
|
|
onClose();
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleRemove = () => {
|
|
modals.openConfirmModal({
|
|
title: t("Remove verification"),
|
|
children: (
|
|
<Text size="sm">
|
|
{t("Are you sure you want to remove verification from this page?")}
|
|
</Text>
|
|
),
|
|
labels: { confirm: t("Remove"), cancel: t("Cancel") },
|
|
confirmProps: { color: "red" },
|
|
onConfirm: () => removeMutation.mutate(pageId, { onSuccess: onClose }),
|
|
});
|
|
};
|
|
|
|
const handleSaveExpiration = () => {
|
|
if (!canSaveExpiration) return;
|
|
updateMutation.mutate({
|
|
pageId,
|
|
mode,
|
|
...(mode === "period" && {
|
|
periodAmount,
|
|
periodUnit,
|
|
}),
|
|
...(mode === "fixed" &&
|
|
fixedDate && {
|
|
fixedExpiresAt: new Date(fixedDate).toISOString(),
|
|
}),
|
|
});
|
|
};
|
|
|
|
const handleRemoveVerifier = (userId: string) => {
|
|
if (!info.verifiers) return;
|
|
const remaining = info.verifiers
|
|
.filter((v) => v.id !== userId)
|
|
.map((v) => v.id);
|
|
updateMutation.mutate({ pageId, verifierIds: remaining });
|
|
};
|
|
|
|
const handleAddVerifier = (userId: string) => {
|
|
if (!info.verifiers) return;
|
|
if (info.verifiers.some((v) => v.id === userId)) return;
|
|
const verifierIds = [...info.verifiers.map((v) => v.id), userId];
|
|
updateMutation.mutate({ pageId, verifierIds });
|
|
};
|
|
|
|
const status = info.status;
|
|
|
|
return (
|
|
<Stack>
|
|
<Text size="sm" c="dimmed">
|
|
{t("Assigned verifiers must periodically re-verify this page.")}
|
|
</Text>
|
|
|
|
{info.verifiedBy && (
|
|
<Group gap="sm">
|
|
<div>
|
|
<Text size="sm">
|
|
{status === "expired"
|
|
? t("Last verified by {{name}} {{time}} (expired)", {
|
|
name: info.verifiedBy.name,
|
|
time: verifiedAtAgo,
|
|
})
|
|
: t("Verified by {{name}} {{time}}", {
|
|
name: info.verifiedBy.name,
|
|
time: verifiedAtAgo,
|
|
})}
|
|
</Text>
|
|
{info.expiresAt && (
|
|
<Text size="xs" c="dimmed">
|
|
{t(status === "expired" ? "Expired {{date}}" : "Expires {{date}}", {
|
|
date: new Date(info.expiresAt).toLocaleDateString(undefined, {
|
|
month: "long",
|
|
day: "numeric",
|
|
year: "numeric",
|
|
}),
|
|
})}
|
|
</Text>
|
|
)}
|
|
</div>
|
|
</Group>
|
|
)}
|
|
|
|
<Divider />
|
|
|
|
{info.verifiers && info.verifiers.length > 0 && (
|
|
<>
|
|
<div>
|
|
<Text size="sm" fw={600} tt="uppercase" c="dimmed" mb={4}>
|
|
{t("Verifiers")}
|
|
</Text>
|
|
<VerifierList
|
|
verifiers={info.verifiers}
|
|
canManage={info.permissions?.canManage}
|
|
onRemove={
|
|
info.permissions?.canManage ? handleRemoveVerifier : undefined
|
|
}
|
|
/>
|
|
{info.permissions?.canManage &&
|
|
info.verifiers.length < MAX_VERIFIERS && (
|
|
<div style={{ marginTop: "var(--mantine-spacing-xs)" }}>
|
|
<VerifierPicker
|
|
excludeIds={existingVerifierIds}
|
|
onSelect={(user) => handleAddVerifier(user.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Divider />
|
|
</>
|
|
)}
|
|
|
|
{info.permissions?.canManage && (
|
|
<>
|
|
<div>
|
|
<Text size="sm" fw={600} mb={6}>
|
|
{t("Expiration")}
|
|
</Text>
|
|
<ExpirationFields
|
|
mode={mode}
|
|
periodAmount={periodAmount}
|
|
periodUnit={periodUnit}
|
|
fixedDate={fixedDate}
|
|
onModeChange={setMode}
|
|
onPeriodAmountChange={setPeriodAmount}
|
|
onPeriodUnitChange={setPeriodUnit}
|
|
onFixedDateChange={setFixedDate}
|
|
baseDate={
|
|
info.verifiedAt ? new Date(info.verifiedAt) : undefined
|
|
}
|
|
/>
|
|
{hasExpirationChange && (
|
|
<Button
|
|
size="compact-sm"
|
|
mt="xs"
|
|
color="dark"
|
|
onClick={handleSaveExpiration}
|
|
loading={updateMutation.isPending}
|
|
disabled={!canSaveExpiration}
|
|
>
|
|
{t("Save")}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<Divider />
|
|
</>
|
|
)}
|
|
|
|
{info.permissions?.canVerify && (
|
|
<div>
|
|
<Text size="sm" fw={600} mb={4}>
|
|
{t("Confirm")}
|
|
</Text>
|
|
<Checkbox
|
|
label={t("I've reviewed this page for accuracy")}
|
|
checked={confirmed}
|
|
onChange={(event) => setConfirmed(event.currentTarget.checked)}
|
|
color="dark"
|
|
/>
|
|
{storedFixedExpired && (
|
|
<Text size="xs" c="red" mt={6}>
|
|
{t("The fixed expiration date has passed.")}
|
|
</Text>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<Group justify="space-between">
|
|
{info.permissions?.canManage && (
|
|
<Button
|
|
variant="subtle"
|
|
color="red"
|
|
size="compact-sm"
|
|
onClick={handleRemove}
|
|
loading={removeMutation.isPending}
|
|
>
|
|
{t("Remove verification")}
|
|
</Button>
|
|
)}
|
|
|
|
{info.permissions?.canVerify && (
|
|
<Button
|
|
onClick={handleVerify}
|
|
disabled={!confirmed || storedFixedExpired}
|
|
loading={verifyMutation.isPending}
|
|
color={status === "expired" ? "red" : "dark"}
|
|
ml="auto"
|
|
>
|
|
{t("Verify")}
|
|
</Button>
|
|
)}
|
|
</Group>
|
|
</Stack>
|
|
);
|
|
}
|
|
|
|
function QmsManageContent({ pageId, info, onClose }: ManageContentProps) {
|
|
const { t } = useTranslation();
|
|
const verifyMutation = useVerifyPageMutation();
|
|
const submitMutation = useSubmitForApprovalMutation();
|
|
const rejectMutation = useRejectApprovalMutation();
|
|
const obsoleteMutation = useMarkObsoleteMutation();
|
|
const removeMutation = useRemoveVerificationMutation();
|
|
const updateMutation = useUpdateVerificationMutation();
|
|
const [confirmed, setConfirmed] = useState(false);
|
|
const [showRejectForm, setShowRejectForm] = useState(false);
|
|
const [rejectComment, setRejectComment] = useState("");
|
|
const verifiedAtAgo = useTimeAgo(info.verifiedAt ?? new Date().toISOString());
|
|
const requestedAtAgo = useTimeAgo(
|
|
info.requestedAt ?? new Date().toISOString(),
|
|
);
|
|
const rejectedAtAgo = useTimeAgo(info.rejectedAt ?? new Date().toISOString());
|
|
|
|
const status = info.status;
|
|
|
|
const existingVerifierIds = info.verifiers?.map((v) => v.id) ?? [];
|
|
|
|
const handleSubmitForApproval = () => {
|
|
submitMutation.mutate(pageId, { onSuccess: onClose });
|
|
};
|
|
|
|
const handleVerify = () => {
|
|
verifyMutation.mutate(pageId, {
|
|
onSuccess: () => {
|
|
setConfirmed(false);
|
|
onClose();
|
|
},
|
|
});
|
|
};
|
|
|
|
const handleReject = () => {
|
|
rejectMutation.mutate(
|
|
{ pageId, comment: rejectComment || undefined },
|
|
{
|
|
onSuccess: () => {
|
|
setShowRejectForm(false);
|
|
setRejectComment("");
|
|
onClose();
|
|
},
|
|
},
|
|
);
|
|
};
|
|
|
|
const handleMarkObsolete = () => {
|
|
modals.openConfirmModal({
|
|
title: t("Mark as obsolete"),
|
|
children: (
|
|
<Stack gap="xs">
|
|
<Text size="sm">
|
|
{t(
|
|
"Are you sure you want to mark this page as obsolete? This action cannot be undone.",
|
|
)}
|
|
</Text>
|
|
<Text size="sm" c="dimmed">
|
|
{t(
|
|
"To restore this page, you will need to remove verification and set it up again.",
|
|
)}
|
|
</Text>
|
|
</Stack>
|
|
),
|
|
labels: { confirm: t("Mark obsolete"), cancel: t("Cancel") },
|
|
confirmProps: { color: "red" },
|
|
onConfirm: () =>
|
|
obsoleteMutation.mutate(pageId, { onSuccess: onClose }),
|
|
});
|
|
};
|
|
|
|
const handleRemove = () => {
|
|
modals.openConfirmModal({
|
|
title: t("Remove verification"),
|
|
children: (
|
|
<Text size="sm">
|
|
{t("Are you sure you want to remove verification from this page?")}
|
|
</Text>
|
|
),
|
|
labels: { confirm: t("Remove"), cancel: t("Cancel") },
|
|
confirmProps: { color: "red" },
|
|
onConfirm: () => removeMutation.mutate(pageId, { onSuccess: onClose }),
|
|
});
|
|
};
|
|
|
|
const handleRemoveVerifier = (userId: string) => {
|
|
if (!info.verifiers) return;
|
|
const remaining = info.verifiers
|
|
.filter((v) => v.id !== userId)
|
|
.map((v) => v.id);
|
|
updateMutation.mutate({ pageId, verifierIds: remaining });
|
|
};
|
|
|
|
const handleAddVerifier = (userId: string) => {
|
|
if (!info.verifiers) return;
|
|
if (info.verifiers.some((v) => v.id === userId)) return;
|
|
const verifierIds = [...info.verifiers.map((v) => v.id), userId];
|
|
updateMutation.mutate({ pageId, verifierIds });
|
|
};
|
|
|
|
const canManageVerifiers =
|
|
info.permissions?.canManage && status !== "obsolete";
|
|
|
|
return (
|
|
<Stack>
|
|
<Text size="sm" c="dimmed">
|
|
{t("Pages move through draft, approval, and approved stages.")}
|
|
</Text>
|
|
|
|
{status === "draft" && (
|
|
<>
|
|
{info.rejectedBy && info.rejectedAt && (
|
|
<div>
|
|
<Text size="sm" c="red">
|
|
{t("Returned by {{name}} {{time}}", {
|
|
name: info.rejectedBy.name,
|
|
time: rejectedAtAgo,
|
|
})}
|
|
</Text>
|
|
{info.rejectionComment && (
|
|
<Text size="sm" c="dimmed" mt={4} fs="italic">
|
|
“{info.rejectionComment}”
|
|
</Text>
|
|
)}
|
|
</div>
|
|
)}
|
|
{!info.rejectedBy && (
|
|
<Text size="sm">{t("No approval has been requested yet.")}</Text>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{status === "in_approval" && (
|
|
<div>
|
|
<Text size="sm">
|
|
{t("Submitted by {{name}} {{time}}", {
|
|
name: info.requestedBy?.name ?? t("Someone"),
|
|
time: requestedAtAgo,
|
|
})}
|
|
</Text>
|
|
</div>
|
|
)}
|
|
|
|
{status === "approved" && info.verifiedBy && (
|
|
<div>
|
|
<Text size="sm">
|
|
{t("Approved by {{name}} {{time}}", {
|
|
name: info.verifiedBy.name,
|
|
time: verifiedAtAgo,
|
|
})}
|
|
</Text>
|
|
</div>
|
|
)}
|
|
|
|
{status === "obsolete" && (
|
|
<Text size="sm" c="dimmed">
|
|
{t("This document has been marked as obsolete.")}
|
|
</Text>
|
|
)}
|
|
|
|
<Divider />
|
|
|
|
{info.verifiers && info.verifiers.length > 0 && (
|
|
<>
|
|
<div>
|
|
<Text size="sm" fw={600} tt="uppercase" c="dimmed" mb={4}>
|
|
{t("Verifiers")}
|
|
</Text>
|
|
<VerifierList
|
|
verifiers={info.verifiers}
|
|
canManage={canManageVerifiers}
|
|
onRemove={canManageVerifiers ? handleRemoveVerifier : undefined}
|
|
/>
|
|
{canManageVerifiers && info.verifiers.length < MAX_VERIFIERS && (
|
|
<div style={{ marginTop: "var(--mantine-spacing-xs)" }}>
|
|
<VerifierPicker
|
|
excludeIds={existingVerifierIds}
|
|
onSelect={(user) => handleAddVerifier(user.value)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Divider />
|
|
</>
|
|
)}
|
|
|
|
{status === "in_approval" && info.permissions?.canVerify && (
|
|
<>
|
|
{showRejectForm ? (
|
|
<div>
|
|
<Text size="sm" fw={600} mb={4}>
|
|
{t("Rejection comment")}
|
|
</Text>
|
|
<Textarea
|
|
value={rejectComment}
|
|
onChange={(e) => setRejectComment(e.currentTarget.value)}
|
|
placeholder={t("Reason for returning this document...")}
|
|
minRows={2}
|
|
variant="filled"
|
|
maxLength={500}
|
|
/>
|
|
<Group justify="flex-end" mt="sm" gap="xs">
|
|
<Button
|
|
variant="subtle"
|
|
color="gray"
|
|
size="compact-sm"
|
|
onClick={() => {
|
|
setShowRejectForm(false);
|
|
setRejectComment("");
|
|
}}
|
|
>
|
|
{t("Cancel")}
|
|
</Button>
|
|
<Button
|
|
color="red"
|
|
onClick={handleReject}
|
|
loading={rejectMutation.isPending}
|
|
>
|
|
{t("Confirm rejection")}
|
|
</Button>
|
|
</Group>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<Checkbox
|
|
label={t("I've reviewed this page for accuracy")}
|
|
checked={confirmed}
|
|
onChange={(event) => setConfirmed(event.currentTarget.checked)}
|
|
color="dark"
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<Group justify="space-between">
|
|
{info.permissions?.canManage && (
|
|
<Button
|
|
variant="subtle"
|
|
color="red"
|
|
size="compact-sm"
|
|
onClick={handleRemove}
|
|
loading={removeMutation.isPending}
|
|
>
|
|
{t("Remove verification")}
|
|
</Button>
|
|
)}
|
|
|
|
<Group gap="xs" ml="auto">
|
|
{status === "draft" && info.permissions?.canSubmitForApproval && (
|
|
<Button
|
|
onClick={handleSubmitForApproval}
|
|
loading={submitMutation.isPending}
|
|
color="dark"
|
|
>
|
|
{t("Submit for approval")}
|
|
</Button>
|
|
)}
|
|
|
|
{status === "in_approval" &&
|
|
info.permissions?.canVerify &&
|
|
!showRejectForm && (
|
|
<>
|
|
<Button
|
|
variant="light"
|
|
color="red"
|
|
onClick={() => setShowRejectForm(true)}
|
|
>
|
|
{t("Reject")}
|
|
</Button>
|
|
<Button
|
|
onClick={handleVerify}
|
|
disabled={!confirmed}
|
|
loading={verifyMutation.isPending}
|
|
color="dark"
|
|
>
|
|
{t("Approve")}
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
{status === "approved" && (
|
|
<>
|
|
{info.permissions?.canSubmitForApproval && (
|
|
<Button
|
|
variant="light"
|
|
onClick={handleSubmitForApproval}
|
|
loading={submitMutation.isPending}
|
|
>
|
|
{t("Re-submit for approval")}
|
|
</Button>
|
|
)}
|
|
{info.permissions?.canMarkObsolete && (
|
|
<Button
|
|
variant="light"
|
|
color="gray"
|
|
onClick={handleMarkObsolete}
|
|
loading={obsoleteMutation.isPending}
|
|
>
|
|
{t("Mark obsolete")}
|
|
</Button>
|
|
)}
|
|
</>
|
|
)}
|
|
</Group>
|
|
</Group>
|
|
</Stack>
|
|
);
|
|
}
|