EZQMS-1317: authors signature on review and approval request & block review bypass (#7631)

Signed-off-by: Victor Ilyushchenko <alt13ri@gmail.com>
This commit is contained in:
Victor Ilyushchenko 2025-01-13 14:19:47 +03:00 committed by GitHub
parent 71d72ba20c
commit b949744500
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 130 additions and 18 deletions

View File

@ -44,6 +44,7 @@
export let withoutFooter = false
export let closeIcon: AnySvelteComponent = Close
export let shadow: boolean = false
export let className: string = ''
const dispatch = createEventDispatcher()
@ -66,7 +67,7 @@
<svelte:element
this={isForm ? 'form' : 'div'}
class="root"
class="root {className}"
class:shadow
class:embedded
on:submit|preventDefault={isForm && shouldSubmitOnEnter ? submit : undefined}

View File

@ -84,6 +84,8 @@
"ProvideRejectionReason": "Type rejection reason...",
"RejectionReason": "Rejection reason",
"ConfirmReviewCompletion": "Confirm review completion",
"ConfirmApprovalSubmission": "Confirm submission for approval",
"ConfirmReviewSubmission": "Confirm submission for review",
"AddApprovalTitle": "Send your document to approvers",
"AddApprovalDescription1": "To prepare your document for approval it should fit few requirements:",
"AddApprovalDescription2": "has status 'Draft' or 'Reviewed'",

View File

@ -76,6 +76,8 @@
"ProvideRejectionReason": "Tapez la raison du rejet...",
"RejectionReason": "Raison du rejet",
"ConfirmReviewCompletion": "Confirmer l'achèvement de la révision",
"ConfirmApprovalSubmission": "Confirmer la soumission pour approbation",
"ConfirmReviewSubmission": "Confirmer la soumission pour examen",
"AddApprovalTitle": "Envoyer votre document aux approuveurs",
"AddApprovalDescription1": "Pour préparer votre document à l'approbation, il doit remplir quelques conditions :",
"AddApprovalDescription2": "a le statut 'Brouillon' ou 'Révisé'",

View File

@ -84,6 +84,8 @@
"ProvideRejectionReason": "Напишите причину отказа",
"RejectionReason": "Причина отказа",
"ConfirmReviewCompletion": "Подтвердите окончание рецензии",
"ConfirmApprovalSubmission": "Подтвердите отправку на рецензцию",
"ConfirmReviewSubmission": "Подтвердите отправку на утверждение",
"AddApprovalTitle": "Отправьте ваш документ на утверждение",
"AddApprovalDescription1": "Для отправки вашего документа на утверждение, он должен удовлетворять следующим требованиям:",
"AddApprovalDescription2": "иметь статус 'Рабочая копия' или 'Рецензии получены'",

View File

@ -165,7 +165,8 @@
const teamPopupData: TeamPopupData = {
controlledDoc: $controlledDocument,
requestClass
requestClass,
requireSignature: true
}
showPopup(TeamPopup, teamPopupData, 'center')

View File

@ -109,6 +109,7 @@
on:close
width="32rem"
shadow={true}
className={'signature-dialog'}
>
<div class="flex-col flex-gap-2">
<StylishEdit

View File

@ -6,7 +6,7 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { RequestStatus } from '@hcengineering/request'
import { Label, ModernDialog } from '@hcengineering/ui'
import { Label, ModernDialog, showPopup } from '@hcengineering/ui'
import { getClient } from '@hcengineering/presentation'
import contact, { Employee, PersonAccount } from '@hcengineering/contact'
import { Class, Ref } from '@hcengineering/core'
@ -20,10 +20,12 @@
import documentsRes from '../plugin'
import { sendApprovalRequest, sendReviewRequest } from '../utils'
import SignatureDialog from './SignatureDialog.svelte'
export let controlledDoc: ControlledDocument
export let requestClass: Ref<Class<DocumentRequest>>
export let readonly: boolean = false
export let requireSignature: boolean = false
const client = getClient()
const hierarchy = client.getHierarchy()
@ -67,13 +69,34 @@
let users: Ref<Employee>[] = controlledDoc[docField] ?? []
async function submit (): Promise<void> {
loading = true
const complete = async (): Promise<void> => {
loading = true
await sendRequestFunc?.(client, controlledDoc, users)
await sendRequestFunc?.(client, controlledDoc, users)
loading = false
loading = false
dispatch('close')
dispatch('close')
}
if (requireSignature) {
showPopup(
SignatureDialog,
{
confirmationTitle: isReviewRequest
? documentsRes.string.ConfirmReviewSubmission
: documentsRes.string.ConfirmApprovalSubmission
},
'center',
async (res) => {
if (!res) return
await complete()
}
)
} else {
await complete()
}
}
$: canSubmit = docRequest === undefined && users.length > 0

View File

@ -87,12 +87,19 @@
return rawName !== undefined ? formatName(rawName) : ''
}
const authorSignDate =
reviewRequest !== undefined
? reviewRequest.createdOn
: approvalRequest !== undefined
? approvalRequest.createdOn
: $controlledDocument.createdOn
signers = [
{
id: $controlledDocument.author,
role: 'author',
name: getNameByEmployeeId($controlledDocument.author),
date: $controlledDocument.createdOn !== undefined ? formatSignatureDate($controlledDocument.createdOn) : ''
date: authorSignDate !== undefined ? formatSignatureDate(authorSignDate) : ''
}
]

View File

@ -116,9 +116,11 @@ export default mergeIds(documentsId, documents, {
ConfirmApproval: '' as IntlString,
ConfirmRejection: '' as IntlString,
ConfirmApprovalSubmission: '' as IntlString,
ProvideRejectionReason: '' as IntlString,
RejectionReason: '' as IntlString,
ConfirmReviewCompletion: '' as IntlString,
ConfirmReviewSubmission: '' as IntlString,
AddApprovalTitle: '' as IntlString,
AddApprovalDescription1: '' as IntlString,
AddApprovalDescription2: '' as IntlString,

View File

@ -64,6 +64,10 @@ export const documentAllVersionsUpdated = createEvent<ControlledDocument[]>(
export const reviewRequestUpdated = createEvent<DocumentReviewRequest>(generateActionName('reviewRequestUpdated'))
export const reviewRequestHistoryUpdated = createEvent<DocumentReviewRequest[]>(
generateActionName('reviewRequestHistoryUpdated')
)
export const approvalRequestUpdated = createEvent<DocumentApprovalRequest>(generateActionName('approvalRequestUpdated'))
export const editorModeUpdated = createEvent<EditorMode>(generateActionName('editorModeUpdated'))

View File

@ -17,17 +17,24 @@ import { ControlledDocumentState, DocumentState } from '@hcengineering/controlle
import { TrainingState } from '@hcengineering/training'
import { combine } from 'effector'
import { $documentComments } from './documentComments'
import { $documentState, $isLatestVersion, $training } from './editor'
import { $controlledDocument, $documentState, $isLatestVersion, $reviewRequestHistory, $training } from './editor'
export const $canSendForApproval = combine(
$controlledDocument,
$isLatestVersion,
$documentState,
$documentComments,
$training,
(isLatestVersion, state, comments, training) => {
$reviewRequestHistory,
(document, isLatestVersion, state, comments, training, reviewHistory) => {
let haveBeenReviewedOnce = false
if (document !== null) {
const reviews = (reviewHistory ?? []).filter((review) => review.attachedTo === document._id)
if (reviews.length > 0) haveBeenReviewedOnce = true
}
return (
isLatestVersion &&
(state === DocumentState.Draft || state === ControlledDocumentState.Reviewed) &&
((state === DocumentState.Draft && !haveBeenReviewedOnce) || state === ControlledDocumentState.Reviewed) &&
comments.every((comment) => comment.resolved) &&
(training === null || training.state === TrainingState.Released)
)

View File

@ -43,6 +43,7 @@ import {
documentAllVersionsUpdated,
editorModeUpdated,
reviewRequestUpdated,
reviewRequestHistoryUpdated,
rightPanelTabChanged,
documentSnapshotsUpdated,
trainingUpdated,
@ -122,6 +123,10 @@ export const $reviewRequest = createStore<DocumentReviewRequest | null>(null)
.on(reviewRequestUpdated, (_, payload) => payload)
.reset(controlledDocumentClosed)
export const $reviewRequestHistory = createStore<DocumentReviewRequest[] | null>(null)
.on(reviewRequestHistoryUpdated, (_, payload) => payload)
.reset(controlledDocumentClosed)
export const $approvalRequest = createStore<DocumentApprovalRequest | null>(null)
.on(approvalRequestUpdated, (_, payload) => payload)
.reset(controlledDocumentClosed)

View File

@ -39,7 +39,8 @@ import {
documentSnapshotsUpdated,
trainingUpdated,
projectUpdated,
projectDocumentsUpdated
projectDocumentsUpdated,
reviewRequestHistoryUpdated
} from './actions'
import { $documentCommentsFilter } from './documentComments'
import { $controlledDocument, $documentTraining } from './editor'
@ -48,6 +49,7 @@ const controlledDocumentQuery = createQuery(true)
const documentVersionsQuery = createQuery(true)
const documentSnapshotsQuery = createQuery(true)
const reviewRequestQuery = createQuery(true)
const reviewRequestHistoryQuery = createQuery(true)
const approvalRequestQuery = createQuery(true)
const documentCommentsQuery = createQuery(true)
const workingCopyMetadataQuery = createQuery(true)
@ -125,6 +127,25 @@ const queryReviewRequestFx = createEffect(
}
)
const queryReviewRequestHistoryFx = createEffect(
(payload: { _id: Ref<ControlledDocument>, _class: Ref<Class<ControlledDocument>> }) => {
const { _id, _class } = payload
if (_id == null || _class == null) {
reviewRequestHistoryQuery.unsubscribe()
return
}
reviewRequestHistoryQuery.query(
documents.class.DocumentReviewRequest,
{ attachedTo: _id, attachedToClass: _class },
(result) => {
if (result !== null && result !== undefined && result.length > 0) {
reviewRequestHistoryUpdated(result)
}
}
)
}
)
const queryApprovalRequestFx = createEffect(
(payload: { _id: Ref<ControlledDocument>, _class: Ref<Class<ControlledDocument>> }) => {
const { _id, _class } = payload
@ -259,6 +280,7 @@ forward({
to: [
queryControlledDocumentFx,
queryReviewRequestFx,
queryReviewRequestHistoryFx,
queryApprovalRequestFx,
querySavedAttachmentsFx,
queryProjectFx,

View File

@ -185,6 +185,7 @@ export async function getDocumentMetaLinkFragment (document: Doc): Promise<Locat
export interface TeamPopupData {
controlledDoc: ControlledDocument
requestClass: Ref<Class<DocumentRequest>>
requireSignature?: boolean
}
export async function sendReviewRequest (

View File

@ -226,7 +226,7 @@ test.describe('QMS. PDF Download and Preview', () => {
const documentContentPageSecond = new DocumentContentPage(userSecondPage)
const documentsPageSecond = new DocumentsPage(userSecondPage)
await documentsPageSecond.openDocument(approveDocument.title)
await documentContentPageSecond.clickApproveButton()
await documentContentPageSecond.confirmApproval()
await documentsPageSecond.openDocument(approveDocument.title)
})
await test.step('5. check if reviewers and approvers are visible', async () => {
@ -275,7 +275,7 @@ test.describe('QMS. PDF Download and Preview', () => {
const documentContentPageSecond = new DocumentContentPage(userSecondPage)
const documentsPageSecond = new DocumentsPage(userSecondPage)
await documentsPageSecond.openDocument(approveDocument.title)
await documentContentPageSecond.clickApproveButton()
await documentContentPageSecond.confirmApproval()
await documentsPageSecond.openDocument(approveDocument.title)
})
await test.step('5. check if reviewers and approvers are visible', async () => {

View File

@ -926,7 +926,29 @@ test.describe('QMS. Documents tests', () => {
await attachScreenshot('TESTS-206_check_document.png', page)
})
await test.step('7. Send for Approval', async () => {
await test.step('7. Send for Review', async () => {
await documentContentPage.buttonSendForReview.click()
await documentContentPage.fillSelectReviewersForm([])
await documentContentPage.checkDocumentStatus(DocumentStatus.IN_REVIEW)
await documentContentPage.checkDocument({
...documentDetails,
status: DocumentStatus.IN_REVIEW
})
await attachScreenshot('TESTS-206_send_for_review_2.png', page)
})
await test.step('8. Complete Review', async () => {
const documentContentPageSecond = new DocumentContentPage(userSecondPage)
await documentContentPageSecond.completeReview()
await documentContentPageSecond.checkDocumentStatus(DocumentStatus.REVIEWED)
await documentContentPageSecond.checkCurrentRights(DocumentRights.VIEWING)
await attachScreenshot('TESTS-206_complete_review_2.png', page)
})
await test.step('9. Send for Approval', async () => {
await documentContentPage.buttonSendForApproval.click()
await documentContentPage.fillSelectApproversForm([reviewer])
await documentContentPage.checkDocumentStatus(DocumentStatus.IN_APPROVAL)
@ -938,7 +960,7 @@ test.describe('QMS. Documents tests', () => {
await documentContentPage.checkCurrentRights(DocumentRights.VIEWING)
})
await test.step('8. Approve document', async () => {
await test.step('10. Approve document', async () => {
const documentsPageSecond = new DocumentsPage(userSecondPage)
await documentsPageSecond.openDocument(completeDocument.title)
@ -957,7 +979,7 @@ test.describe('QMS. Documents tests', () => {
await attachScreenshot('TESTS-206_approve_document.png', page)
})
await test.step('9. Check document', async () => {
await test.step('11. Check document', async () => {
await documentContentPage.checkDocumentStatus(DocumentStatus.EFFECTIVE)
await documentContentPage.checkDocument({
...documentDetails,
@ -969,7 +991,7 @@ test.describe('QMS. Documents tests', () => {
await attachScreenshot('TESTS-206_check_document.png', page)
})
await test.step('10. Check History tab', async () => {
await test.step('12. Check History tab', async () => {
await documentContentPage.buttonHistoryTab.first().click()
const documentHistoryPage = new DocumentHistoryPage(page)

View File

@ -31,6 +31,7 @@ export class DocumentContentPage extends DocumentCommonPage {
readonly buttonCompleteReview: Locator
readonly inputPassword: Locator
readonly buttonSubmit: Locator
readonly buttonSubmitSignature: Locator
readonly buttonReject: Locator
readonly inputRejectionReason: Locator
readonly buttonApprove: Locator
@ -137,6 +138,7 @@ export class DocumentContentPage extends DocumentCommonPage {
})
this.inputPassword = page.locator('input[name="documents:string:Password"]')
this.buttonSubmit = page.locator('div.popup button[type="submit"]')
this.buttonSubmitSignature = page.locator('.signature-dialog button[type="submit"]')
this.buttonReject = page.locator('button[type="button"] > span', { hasText: 'Reject' })
this.inputRejectionReason = page.locator('div.popup div[id="rejection-reason"] input')
this.buttonApprove = page.locator('button[type="button"] > span', { hasText: 'Approve' })
@ -727,6 +729,7 @@ export class DocumentContentPage extends DocumentCommonPage {
}
await this.textSelectReviewersPopup.click({ force: true })
await this.buttonSelectMemberSubmit.click()
await this.confirmSubmission()
}
async fillSelectApproversForm (approvers: Array<string>): Promise<void> {
@ -736,6 +739,7 @@ export class DocumentContentPage extends DocumentCommonPage {
}
await this.textSelectApproversPopup.click({ force: true })
await this.buttonSelectMemberSubmit.click()
await this.confirmSubmission()
}
async checkCurrentRights (right: DocumentRights): Promise<void> {
@ -772,6 +776,7 @@ export class DocumentContentPage extends DocumentCommonPage {
await this.addReasonAndImpactToTheDocument(reason, impact)
await this.buttonSendForApproval.click()
await this.buttonSelectMemberSubmit.click()
await this.confirmSubmission()
await this.checkDocumentStatus(DocumentStatus.IN_APPROVAL)
await this.checkDocument({
...documentDetails,
@ -817,6 +822,11 @@ export class DocumentContentPage extends DocumentCommonPage {
await this.buttonSubmit.click()
}
async confirmSubmission (): Promise<void> {
await this.inputPassword.fill(PlatformPassword)
await this.buttonSubmitSignature.click()
}
async changeCurrentRight (newRight: DocumentRights): Promise<void> {
await this.buttonCurrentRights.click()
await this.selectMenuItem(this.page, newRight)