Files
huly-platform/plugins/controlled-documents-resources/src/utils.ts
T
Alexey ZinovievandGitHub 8163a30b77 EZQMS-1109: Add signature details for reviews/approvals (#6111)
* ezqms-1109: add signature details for reviews/approvals
Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
2024-07-23 11:09:54 +07:00

853 lines
26 KiB
TypeScript

//
// Copyright © 2022-2023 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
import core, {
type AttachedData,
type Class,
type Doc,
type DocumentQuery,
type Hierarchy,
type Ref,
type Tx,
type TxOperations,
type Space,
type Markup,
type Client,
type WithLookup,
SortingOrder,
generateId,
getCurrentAccount,
checkPermission
} from '@hcengineering/core'
import { type IntlString, getMetadata, getResource, translate } from '@hcengineering/platform'
import presentation, { copyDocumentContent, getClient } from '@hcengineering/presentation'
import contact, { type Employee, type PersonAccount } from '@hcengineering/contact'
import request, { RequestStatus } from '@hcengineering/request'
import textEditor from '@hcengineering/text-editor'
import { isEmptyMarkup } from '@hcengineering/text'
import { getEventPositionElement, showPopup, getUserTimezone, type Location } from '@hcengineering/ui'
import { type KeyFilter } from '@hcengineering/view'
import chunter from '@hcengineering/chunter'
import documents, {
type AttachmentsDocumentSection,
type CollaborativeDocumentSection,
type ControlledDocument,
type Document,
type DocumentRequest,
type DocumentSection,
type DocumentTemplate,
type DocumentSpace,
type DocumentCategory,
type DocumentMeta,
type DocumentComment,
type OrgSpace,
type Project,
type ProjectDocument,
type ProjectMeta,
ControlledDocumentState,
DocumentState,
calcRank,
genRanks,
getDocumentName,
getDocumentId
} from '@hcengineering/controlled-documents'
import { type Request } from '@hcengineering/request'
import documentsResources from './plugin'
import GuidanceEditor from './components/document/editors/GuidanceEditor.svelte'
import { getProjectDocumentLink } from './navigation'
import { wizardOpened } from './stores/wizards/create-document'
export type TranslatedDocumentStates = Readonly<Record<DocumentState, string>>
export const TOKEN = getMetadata(presentation.metadata.Token) ?? ''
export const COLLABORATOR_URL = getMetadata(textEditor.metadata.CollaboratorUrl) ?? ''
export const isDocumentCommentAttachedTo = (
value: DocumentComment | null | undefined,
location: { sectionKey: string | null, nodeId?: string | null }
): boolean => {
if (value === null || value === undefined) {
return false
}
if (value.sectionKey !== location.sectionKey) {
return false
}
if (value.nodeId === location.nodeId) {
return true
}
return (value.nodeId ?? null) === (location.nodeId ?? null)
}
export async function getTranslatedDocumentStates (lang: string): Promise<TranslatedDocumentStates> {
return {
[DocumentState.Draft]: await translate(documents.string.Draft, {}, lang),
[DocumentState.Deleted]: await translate(documents.string.Deleted, {}, lang),
[DocumentState.Effective]: await translate(documents.string.Effective, {}, lang),
[DocumentState.Archived]: await translate(documents.string.Archived, {}, lang)
}
}
export type TranslatedControlledDocStates = Readonly<Record<ControlledDocumentState, string>>
export async function getTranslatedControlledDocStates (lang: string): Promise<TranslatedControlledDocStates> {
return {
[ControlledDocumentState.Approved]: await translate(documentsResources.controlledDocStates.Approved, {}, lang),
[ControlledDocumentState.InApproval]: await translate(documentsResources.controlledDocStates.InApproval, {}, lang),
[ControlledDocumentState.InReview]: await translate(documentsResources.controlledDocStates.InReview, {}, lang),
[ControlledDocumentState.Reviewed]: await translate(documentsResources.controlledDocStates.Reviewed, {}, lang),
[ControlledDocumentState.Rejected]: await translate(documentsResources.controlledDocStates.Rejected, {}, lang),
[ControlledDocumentState.ToReview]: await translate(documentsResources.controlledDocStates.ToReview, {}, lang)
}
}
export function notEmpty<T> (id: T | undefined | null): id is T {
return id !== undefined && id !== null && id !== ''
}
export function isSpace (hierarchy: Hierarchy, doc: Doc): doc is DocumentSpace {
return hierarchy.isDerived(doc._class, documents.class.DocumentSpace)
}
export function isDocument (hierarchy: Hierarchy, doc: Doc): doc is Document {
return hierarchy.isDerived(doc._class, documents.class.Document)
}
export function isDocumentTemplate (hierarchy: Hierarchy, doc: Doc): doc is DocumentTemplate {
return hierarchy.isDerived(doc._class, documents.mixin.DocumentTemplate)
}
export function isProjectDocument (hierarchy: Hierarchy, doc: Doc): doc is ProjectDocument {
return hierarchy.isDerived(doc._class, documents.class.ProjectDocument)
}
export function createCollaborativeSection (
document: Document,
section: AttachedData<DocumentSection>,
copyFrom?: CollaborativeDocumentSection
): AttachedData<CollaborativeDocumentSection> {
const collaboratorSectionId = generateId()
if (copyFrom != null) {
if (document.content !== undefined) {
void copyDocumentContent(document.content, copyFrom.collaboratorSectionId, collaboratorSectionId)
}
}
return {
...section,
collaboratorSectionId,
attachments: 0
}
}
export function createAttachmentsSection (
document: Document,
section: AttachedData<DocumentSection>,
copyFrom?: DocumentSection
): AttachedData<AttachmentsDocumentSection> {
return {
...section,
attachments: 0
}
}
export async function getVisibleFilters (filters: KeyFilter[], space?: Ref<Space>): Promise<KeyFilter[]> {
// Removes the "Space" filter if a specific space is provided
return space === undefined ? filters : filters.filter((f) => f.key !== 'space')
}
export async function sortDocumentStates (_: TxOperations, states: DocumentState[]): Promise<DocumentState[]> {
states.sort((state1, state2) => documentStatesOrder.indexOf(state2) - documentStatesOrder.indexOf(state1))
return states
}
export async function getAllDocumentStates (): Promise<DocumentState[]> {
return documentStatesOrder
}
export async function getDocumentMetaLinkFragment (document: Doc): Promise<Location> {
const meta = document._id as Ref<DocumentMeta>
const client = getClient()
const docs = await client.findAll(
documents.class.ControlledDocument,
{ attachedTo: meta },
{
sort: {
major: SortingOrder.Descending,
minor: SortingOrder.Descending
}
}
)
let targetDocument: ControlledDocument | undefined
for (const doc of docs) {
if (doc.state === DocumentState.Effective) {
targetDocument = doc
break
} else if (doc.state === DocumentState.Deleted && targetDocument === undefined) {
targetDocument = doc
} else if (doc.state === DocumentState.Draft) {
targetDocument = doc
} else if (doc.state === DocumentState.Archived) {
break
}
}
if (targetDocument === undefined) {
throw new Error('Cannot resolve a document for meta ' + meta)
}
const projectDoc = await client.findOne(
documents.class.ProjectDocument,
{ document: targetDocument._id },
{
lookup: {
project: documents.class.Project
},
sort: {
'$lookup.project.createdOn': SortingOrder.Descending
},
limit: 1
}
)
const project = projectDoc?.project ?? documents.ids.NoProject
return getProjectDocumentLink(targetDocument, project)
}
export async function appendSection (document: Document, sectionType: Class<DocumentSection>): Promise<void> {
if (document === undefined) {
return
}
const client = getClient()
const lastSection = await client.findOne(
documents.class.DocumentSection,
{ attachedTo: document._id, attachedToClass: document._class },
{ sort: { rank: SortingOrder.Descending } }
)
await addSectionBetween(client, document, sectionType, lastSection, undefined)
}
export async function addSectionBetween (
client: TxOperations,
document: Document,
sectionType: Class<DocumentSection>,
first?: DocumentSection,
second?: DocumentSection,
copyFrom?: DocumentSection
): Promise<void> {
if (document === undefined) {
return
}
const hierarchy = client.getHierarchy()
const defaultTitle = await translate(documentsResources.string.Untitled, {})
const copy = await translate(documentsResources.string.Copy, {})
let sectionData: AttachedData<DocumentSection> = {
title: copyFrom != null ? `${copyFrom.title} (${copy})` : defaultTitle,
rank: calcRank(first, second),
key: generateId()
}
if (hierarchy.hasMixin(sectionType, documents.mixin.DocumentSectionCreator)) {
const { creator } = hierarchy.as(sectionType, documents.mixin.DocumentSectionCreator)
const create = await getResource(creator)
sectionData = create(document, sectionData, copyFrom)
}
const sectionId = await client.addCollection(
sectionType._id,
document.space,
document._id,
document._class,
'sections',
sectionData
)
const isDocumentTemplate = hierarchy.hasMixin(document, documents.mixin.DocumentTemplate)
if (isDocumentTemplate) {
let description = ''
let guidance = ''
if (copyFrom != null && hierarchy.hasMixin(copyFrom, documents.mixin.DocumentTemplateSection)) {
const templateSection = hierarchy.as(copyFrom, documents.mixin.DocumentTemplateSection)
description = templateSection.description ?? ''
guidance = templateSection.guidance ?? ''
}
await client.updateMixin(sectionId, sectionType._id, document.space, documents.mixin.DocumentTemplateSection, {
mandatory: false,
description,
guidance
})
}
}
export interface TeamPopupData {
controlledDoc: ControlledDocument
requestClass: Ref<Class<DocumentRequest>>
}
export async function sendReviewRequest (
client: TxOperations,
controlledDoc: ControlledDocument,
reviewers: Array<Ref<Employee>>
): Promise<void> {
const reviewersAccounts = await client.findAll(contact.class.PersonAccount, { person: { $in: reviewers } })
if (reviewersAccounts.length === 0) {
return
}
if (reviewersAccounts.length < reviewers.length) {
console.warn('Number of user accounts is less than requested for document review request')
}
const approveTx = client.txFactory.createTxUpdateDoc(controlledDoc._class, controlledDoc.space, controlledDoc._id, {
controlledState: ControlledDocumentState.Reviewed
})
await client.update(controlledDoc, {
reviewers,
controlledState: ControlledDocumentState.InReview
})
await createRequest(
client,
controlledDoc._id,
controlledDoc._class,
documents.class.DocumentReviewRequest,
controlledDoc.space,
reviewersAccounts.map((u) => u._id),
approveTx,
undefined,
true
)
}
export async function sendApprovalRequest (
client: TxOperations,
controlledDoc: ControlledDocument,
approvers: Array<Ref<Employee>>
): Promise<void> {
const approversAccounts = await client.findAll(contact.class.PersonAccount, { person: { $in: approvers } })
if (approversAccounts.length === 0) {
return
}
if (approversAccounts.length < approvers.length) {
console.warn('Number of user accounts is less than requested for document approval request')
}
const approveTx = client.txFactory.createTxUpdateDoc(controlledDoc._class, controlledDoc.space, controlledDoc._id, {
controlledState: ControlledDocumentState.Approved
})
const rejectTx = client.txFactory.createTxUpdateDoc(controlledDoc._class, controlledDoc.space, controlledDoc._id, {
controlledState: ControlledDocumentState.Rejected
})
await client.update(controlledDoc, {
approvers,
controlledState: ControlledDocumentState.InApproval
})
await createRequest(
client,
controlledDoc._id,
controlledDoc._class,
documents.class.DocumentApprovalRequest,
controlledDoc.space,
approversAccounts.map((u) => u._id),
approveTx,
rejectTx,
true
)
}
async function createRequest<T extends Doc> (
client: TxOperations,
attachedTo: Ref<T>,
attachedToClass: Ref<Class<T>>,
reqClass: Ref<Class<Request>>,
space: Ref<DocumentSpace>,
users: Array<Ref<PersonAccount>>,
approveTx: Tx,
rejectedTx?: Tx,
areAllApprovesRequired = true
): Promise<Ref<Request>> {
return await client.addCollection(reqClass, space, attachedTo, attachedToClass, 'requests', {
requested: users,
approved: [],
tx: approveTx,
rejectedTx,
status: RequestStatus.Active,
requiredApprovesCount: areAllApprovesRequired ? users.length : 1
})
}
async function getActiveRequest (
client: TxOperations,
reqClass: Ref<Class<DocumentRequest>>,
controlledDoc: ControlledDocument
): Promise<DocumentRequest | undefined> {
return await client.findOne(
reqClass,
{
attachedTo: controlledDoc._id,
status: RequestStatus.Active
},
{ sort: { modifiedOn: SortingOrder.Descending } } // TODO: replace with "createdOn" field as soon as it's fixed on the Platform
)
}
export async function completeRequest (
client: TxOperations,
reqClass: Ref<Class<DocumentRequest>>,
controlledDoc: ControlledDocument
): Promise<void> {
const req = await getActiveRequest(client, reqClass, controlledDoc)
const me = getCurrentAccount()._id as Ref<PersonAccount>
if (req == null || !req.requested.includes(me) || req.approved.includes(me)) {
return
}
await client.update(req, {
$push: {
approved: me
}
})
}
export async function saveComment (message: Markup | undefined, req: DocumentRequest): Promise<void> {
if (message === undefined || message === '' || isEmptyMarkup(message)) {
return
}
const client = getClient()
const id = await client.addCollection(chunter.class.ChatMessage, req.space, req._id, req._class, 'comments', {
message
})
await client.createMixin(id, chunter.class.ChatMessage, req.space, request.mixin.RequestDecisionComment, {})
}
export async function rejectRequest (
client: TxOperations,
reqClass: Ref<Class<DocumentRequest>>,
controlledDoc: ControlledDocument,
rejectionNote: string
): Promise<void> {
const req = await getActiveRequest(client, reqClass, controlledDoc)
if (req == null || req.rejected != null) {
return
}
const me = getCurrentAccount()._id as Ref<PersonAccount>
await saveComment(rejectionNote, req)
await client.update(req, {
rejected: me,
status: RequestStatus.Rejected
})
}
export type ControlledStatesTags = {
[K in ControlledDocumentState]: DocumentStateTagType
}
export const controlledStatesTags: ControlledStatesTags = {
[ControlledDocumentState.InReview]: 'inProgress',
[ControlledDocumentState.Reviewed]: 'inProgress',
[ControlledDocumentState.InApproval]: 'inProgress',
[ControlledDocumentState.Approved]: 'inProgress',
[ControlledDocumentState.Rejected]: 'rejected',
[ControlledDocumentState.ToReview]: 'effective'
}
export type StatesTags = {
[K in DocumentState]: DocumentStateTagType
}
export async function changeSectionIndex (
client: TxOperations,
section: DocumentSection,
newIndex: number
): Promise<void> {
const sections = await client.findAll(
documents.class.DocumentSection,
{ attachedTo: section.attachedTo },
{ sort: { rank: SortingOrder.Ascending } }
)
if (sections.length === 0) {
return
}
const oldIndex = sections.findIndex((s) => s._id === section._id)
if (oldIndex === newIndex) {
return
}
let newSections = [...sections.slice(0, oldIndex), ...sections.slice(oldIndex + 1)]
newSections = [...newSections.slice(0, newIndex), section, ...newSections.slice(newIndex)]
const ranks = genRanks(sections.length)
for (const i of Array(sections.length).keys()) {
const rank = ranks.next().value as string
await client.update(newSections[i], { rank })
}
}
export const statesTags: StatesTags = {
[DocumentState.Draft]: 'draft',
[DocumentState.Effective]: 'effective',
[DocumentState.Archived]: 'obsolete',
[DocumentState.Deleted]: 'obsolete'
}
export const documentStatesOrder = [
DocumentState.Draft,
DocumentState.Effective,
DocumentState.Archived,
DocumentState.Deleted
]
export const controlledDocumentStatesOrder = [
ControlledDocumentState.InReview,
ControlledDocumentState.Reviewed,
ControlledDocumentState.InApproval,
ControlledDocumentState.Approved,
ControlledDocumentState.Rejected,
ControlledDocumentState.ToReview
]
export interface LoginInfo {
email: string
password: string
}
export const loginIntlFieldNames: Readonly<{ [K in keyof LoginInfo]: IntlString }> = {
email: documentsResources.string.Email,
password: documentsResources.string.Password
}
export type DocumentStateTagType = 'effective' | 'inProgress' | 'rejected' | 'draft' | 'obsolete'
export type GuidanceEditorMode = 'readonly' | 'canEdit' | 'editing'
export function openGuidanceEditor (
client: TxOperations,
section: DocumentSection,
index: number,
mode: GuidanceEditorMode,
ev?: MouseEvent
): void {
showPopup(
GuidanceEditor,
{ section, index, mode, width: mode !== 'editing' ? '30rem' : undefined },
mode === 'editing' || ev == null ? 'center' : getEventPositionElement(ev),
async (res) => {
if (res == null) return
const { reopenMode, guidance } = res
if (reopenMode != null) {
openGuidanceEditor(client, section, index, reopenMode)
} else if (mode === 'editing' && guidance != null) {
await client.updateMixin(section._id, section._class, section.space, documents.mixin.DocumentTemplateSection, {
guidance
})
}
}
)
}
export function isDocOwner (ownableDocument: { owner?: Ref<Employee> }): boolean {
const currentPerson = (getCurrentAccount() as PersonAccount)?.person
return ownableDocument.owner === currentPerson
}
export async function canChangeDocumentOwner (doc?: Document | Document[]): Promise<boolean> {
if (doc === null || doc === undefined) {
return false
}
if (Array.isArray(doc)) {
return false
}
const me = getCurrentEmployee()
if (me === doc.owner) {
return true
}
return await checkPermission(getClient(), documents.permission.UpdateDocumentOwner, doc.space)
}
export async function canCreateChildTemplate (
doc?: Document | Document[] | DocumentSpace | DocumentSpace[] | ProjectDocument | ProjectDocument[]
): Promise<boolean> {
if (doc === null || doc === undefined) {
return false
}
if (Array.isArray(doc)) {
return false
}
const client = getClient()
const spaceId: Ref<DocumentSpace> = isSpace(client.getHierarchy(), doc) ? doc._id : doc.space
const orgSpace = await client.findOne(documents.class.OrgSpace, { _id: spaceId })
return orgSpace !== undefined && (await checkPermission(client, documents.permission.CreateDocument, spaceId))
}
export async function canCreateChildDocument (
doc?: Document | Document[] | DocumentSpace | DocumentSpace[] | ProjectDocument | ProjectDocument[]
): Promise<boolean> {
if (doc === null || doc === undefined) {
return false
}
if (Array.isArray(doc)) {
return false
}
const client = getClient()
const hierarchy = client.getHierarchy()
const spaceId: Ref<DocumentSpace> = isSpace(hierarchy, doc) ? doc._id : doc.space
const canCreateDocument = await checkPermission(client, documents.permission.CreateDocument, spaceId)
if (!canCreateDocument) {
return false
}
if (isSpace(hierarchy, doc)) {
const spaceType = await client.findOne(documents.class.DocumentSpaceType, { _id: doc.type })
return spaceType?.projects !== true
}
if (isProjectDocument(hierarchy, doc)) {
return await isEditableProject(doc.project)
}
return true
}
export async function canDeleteDocumentCategory (doc?: Doc | Doc[]): Promise<boolean> {
if (doc === null || doc === undefined) {
return false
}
if (Array.isArray(doc)) {
return false
}
const client = getClient()
const attachedItem = await client.findOne(documents.class.Document, {
category: doc._id as Ref<DocumentCategory>
})
if (attachedItem !== undefined) {
return false
}
return await checkPermission(client, documents.permission.DeleteDocumentCategory, (doc as DocumentCategory).space)
}
function getCurrentProjectId (space: Ref<DocumentSpace>): string {
return `${space}_###_project`
}
export function getCurrentProject (space: Ref<DocumentSpace>): Ref<Project> | undefined {
return localStorage.getItem(getCurrentProjectId(space)) as Ref<Project>
}
export function setCurrentProject (space: Ref<DocumentSpace>, project: Ref<Project> | undefined): void {
if (project !== undefined) {
localStorage.setItem(getCurrentProjectId(space), project)
} else {
localStorage.removeItem(getCurrentProjectId(space))
}
}
async function getLatestProject (space: Ref<DocumentSpace>, includeReadonly = false): Promise<Project | undefined> {
const client = getClient()
// TODO we should use better approach on selecting the latest available project
// consider assigning sequential numbers to projects
const query: DocumentQuery<Project> = includeReadonly ? { space } : { space, readonly: false }
return await client.findOne(documents.class.Project, query, {
limit: 1,
sort: { createdOn: SortingOrder.Descending }
})
}
export async function getLatestProjectId (
spaceRef: Ref<DocumentSpace>,
includeReadonly = false
): Promise<Ref<Project> | undefined> {
const client = getClient()
const space = await client.findOne(
documents.class.DocumentSpace,
{
_id: spaceRef
},
{
lookup: {
type: core.class.SpaceType
}
}
)
if (space !== undefined) {
if (space.$lookup?.type?.projects ?? false) {
const project = await getLatestProject(spaceRef, includeReadonly)
return project?._id
} else {
return documents.ids.NoProject
}
}
}
export async function isEditableProject (_id: Ref<Project> | undefined): Promise<boolean> {
if (_id === undefined) {
return false
}
if (_id === documents.ids.NoProject) {
return true
}
const client = getClient()
const project = await client.findOne(documents.class.Project, { _id })
return project !== undefined && !project.readonly
}
export function getProjectDocsHierarchy (projectMeta: Array<WithLookup<ProjectMeta>>): {
rootDocs: Array<WithLookup<ProjectMeta>>
childrenByParent: Record<Ref<WithLookup<DocumentMeta>>, Array<WithLookup<ProjectMeta>>>
} {
const rootDocs: Array<WithLookup<ProjectMeta>> = []
const childrenByParent: Record<Ref<DocumentMeta>, Array<WithLookup<ProjectMeta>>> = {}
for (const meta of projectMeta) {
const parentId = meta.path[0] ?? documents.ids.NoParent
if (childrenByParent[parentId] === undefined) {
childrenByParent[parentId] = []
}
childrenByParent[parentId].push(meta)
if (parentId === documents.ids.NoParent) {
rootDocs.push(meta)
}
}
return { rootDocs, childrenByParent }
}
export function compareDocs (doc1: Document, doc2: Document): number {
return getDocumentName(doc1).localeCompare(getDocumentName(doc2), undefined, { numeric: true })
}
export async function documentIdentifierProvider (client: Client, ref: Ref<Document>, doc?: Document): Promise<string> {
const document = doc ?? (await client.findOne(documents.class.Document, { _id: ref }))
if (document === undefined) {
return ''
}
return getDocumentId(document)
}
export function documentCompareFn (doc1: Document, doc2: Document): number {
return doc1.major - doc2.major !== 0 ? doc1.major - doc2.major : doc1.minor - doc2.minor
}
export function getDocumentVersionString (doc: Document): string {
return `v${doc.major}.${doc.minor}`
}
export async function getControlledDocumentTitle (
client: Client,
ref: Ref<ControlledDocument>,
doc?: ControlledDocument
): Promise<string> {
const object = doc ?? (await client.findOne(documents.class.ControlledDocument, { _id: ref }))
if (object === undefined) return ''
return object.title
}
export const getCurrentEmployee = (): Ref<Employee> | undefined => {
const currentAccount = getCurrentAccount()
const person = (currentAccount as PersonAccount)?.person
if (person === null || person === undefined) {
return undefined
}
return person as Ref<Employee>
}
export async function createChildDocument (doc: ProjectDocument): Promise<void> {
wizardOpened({ $$currentStep: 'template', location: { space: doc.space, project: doc.project, parent: doc._id } })
showPopup(documents.component.QmsDocumentWizard, {})
}
export async function createChildTemplate (doc: ProjectDocument): Promise<void> {
wizardOpened({ $$currentStep: 'info', location: { space: doc.space, project: doc.project, parent: doc._id } })
showPopup(documents.component.QmsTemplateWizard, {})
}
export async function createDocument (space: DocumentSpace): Promise<void> {
const project = await getLatestProjectId(space._id)
wizardOpened({
$$currentStep: 'template',
location: { space: space._id, project: project ?? documents.ids.NoProject }
})
showPopup(documents.component.QmsDocumentWizard, {})
}
export async function createTemplate (space: OrgSpace): Promise<void> {
const project = await getLatestProjectId(space._id)
wizardOpened({ $$currentStep: 'info', location: { space: space._id, project: project ?? documents.ids.NoProject } })
showPopup(documents.component.QmsTemplateWizard, {})
}
export function formatSignatureDate (date: number): string {
const timeZone: string = getUserTimezone()
return new Date(date).toLocaleDateString('default', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone,
timeZoneName: 'short',
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
})
}