UBERF-7090: Add QMS plugins (#5716)

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
Alexey Zinoviev
2024-06-03 19:55:54 +04:00
committed by GitHub
parent f11a7f46c2
commit 48e1ca9849
618 changed files with 40225 additions and 7 deletions
@@ -0,0 +1,204 @@
<!--
// 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.
-->
<script lang="ts">
import chunter from '@hcengineering/chunter'
import { getClient } from '@hcengineering/presentation'
import { Heading, isEmptyMarkup } from '@hcengineering/text-editor'
import { Button, Component, EditBox } from '@hcengineering/ui'
import documents, { Document, DocumentSection, DocumentTemplateSection } from '@hcengineering/controlled-documents'
import Info from '../icons/Info.svelte'
import {
$documentCommentHighlightedLocation as highlighted,
$canAddDocumentComments as canAddDocumentComments,
$canViewDocumentComments as canViewDocumentComments,
$collapsedDocumentSectionIds as collapsedSectionIds,
$documentSectionEditingDescription as documentSectionEditingDescription,
$groupedDocumentComments as groupedDocumentComments,
$isEditable as isEditable,
documentCommentsDisplayRequested,
documentSectionToggled,
showAddCommentPopupFx,
updateDocumentSectionDescriptionFx
} from '../../stores/editors/document'
import { openGuidanceEditor } from '../../utils'
import FieldSectionEditor from '../FieldSectionEditor.svelte'
import DescriptionEditor from './editors/DescriptionEditor.svelte'
export let document: Document
export let value: DocumentSection
export let index: number
export let dragging = false
export let headings: Heading[] = [] // out
const client = getClient()
const h = client.getHierarchy()
$: editor = h.as(h.getClass(value._class), documents.mixin.DocumentSectionEditor)?.editor
let sectionHasGuidance = false
let templateSection: DocumentTemplateSection | undefined
$: getDocumentTemplateSection(value)
async function getDocumentTemplateSection (section: DocumentSection) {
if (h.hasMixin(value, documents.mixin.DocumentTemplateSection)) {
templateSection = h.as(value, documents.mixin.DocumentTemplateSection)
} else if (value.templateSectionId != null) {
templateSection = await client.findOne(documents.mixin.DocumentTemplateSection, { _id: value.templateSectionId })
}
if (templateSection) {
const guidance = templateSection.guidance
sectionHasGuidance = !isEmptyMarkup(guidance)
}
}
let contentHeadings: Heading[] = []
$: sectionHeading = makeSectionHeading(value, index)
$: headings = [sectionHeading, ...contentHeadings]
function handleSectionContentHeadings (headings: Heading[]): void {
contentHeadings = headings
}
function makeSectionHeading (section: DocumentSection, index: number): Heading {
index = index + 1
const id = `section-${index}`
const title = `${index}. ${section.title}`
return { id, title, level: 0 }
}
async function handleOpenAddCommentPopup (ev?: Event): Promise<void> {
if (!$canAddDocumentComments) {
return
}
await showAddCommentPopupFx({
element: (ev as MouseEvent).target as HTMLElement,
sectionKey: value.key
})
}
function handleDisplayDocumentComments (ev?: Event): void {
if (!$canViewDocumentComments) {
return
}
if (!$groupedDocumentComments.hasDocumentComments(value.key)) {
return
}
ev?.stopPropagation()
documentCommentsDisplayRequested({ element: (ev as MouseEvent).target as HTMLElement, sectionKey: value.key })
}
async function updateSectionTitle (title: string) {
await client.update(value, { title })
}
let sectionElement: HTMLElement
export function getSectionElement (): HTMLElement {
return sectionElement
}
let title = value.title
let descr = ''
$: getDescription(templateSection)
$: isActiveSectionNode = !!$highlighted && $highlighted.sectionKey === value.key && !$highlighted.nodeId
function getDescription (section?: DocumentTemplateSection) {
if (section != null) {
descr = h.as(section, documents.mixin.DocumentTemplateSection).description ?? ''
}
}
$: isEditingDescription = $documentSectionEditingDescription === value._id
async function stopEditingDescription () {
if (!templateSection) {
return
}
updateDocumentSectionDescriptionFx({ section: templateSection, description: descr })
}
function showGuidance (ev: MouseEvent) {
if (templateSection == null) {
return
}
const isEditingTemplate = templateSection._id === value._id
openGuidanceEditor(client, templateSection, index + 1, isEditingTemplate ? 'canEdit' : 'readonly', ev)
}
</script>
<FieldSectionEditor
editable={$isEditable}
expanded={!$collapsedSectionIds.has(value._id)}
on:toggle={() => documentSectionToggled(value._id)}
>
<svelte:fragment slot="before-header">
<slot name="before-header" />
</svelte:fragment>
<svelte:fragment slot="index">
{index + 1}
</svelte:fragment>
<svelte:fragment slot="header">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
id={sectionHeading.id}
class:text-editor-highlighted-node-warning={isActiveSectionNode ||
$groupedDocumentComments.hasDocumentComments(value.key)}
class:text-editor-highlighted-node-selected={isActiveSectionNode}
bind:this={sectionElement}
on:click={handleDisplayDocumentComments}
>
{#if $isEditable && !dragging}
<EditBox maxWidth="100%" bind:value={title} propagateClick on:change={() => updateSectionTitle(title)} />
{:else}
{title}
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="header-extra">
<div class="flex-row-center ml-2 mr-10">
{#if $canAddDocumentComments}
<Button icon={chunter.icon.Chunter} kind="list-header" size="small" on:click={handleOpenAddCommentPopup} />
{/if}
{#if templateSection && sectionHasGuidance}
<Button icon={Info} kind="list-header" size="small" on:click={showGuidance} />
{/if}
</div>
</svelte:fragment>
<svelte:fragment slot="before-content">
{#if isEditingDescription || descr.length > 0}
<div class="section-descr no-print">
<DescriptionEditor bind:value={descr} disabled={!isEditingDescription} on:blur={stopEditingDescription} />
</div>
{/if}
</svelte:fragment>
<svelte:fragment slot="content">
<Component
is={editor}
props={{
value,
document,
onHeadings: handleSectionContentHeadings
}}
on:change
/>
</svelte:fragment>
</FieldSectionEditor>
@@ -0,0 +1,197 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { Label } from '@hcengineering/ui'
import { Account, TypedSpace, type Data, type Ref, getCurrentAccount, type Permission } from '@hcengineering/core'
import contact, { PersonAccount, type Employee } from '@hcengineering/contact'
import documents, { type ControlledDocument } from '@hcengineering/controlled-documents'
import { UserBoxItems } from '@hcengineering/contact-resources'
import { type PermissionsStore, permissionsStore } from '@hcengineering/view-resources'
import { createQuery } from '@hcengineering/presentation'
export let controlledDoc: Data<ControlledDocument>
export let space: Ref<TypedSpace>
export let canChangeReviewers: boolean = true
export let canChangeApprovers: boolean = true
export let canChangeCoAuthors: boolean = true
const dispatch = createEventDispatcher()
const currentAccount = getCurrentAccount()
$: reviewers = controlledDoc.reviewers
$: approvers = controlledDoc.approvers
$: coAuthors = controlledDoc.coAuthors
$: permissionsSpace = space === documents.space.UnsortedTemplates ? documents.space.QualityDocuments : space
function getPermittedAccounts (
permission: Ref<Permission>,
space: Ref<TypedSpace>,
permissionsStore: PermissionsStore
): Array<Ref<Account>> {
return Array.from(permissionsStore.ap[space]?.[permission] ?? [])
}
let permittedReviewers: Array<Ref<Employee>> = []
$: permittedReviewerAccounts = getPermittedAccounts(
documents.permission.ReviewDocument,
permissionsSpace,
$permissionsStore
)
const prQuery = createQuery()
$: if (permittedReviewerAccounts.length > 0) {
prQuery.query(
contact.class.PersonAccount,
{
_id: { $in: Array.from(permittedReviewerAccounts) as Array<Ref<PersonAccount>> }
},
(res) => {
permittedReviewers = res.map((pa) => pa.person) as Array<Ref<Employee>>
}
)
} else {
permittedReviewers = []
}
let permittedApprovers: Array<Ref<Employee>> = []
$: permittedApproverAccounts = getPermittedAccounts(
documents.permission.ApproveDocument,
permissionsSpace,
$permissionsStore
)
const paQuery = createQuery()
$: if (permittedApproverAccounts.length > 0) {
paQuery.query(
contact.class.PersonAccount,
{
_id: { $in: Array.from(permittedApproverAccounts) as Array<Ref<PersonAccount>> }
},
(res) => {
permittedApprovers = res.map((pa) => pa.person) as Array<Ref<Employee>>
}
)
} else {
permittedApprovers = []
}
let permittedCoAuthors: Array<Ref<Employee>> = []
$: permittedCoAuthorAccounts = getPermittedAccounts(
documents.permission.CoAuthorDocument,
permissionsSpace,
$permissionsStore
).filter((acc) => acc !== currentAccount._id)
const pcaQuery = createQuery()
$: if (permittedCoAuthorAccounts.length > 0) {
pcaQuery.query(
contact.class.PersonAccount,
{
_id: { $in: Array.from(permittedCoAuthorAccounts) as Array<Ref<PersonAccount>> }
},
(res) => {
permittedCoAuthors = res.map((pa) => pa.person) as Array<Ref<Employee>>
}
)
} else {
permittedCoAuthors = []
}
function handleUsersUpdated (type: 'reviewers' | 'approvers' | 'coAuthors', users: Ref<Employee>[]): void {
dispatch('update', { type, users })
}
</script>
<div class="flex-col">
<div class="flex labelContainer">
<div class="label mr-1">
<Label label={documents.string.CoAuthors} />
</div>
{reviewers?.length}
</div>
<div class="flex-col mt-4">
<UserBoxItems
items={coAuthors}
docQuery={{
active: true,
_id: { $in: permittedCoAuthors }
}}
label={documents.string.CoAuthors}
readonly={!canChangeCoAuthors}
on:update={({ detail }) => {
handleUsersUpdated('coAuthors', detail)
}}
/>
</div>
<div class="mt-6 mb-6 divider" />
<div class="flex labelContainer">
<div class="label mr-1">
<Label label={documents.string.Reviewers} />
</div>
{reviewers?.length}
</div>
<div class="flex-col mt-4">
<UserBoxItems
items={reviewers}
docQuery={{
active: true,
_id: { $in: permittedReviewers }
}}
label={documents.string.Reviewers}
readonly={!canChangeReviewers}
on:update={({ detail }) => {
handleUsersUpdated('reviewers', detail)
}}
/>
</div>
<div class="mt-6 mb-6 divider" />
<div class="flex labelContainer">
<div class="label mr-1">
<Label label={documents.string.Approvers} />
</div>
{approvers?.length}
</div>
<div class="flex-col mt-4">
<UserBoxItems
items={approvers}
docQuery={{
active: true,
_id: { $in: permittedApprovers }
}}
label={documents.string.Approvers}
readonly={!canChangeApprovers}
on:update={({ detail }) => {
handleUsersUpdated('approvers', detail)
}}
/>
</div>
</div>
<style lang="scss">
.labelContainer {
min-width: 11rem;
}
.label {
color: var(--theme-qms-form-row-label-color);
font-weight: 500;
}
.divider {
height: 1px;
width: 100%;
min-height: 1px;
background-color: var(--divider-color);
}
</style>
@@ -0,0 +1,202 @@
<script lang="ts">
import { getContext, onDestroy } from 'svelte'
import { Doc as Ydoc } from 'yjs'
import { getClient } from '@hcengineering/presentation'
import { type Doc } from '@hcengineering/core'
import {
CollaborationIds,
StringDiffViewer,
TiptapCollabProvider,
createTiptapCollaborationData,
formatCollaborativeDocumentId
} from '@hcengineering/text-editor'
import { Dropdown, Label, ListItem, Loading, Scroller, themeStore } from '@hcengineering/ui'
import documents, {
ControlledDocument,
ControlledDocumentSnapshot,
ControlledDocumentState,
Document,
DocumentSection,
DocumentState
} from '@hcengineering/controlled-documents'
import plugin from '../../plugin'
import {
$controlledDocument as controlledDocument,
$controlledDocumentSections as sections,
$comparedDocument as compareTo,
$comparedDocumentSections as compareToSections,
$documentComparisonVersions as documentComparisonVersions,
ComparisonSectionPair,
comparisonRequested,
loadComparedDocumentSectionsFx
} from '../../stores/editors/document'
import { COLLABORATOR_URL, TOKEN, getTranslatedControlledDocStates, getTranslatedDocumentStates } from '../../utils'
import DocumentSectionPairDiffViewer from './DocumentSectionPairDiffViewer.svelte'
import DocumentTitle from './DocumentTitle.svelte'
const client = getClient()
const hierarchy = client.getHierarchy()
const ydoc = getContext<Ydoc>(CollaborationIds.Doc)
let collapsedPairIndices = new Set<number>()
let comparedYdoc: Ydoc | undefined = undefined
let comparedProvider: TiptapCollabProvider | undefined = undefined
let loading = true
const isLoadPending = loadComparedDocumentSectionsFx.pending
const handleSectionDiffPairs = (firstSections: DocumentSection[], secondSections: DocumentSection[]) => {
const result: ComparisonSectionPair[] = []
const firstSectionKeys = new Set(firstSections.map((section) => section.key))
const secondSectionKeys = new Set(secondSections.map((section) => section.key))
let secondIndex = 0
let firstIndex = 0
while (firstIndex < firstSections.length) {
const firstSection = firstSections[firstIndex]
if (secondSectionKeys.has(firstSection.key)) {
while (secondIndex < secondSections.length && !firstSectionKeys.has(secondSections[secondIndex].key)) {
result.push([null, { section: secondSections[secondIndex], index: secondIndex + 1 }])
secondIndex++
}
}
if (secondIndex < secondSections.length && firstSection.key === secondSections[secondIndex].key) {
result.push([
{ section: firstSection, index: firstIndex + 1 },
{ section: secondSections[secondIndex], index: secondIndex + 1 }
])
secondIndex++
} else {
result.push([{ section: firstSection, index: firstIndex + 1 }, null])
}
firstIndex++
}
while (secondIndex < secondSections.length) {
result.push([null, { section: secondSections[secondIndex], index: secondIndex + 1 }])
secondIndex++
}
return result
}
const handleSelect = (event: CustomEvent<ListItem>) => {
const version = $documentComparisonVersions.find((item) => item._id === event.detail._id)
if (version) {
comparisonRequested(version)
}
}
let translatedStates: Readonly<Record<DocumentState | ControlledDocumentState, string>> | null = null
function getTranslatedLabels (lang: string) {
Promise.all([getTranslatedDocumentStates(lang), getTranslatedControlledDocStates(lang)]).then(
([states, controlledStates]) => {
translatedStates = {
...states,
...controlledStates
}
}
)
}
function isDocument (document: Doc | null): document is Document {
if (document == null) {
return false
}
return hierarchy.isDerived(document._class, documents.class.Document)
}
const generateVersionName = (
document: ControlledDocument | ControlledDocumentSnapshot,
translatedStates: Readonly<Record<DocumentState | ControlledDocumentState, string>> | null
): string => {
let state: ControlledDocumentState | DocumentState | undefined = document.controlledState
if (state == null) {
state = document.state ?? DocumentState.Draft
}
if (isDocument(document)) {
return `v${document.major}.${document.minor} | ${translatedStates ? translatedStates[state] : ''}`
} else {
return `${document.name} | ${translatedStates ? translatedStates[state] : ''}`
}
}
$: getTranslatedLabels($themeStore.language)
$: versionItems = $documentComparisonVersions.map((version) => ({
_id: version._id,
label: generateVersionName(version, translatedStates)
}))
$: if ($compareTo) {
if (comparedProvider) {
comparedProvider.disconnect()
}
loading = true
const collaborativeDoc = $compareTo.content
const data = createTiptapCollaborationData({
collaboratorURL: COLLABORATOR_URL,
token: TOKEN,
documentId: formatCollaborativeDocumentId(collaborativeDoc)
})
comparedYdoc = data.ydoc
comparedProvider = data.provider
comparedProvider.loaded.then(() => (loading = false))
}
$: sectionDiffPairs = handleSectionDiffPairs($sections, $compareToSections)
onDestroy(() => {
comparedProvider?.destroy()
})
</script>
<div class="flex flex-gap-2 h-12 pl-7 items-center bottom-divider">
<Label label={plugin.string.Compare} />
<Dropdown
items={versionItems}
disabled
selected={versionItems.find((item) => item._id === $controlledDocument?._id)}
withSearch={false}
placeholder={documents.string.Version}
/>
<Label label={plugin.string.Against} />
<Dropdown
items={versionItems}
selected={versionItems.find((item) => item._id === $compareTo?._id)}
withSearch={false}
placeholder={documents.string.Version}
on:selected={handleSelect}
/>
</div>
{#if loading || $isLoadPending}
<Loading />
{:else}
<Scroller>
<div class="antiAccordion">
<div class="pl-7">
<DocumentTitle>
<StringDiffViewer
value={$controlledDocument?.title ?? ''}
compareTo={(isDocument($compareTo) ? $compareTo : $controlledDocument)?.title ?? ''}
/>
</DocumentTitle>
</div>
{#each sectionDiffPairs as pair, index}
<DocumentSectionPairDiffViewer
{pair}
firstYdoc={ydoc}
secondYdoc={comparedYdoc}
expanded={!collapsedPairIndices.has(index)}
on:toggle={() => {
if (collapsedPairIndices.has(index)) {
collapsedPairIndices.delete(index)
} else {
collapsedPairIndices.add(index)
}
collapsedPairIndices = new Set(collapsedPairIndices)
}}
/>
{/each}
</div>
</Scroller>
{/if}
@@ -0,0 +1,136 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { type Ref } from '@hcengineering/core'
import { Label, Scroller } from '@hcengineering/ui'
import { getClient } from '@hcengineering/presentation'
import documents, { type ChangeControl } from '@hcengineering/controlled-documents'
import documentsRes from '../../plugin'
import {
$controlledDocument as controlledDocument,
$documentReleasedVersions as documentReleasedVersions
} from '../../stores/editors/document/editor'
import { documentCompareFn, getDocumentVersionString } from '../../utils'
const client = getClient()
let changeControls: Record<Ref<ChangeControl>, ChangeControl> = {}
$: if ($documentReleasedVersions.length > 0) {
void client
.findAll(documents.class.ChangeControl, {
_id: { $in: $documentReleasedVersions.map((v) => v.changeControl) }
})
.then((res) => {
changeControls = res.reduce<typeof changeControls>((prev, curr) => {
prev[curr._id] = curr
return prev
}, {})
})
}
$: orderedVersions = $documentReleasedVersions
.filter((doc) => {
if ($controlledDocument == null) {
return false
}
return (
doc.major < $controlledDocument.major ||
(doc.major === $controlledDocument.major && doc.minor <= $controlledDocument.minor)
)
})
.toSorted(documentCompareFn)
function getDescription (cc: ChangeControl | undefined): string {
if (cc === undefined) {
return ''
}
return [cc.reason, cc.description].filter((s) => s !== '' && s !== undefined).join('\n')
}
</script>
<Scroller>
<div class="root">
{#if orderedVersions.length > 0}
<div class="flex-col list">
{#each orderedVersions as version}
<div class="row flex-row-top px-4">
<div class="flex-col col">
<div class="fs-title text-normal version">
{getDocumentVersionString(version)}
</div>
{#if version.effectiveDate !== undefined}
<div class="date">
{new Date(version.effectiveDate).toLocaleDateString('default', {
year: 'numeric',
month: 'short',
day: 'numeric'
})}
</div>
{/if}
</div>
<div class="description">
{getDescription(changeControls[version.changeControl])}
</div>
</div>
{/each}
</div>
{:else}
<Label label={documentsRes.string.FirstDraftVersion} />
{/if}
</div>
</Scroller>
<style lang="scss">
.root {
padding: 1.5rem 3.25rem;
@media print {
padding: 0;
}
}
.list {
gap: 3rem;
}
.col {
flex: 0 0 6rem;
}
.row {
gap: 3rem;
@media print {
border-left: 2px solid var(--theme-divider-color);
}
}
.version {
line-height: 1.25rem;
}
.date {
font-size: 0.6875rem;
color: var(--theme-dark-color);
line-height: 1rem;
}
.description {
white-space: pre-wrap;
line-height: 1.25rem;
}
</style>
@@ -0,0 +1,27 @@
<!--
// Copyright © 2024 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.
-->
<script lang="ts">
import DocumentHistory from './DocumentHistory.svelte'
</script>
<div class="root">
<DocumentHistory />
</div>
<style lang="scss">
.root {
padding: 1.5rem 3.25rem;
}
</style>
@@ -0,0 +1,46 @@
<script lang="ts">
import { Doc as Ydoc } from 'yjs'
import { getClient } from '@hcengineering/presentation'
import { CollaborationDiffViewer, StringDiffViewer } from '@hcengineering/text-editor'
import documents, { DocumentSection, CollaborativeDocumentSection } from '@hcengineering/controlled-documents'
import plugin from '../../plugin'
import { ComparisonSectionPair } from '../../stores/editors/document'
import FieldSectionEditor from '../FieldSectionEditor.svelte'
export let pair: ComparisonSectionPair
export let firstYdoc: Ydoc
export let secondYdoc: Ydoc | undefined = undefined
export let expanded = false
const hierarchy = getClient().getHierarchy()
const getSectionField = (section: DocumentSection | undefined | null) => {
if (!section) {
return ''
}
return (section as CollaborativeDocumentSection).collaboratorSectionId
}
$: _class = pair[0]?.section._class ?? pair[1]?.section._class
</script>
<FieldSectionEditor {expanded} editable={false} on:toggle>
<svelte:fragment slot="index">
<StringDiffViewer value={`${pair[0]?.index ?? ''}`} compareTo={`${pair[1]?.index ?? ''}`} />
</svelte:fragment>
<svelte:fragment slot="header">
<StringDiffViewer value={pair[0]?.section.title ?? ''} compareTo={pair[1]?.section.title ?? ''} />
</svelte:fragment>
<svelte:fragment slot="content">
{#if _class && hierarchy.isDerived(_class, documents.class.CollaborativeDocumentSection)}
<CollaborationDiffViewer
field={getSectionField(pair[0]?.section)}
comparedField={getSectionField(pair[1]?.section)}
ydoc={firstYdoc}
comparedYdoc={secondYdoc}
/>
{:else}
{plugin.string.ComparisonModeNotSupported}
{/if}
</svelte:fragment>
</FieldSectionEditor>
@@ -0,0 +1,215 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { Ref, SortingOrder } from '@hcengineering/core'
import { Label, Scroller, getUserTimezone } from '@hcengineering/ui'
import { createQuery } from '@hcengineering/presentation'
import documents, { DocumentApprovalRequest, DocumentReviewRequest } from '@hcengineering/controlled-documents'
import { employeeByIdStore, personAccountByIdStore } from '@hcengineering/contact-resources'
import { Employee, Person, formatName } from '@hcengineering/contact'
import { IntlString } from '@hcengineering/platform'
import documentsRes from '../../plugin'
import { $controlledDocument as controlledDocument } from '../../stores/editors/document/editor'
interface Signer {
id?: Ref<Person>
role: 'author' | 'reviewer' | 'approver'
name: string
date: string
}
let signers: Signer[] = []
let reviewRequest: DocumentReviewRequest
let approvalRequest: DocumentApprovalRequest
const reviewQuery = createQuery()
const approvalQuery = createQuery()
const timeZone: string = getUserTimezone()
$: if ($controlledDocument !== undefined) {
reviewQuery.query(
documents.class.DocumentReviewRequest,
{
attachedTo: $controlledDocument?._id,
attachedToClass: $controlledDocument?._class
},
(res) => {
reviewRequest = res[0]
},
{
sort: { createdOn: SortingOrder.Descending },
limit: 1
}
)
approvalQuery.query(
documents.class.DocumentApprovalRequest,
{
attachedTo: $controlledDocument?._id,
attachedToClass: $controlledDocument?._class
},
(res) => {
approvalRequest = res[0]
},
{
sort: { createdOn: SortingOrder.Descending },
limit: 1
}
)
} else {
reviewQuery.unsubscribe()
approvalQuery.unsubscribe()
}
$: if ($controlledDocument !== null) {
const getNameByEmployeeId = (id: Ref<Person> | undefined): string => {
if (id === undefined) {
return ''
}
const employee = $employeeByIdStore.get(id as Ref<Employee>)
const rawName = employee?.name
return rawName !== undefined ? formatName(rawName) : ''
}
signers = [
{
id: $controlledDocument.author,
role: 'author',
name: getNameByEmployeeId($controlledDocument.author),
date: $controlledDocument.createdOn !== undefined ? formatDate($controlledDocument.createdOn) : ''
}
]
if (reviewRequest !== undefined) {
reviewRequest.approved.forEach((reviewer, idx) => {
const rAcc = $personAccountByIdStore.get(reviewer)
const date = reviewRequest.approvedDates?.[idx]
signers.push({
id: rAcc?.person,
role: 'reviewer',
name: getNameByEmployeeId(rAcc?.person),
date: formatDate(date ?? reviewRequest.modifiedOn)
})
})
}
if (approvalRequest !== undefined) {
approvalRequest.approved.forEach((approver, idx) => {
const aAcc = $personAccountByIdStore.get(approver)
const date = approvalRequest.approvedDates?.[idx]
signers.push({
id: aAcc?.person,
role: 'approver',
name: getNameByEmployeeId(aAcc?.person),
date: formatDate(date ?? approvalRequest.modifiedOn)
})
})
}
}
function formatDate (date: number): string {
return new Date(date).toLocaleDateString('default', {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone,
timeZoneName: 'short',
hour: 'numeric',
minute: 'numeric',
second: 'numeric'
})
}
function getSignerLabel (role: 'author' | 'reviewer' | 'approver'): IntlString {
switch (role) {
case 'author':
return documentsRes.string.Author
case 'reviewer':
return documentsRes.string.Reviewer
case 'approver':
return documentsRes.string.Approver
}
}
</script>
<Scroller>
<div class="root">
<div class="flex-col list">
{#each signers as signer}
<div class="row flex-row-top px-4">
<div class="flex-col col">
<div class="fs-title text-normal version">
<Label label={getSignerLabel(signer.role)} />
</div>
<div class="date">
{signer.date}
</div>
</div>
<div class="flex-col">
<div class="name">
{signer.name}
</div>
<div class="code">
{signer.id}
</div>
</div>
</div>
{/each}
</div>
</div>
</Scroller>
<style lang="scss">
.list {
gap: 3rem;
}
.col {
flex: 0 0 6rem;
}
.row {
gap: 3rem;
@media print {
border-left: 2px solid var(--theme-divider-color);
}
}
.version {
line-height: 1.25rem;
}
.date {
font-size: 0.6875rem;
color: var(--theme-dark-color);
line-height: 1rem;
}
.name {
line-height: 1.25rem;
font-weight: 500;
}
.code {
font-size: 0.6875rem;
}
</style>
@@ -0,0 +1,10 @@
<div class="title font-semi-bold pt-6 pb-6">
<slot />
</div>
<style lang="scss">
.title {
font-size: 2.25rem;
color: var(--theme-caption-color);
}
</style>
@@ -0,0 +1,35 @@
<!--
//
// Copyright @ 2022 Hardcore Engineering Inc
//
-->
<script lang="ts">
import { Document } from '@hcengineering/controlled-documents'
import { Class, DocumentQuery, Ref, Space, WithLookup } from '@hcengineering/core'
import { Component } from '@hcengineering/ui'
import { Viewlet, ViewletPreference, ViewOptions } from '@hcengineering/view'
export let _class: Ref<Class<Document>>
export let viewlet: WithLookup<Viewlet>
export let viewOptions: ViewOptions
export let query: DocumentQuery<Document> = {}
export let space: Ref<Space> | undefined
export let preference: ViewletPreference | undefined = undefined
</script>
{#if viewlet?.$lookup?.descriptor?.component}
<Component
is={viewlet.$lookup.descriptor.component}
props={{
_class,
config: preference?.config || viewlet.config,
options: viewlet.options,
viewlet,
viewOptions,
viewOptionsConfig: viewlet.viewOptions?.other,
space,
query,
enableChecking: true
}}
/>
{/if}
@@ -0,0 +1,274 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { Ref } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Heading, TableOfContents, TableOfContentsContent } from '@hcengineering/text-editor'
import { EditBox, IconCircles, Scroller } from '@hcengineering/ui'
import { showMenu } from '@hcengineering/view-resources'
import { DocumentSection, calcRank } from '@hcengineering/controlled-documents'
import { onDestroy, tick } from 'svelte'
import { flip } from 'svelte/animate'
import {
$controlledDocument as controlledDocument,
documentCommentsLocationNavigateRequested,
documentSectionCollapsed,
documentSectionExpanded,
$isEditable as isEditable,
$controlledDocumentSectionIds as sectionIds,
$controlledDocumentSections as sections
} from '../../stores/editors/document'
import DocSectionEditor from './DocSectionEditor.svelte'
import DocumentTitle from './DocumentTitle.svelte'
import DocumentPrintTitlePage from '../print/DocumentPrintTitlePage.svelte'
const client = getClient()
const sectionRefs: Record<string, HTMLElement> = {}
let divScroll: HTMLElement
const editors: DocSectionEditor[] = []
const sectionHeadings: Record<Ref<DocumentSection>, Heading[]> = {}
let draggingId: Ref<DocumentSection> | null = null
let dragOverId: Ref<DocumentSection> | null = null
let dragging = false
let title = $controlledDocument?.title ?? ''
$: headings = $sectionIds.map((sectionId) => sectionHeadings[sectionId] ?? []).flat()
const unsubscribeNavigateToLocation = documentCommentsLocationNavigateRequested.subscribe({
next: ({ sectionKey }) => {
const element = sectionRefs[sectionKey]
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
}
})
const handleUpdateTitle = () => {
if (!$controlledDocument || !title) {
return
}
const titleTrimmed = title.trim()
if (titleTrimmed.length > 0 && titleTrimmed !== $controlledDocument.title) {
client.update($controlledDocument, { title: titleTrimmed })
}
}
function resetDrag (ev?: DragEvent): void {
draggingId = null
dragOverId = null
dragging = false
}
function handleDragStart (ev: DragEvent, id: Ref<DocumentSection>): void {
if (ev.dataTransfer) {
dragging = true
ev.dataTransfer.effectAllowed = 'move'
ev.dataTransfer.dropEffect = 'move'
const index = $sectionIds.indexOf(id)
ev.dataTransfer.setDragImage(editors[index].getSectionElement(), 0, 0)
draggingId = id
}
}
async function handleDrop (ev: DragEvent, toId: Ref<DocumentSection>): Promise<void> {
if (ev.dataTransfer && draggingId !== null && toId !== draggingId) {
ev.dataTransfer.dropEffect = 'move'
const draggingIndex = $sectionIds.indexOf(draggingId)
const toIndex = $sectionIds.indexOf(toId)
const [prev, next] = [
$sections[draggingIndex < toIndex ? toIndex : toIndex - 1],
$sections[draggingIndex < toIndex ? toIndex + 1 : toIndex]
]
const section = $sections[draggingIndex]
// workaround to not display editor toolbar
documentSectionCollapsed(draggingId)
documentSectionCollapsed(toId)
await client.update(section, { rank: calcRank(prev, next) })
}
resetDrag()
}
function openSectionMenu (ev: MouseEvent, section: DocumentSection): void {
if (!$sections || $sections.length === 0) {
return
}
showMenu(ev, { object: section })
}
async function handleShowHeading (heading: Heading): Promise<void> {
const sectionId = $sectionIds.find((sectionId) => sectionHeadings[sectionId]?.includes(heading))
if (sectionId) {
documentSectionExpanded(sectionId)
}
await tick()
const element = window.document.getElementById(heading.id)
element?.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
onDestroy(unsubscribeNavigateToLocation)
</script>
{#if $controlledDocument}
<DocumentPrintTitlePage />
<div class="root flex-col relative">
<div class="toc">
<TableOfContents items={headings} on:select={(ev) => handleShowHeading(ev.detail)} />
</div>
{#if headings.length > 0}
<div class="only-print ml-12">
<TableOfContentsContent items={headings} />
</div>
<div class="pagebreak" />
{/if}
<Scroller bind:divScroll>
<div class="antiAccordion">
<div class="doc-title">
<DocumentTitle>
{#if $isEditable}
<EditBox
value={title}
on:value={(event) => {
title = event.detail
}}
on:blur={handleUpdateTitle}
/>
{:else}
{$controlledDocument.title}
{/if}
</DocumentTitle>
</div>
{#if $sections}
{#each $sections as section, i (section._id)}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="row"
bind:this={sectionRefs[section.key]}
class:is-dragging={section._id === draggingId}
class:drag-over-highlight={section._id === dragOverId}
on:dragstart={(ev) => {
handleDragStart(ev, section._id)
}}
on:dragleave|preventDefault={() => {
if (dragOverId === section._id) dragOverId = null
return false
}}
on:dragover|preventDefault={() => {
dragOverId = section._id
return false
}}
on:dragend={resetDrag}
on:drop|preventDefault={(ev) => handleDrop(ev, section._id)}
animate:flip={{ duration: 400 }}
>
<DocSectionEditor
bind:headings={sectionHeadings[section._id]}
bind:this={editors[i]}
value={section}
document={$controlledDocument}
index={i}
{dragging}
on:change={resetDrag}
>
<div class="m0 flex-row-center draggable-container" slot="before-header">
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="draggable-mark"
class:dragging
on:click={(ev) => {
openSectionMenu(ev, section)
}}
>
<IconCircles size="small" />
</div>
</div>
</DocSectionEditor>
</div>
{/each}
{/if}
</div>
</Scroller>
</div>
{/if}
<style lang="scss">
.root {
@media print {
margin-left: -1rem;
}
}
.toc {
position: absolute;
width: 1rem;
pointer-events: all;
left: 1px;
top: 1rem;
z-index: 1;
}
.doc-title {
padding-left: 3.25rem;
}
.row {
position: relative;
margin-left: 0;
.draggable-container {
width: 100%;
height: 100%;
.draggable-mark {
padding: 0.375rem 0.125rem;
&:hover {
background-color: var(--theme-button-hovered);
border-radius: 0.375rem;
cursor: pointer;
}
&.dragging {
cursor: grabbing;
position: relative;
align-self: baseline;
}
}
}
&:hover {
.draggable-mark {
opacity: 0.9;
}
}
}
.drag-over-highlight {
opacity: 0.2;
}
</style>
@@ -0,0 +1,173 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { type Ref } from '@hcengineering/core'
import { Label, Scroller, PlainTextEditor } from '@hcengineering/ui'
import { createQuery, getClient } from '@hcengineering/presentation'
import documents, { type DocumentSpace, type ChangeControl, DocumentState } from '@hcengineering/controlled-documents'
import documentsRes from '../../plugin'
import { $controlledDocument as controlledDocument, $isEditable as isEditable } from '../../stores/editors/document'
import DocumentBoxItems from '../DocumentBoxItems.svelte'
const client = getClient()
let changeControl: ChangeControl
const ccQuery = createQuery()
$: if ($controlledDocument != null) {
ccQuery.query(documents.class.ChangeControl, { _id: $controlledDocument.changeControl }, (res) => {
;[changeControl] = res
})
} else {
ccQuery.unsubscribe()
}
const spacesQuery = createQuery()
let docSpaces: Ref<DocumentSpace>[] = []
spacesQuery.query(documents.class.DocumentSpace, {}, (res) => {
docSpaces = res.map((s) => s._id)
})
async function handleFieldUpdated (field: keyof ChangeControl, ev: UIEvent): Promise<void> {
if (ev == null) {
return
}
const target = ev.target as HTMLInputElement
if (target == null) {
return
}
await updateCCField(field, target.value)
}
async function updateCCField<T extends keyof ChangeControl> (field: T, value: ChangeControl[T]): Promise<void> {
if ($controlledDocument == null) {
return
}
await client.updateDoc(
documents.class.ChangeControl,
$controlledDocument.space,
$controlledDocument.changeControl,
{ [field]: value }
)
}
</script>
{#if $controlledDocument !== undefined && changeControl}
<Scroller>
<div class="root">
<div class="block">
<div class="title">
<Label label={documents.string.Description} />
</div>
{#if $isEditable}
<PlainTextEditor
value={changeControl.description}
placeholder={documentsRes.string.DescribeChanges}
disabled={!$isEditable}
on:blur={(ev) => {
void handleFieldUpdated('description', ev)
}}
/>
{:else}
{changeControl.description ?? '—'}
{/if}
</div>
<div class="block">
<div class="title">
<Label label={documents.string.Reason} />
</div>
{#if $isEditable}
<PlainTextEditor
value={changeControl.reason}
placeholder={documentsRes.string.DescribeReason}
disabled={!$isEditable}
on:blur={(ev) => {
void handleFieldUpdated('reason', ev)
}}
/>
{:else}
{changeControl.reason ?? '—'}
{/if}
</div>
<div class="block">
<div class="title">
<Label label={documents.string.ImpactAnalysis} />
</div>
{#if $isEditable}
<PlainTextEditor
value={changeControl.impact}
placeholder={documentsRes.string.DescribeImpact}
disabled={!$isEditable}
on:blur={(ev) => {
void handleFieldUpdated('impact', ev)
}}
/>
{:else}
{changeControl.impact ?? '—'}
{/if}
</div>
<div class="block">
<div class="title">
<Label label={documents.string.ImpactedDocuments} />
</div>
{#if $isEditable || changeControl.impactedDocuments.length > 0}
<DocumentBoxItems
_class={documents.class.ControlledDocument}
items={changeControl.impactedDocuments}
label={documents.string.ImpactedDocuments}
readonly={!$isEditable}
docQuery={{
space: { $in: docSpaces },
state: DocumentState.Effective,
attachedTo: { $ne: $controlledDocument?.attachedTo }
}}
on:update={({ detail }) => updateCCField('impactedDocuments', detail)}
/>
{:else}
<Label label={documentsRes.string.NoDocuments} />
{/if}
</div>
</div>
</Scroller>
{/if}
<style lang="scss">
.root {
padding: 1.5rem 3.25rem;
display: flex;
flex-direction: column;
gap: 3rem;
}
.block {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.title {
font-weight: 500;
font-size: var(--body-font-size);
color: var(--theme-caption-color);
user-select: none;
}
</style>
@@ -0,0 +1,428 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { DateRangeMode, type MixinUpdate, Timestamp } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import {
DatePresenter,
DropdownLabels,
Label,
DropdownTextItem,
RadioButton,
Scroller,
Toggle
} from '@hcengineering/ui'
import { getClient } from '@hcengineering/presentation'
import { UserBoxItems } from '@hcengineering/contact-resources'
import {
type Document,
ControlledDocumentState,
DEFAULT_PERIODIC_REVIEW_INTERVAL,
DocumentState,
type DocumentTraining,
periodicReviewIntervals,
ControlledDocument
} from '@hcengineering/controlled-documents'
import {
NullablePositiveNumberEditor,
TrainingRefEditor,
TrainingRequestRolesEditor
} from '@hcengineering/training-resources'
import { createDocumentTraining, getDocumentTrainingClass, updateDocumentTraining } from '../../docutils'
import documentsRes from '../../plugin'
import {
$isDocumentOwner as isDocumentOwner,
$controlledDocument as controlledDocument,
$documentState as documentState,
$documentAllVersionsDescSorted as documentAllVersionsDescSorted,
$documentTraining as documentTraining
} from '../../stores/editors/document/editor'
enum Severity {
Minor = 'minor',
Major = 'major'
}
const client = getClient()
async function changePlannedEffectiveDate (plannedEffectiveDate: Timestamp) {
if (!$controlledDocument) {
return
}
await client.update($controlledDocument, { plannedEffectiveDate })
}
let selectedDate: Timestamp =
$controlledDocument?.plannedEffectiveDate != null && $controlledDocument?.plannedEffectiveDate > 0
? $controlledDocument.plannedEffectiveDate
: Date.now()
let selected: IntlString | undefined = undefined
if ($controlledDocument?.plannedEffectiveDate === 0) {
selected = documentsRes.string.EffectiveImmediately
} else if ($controlledDocument?.plannedEffectiveDate != null) {
selected = documentsRes.string.EffectiveOn
}
async function changeSelectedDate (ev: CustomEvent) {
if (ev.detail !== undefined) {
selectedDate = ev.detail
await changePlannedEffectiveDate(ev.detail)
selected = documentsRes.string.EffectiveOn
}
}
const reviewIntervals: DropdownTextItem[] = []
for (const interval of periodicReviewIntervals) {
reviewIntervals.push({
id: interval.toString(),
label: interval.toString()
})
}
let selectedReviewInterval = $controlledDocument?.reviewInterval
? $controlledDocument.reviewInterval.toString()
: DEFAULT_PERIODIC_REVIEW_INTERVAL.toString()
async function changePeriodicReviewInterval (interval?: string) {
if ($controlledDocument == null || interval == null) {
return
}
const reviewInterval = Number(+interval)
if (!Number.isSafeInteger(reviewInterval)) {
return
}
await client.update($controlledDocument, { reviewInterval })
selectedReviewInterval = interval.toString()
}
$: canEdit =
$isDocumentOwner &&
$documentState != null &&
![
DocumentState.Archived,
DocumentState.Deleted,
DocumentState.Effective,
ControlledDocumentState.InApproval,
ControlledDocumentState.Approved,
ControlledDocumentState.ToReview
].includes($documentState)
const hierarchy = client.getHierarchy()
const documentTrainingClass = getDocumentTrainingClass(hierarchy)
async function toggleTraining (on: boolean): Promise<void> {
if (!$controlledDocument || !canEdit) {
return
}
if (on) {
if ($documentTraining === null) {
await createDocumentTraining(client, $controlledDocument, {
enabled: true,
training: null,
roles: [],
trainees: [],
maxAttempts: null,
dueDays: null
})
} else {
await updateTraining({ enabled: true })
}
} else {
await updateTraining({ enabled: false })
}
}
async function updateTraining (update: MixinUpdate<Document, DocumentTraining>): Promise<boolean> {
if (!$controlledDocument || !canEdit || !documentTraining) {
return false
}
await updateDocumentTraining(client, $controlledDocument, update)
return true
}
$: severity = getSeverity($controlledDocument, $documentAllVersionsDescSorted)
function getPreviousDocument (
document: ControlledDocument,
allVersionsDesc: ControlledDocument[]
): ControlledDocument | undefined {
return allVersionsDesc.find(
(d) => (d.major === document.major && d.minor < document.minor) || d.major < document.major
)
}
function getSeverity (document: ControlledDocument | null, allVersionsDesc: ControlledDocument[]): Severity {
if (document == null) {
return Severity.Minor
}
const prevDocument = getPreviousDocument(document, allVersionsDesc)
if (prevDocument == null) {
return document.major > 0 ? Severity.Major : Severity.Minor
} else {
return prevDocument.major < document.major ? Severity.Major : Severity.Minor
}
}
function getVersionForSeverity (severity: Severity): { major: number, minor: number } | undefined {
if ($controlledDocument == null) {
return
}
const prevDocument = getPreviousDocument($controlledDocument, $documentAllVersionsDescSorted)
if (severity === Severity.Major) {
return {
major: (prevDocument?.major ?? 0) + 1,
minor: 0
}
} else {
return {
major: prevDocument?.major ?? 0,
minor: (prevDocument?.minor ?? 0) + 1
}
}
}
async function handleSeverityChanged (newSeverity: Severity): Promise<void> {
if (!canEdit || $controlledDocument == null) {
return
}
const oldSeverity = getSeverity($controlledDocument, $documentAllVersionsDescSorted)
if (oldSeverity === newSeverity) {
return
}
const versionUpdate = getVersionForSeverity(newSeverity)
if (versionUpdate === undefined) {
return
}
await client.update($controlledDocument, versionUpdate)
}
</script>
<Scroller>
<div class="content">
<section class="section">
<header class="fs-title text-lg mb-4">
<Label label={documentsRes.string.ChangeSeverity} />
</header>
<div class="flex-col">
<RadioButton
group={severity}
id={Severity.Minor}
labelIntl={documentsRes.string.Minor}
labelGap="large"
value={Severity.Minor}
disabled={!canEdit}
action={() => {
void handleSeverityChanged(Severity.Minor)
}}
gap="large"
/>
<RadioButton
group={severity}
id={Severity.Major}
labelIntl={documentsRes.string.Major}
labelGap="large"
value={Severity.Major}
disabled={!canEdit}
action={() => {
void handleSeverityChanged(Severity.Major)
}}
gap="none"
/>
</div>
<header class="fs-title text-lg my-4">
<Label label={documentsRes.string.EffectiveDocumentLifecycle} />
</header>
<span class="fs-title text-normal">
<Label label={documentsRes.string.EffectiveDate} />
</span>
<div>
<div class="flex-col">
<RadioButton
bind:group={selected}
id={documentsRes.string.EffectiveImmediately}
labelIntl={documentsRes.string.EffectiveImmediately}
labelGap="large"
value={documentsRes.string.EffectiveImmediately}
disabled={!canEdit}
action={() => {
selected = documentsRes.string.EffectiveImmediately
changePlannedEffectiveDate(0)
}}
gap="large"
/>
<RadioButton
bind:group={selected}
id={documentsRes.string.EffectiveOn}
labelGap="large"
value={documentsRes.string.EffectiveOn}
disabled={!canEdit}
action={() => {
selected = documentsRes.string.EffectiveOn
changePlannedEffectiveDate(selectedDate)
}}
gap="none"
>
<div class="flex-row-center flex-gap-1">
<Label label={documentsRes.string.EffectiveOn} />
<DatePresenter
mode={DateRangeMode.DATETIME}
bind:value={selectedDate}
on:change={changeSelectedDate}
editable={canEdit}
/>
</div>
</RadioButton>
</div>
</div>
<div class="flex-row-center flex-gap-2">
<span class="whitespace-nowrap fs-title text-normal">
<Label label={documentsRes.string.PeriodicReviewToBeCompleted} />
</span>
<DropdownLabels
kind="regular"
selected={selectedReviewInterval}
placeholder={documentsRes.string.PeriodicReviewToBeCompleted}
items={reviewIntervals}
enableSearch={false}
multiselect={false}
allowDeselect={false}
on:selected={({ detail }) => changePeriodicReviewInterval(detail)}
disabled={!canEdit}
/>
<span>
<Label label={documentsRes.string.MonthsAfterEffectiveDate} />
</span>
</div>
</section>
<section class="section pb-16">
<header class="flex-row-center mb-4 flex-gap-4">
<span class="fs-title text-lg">
<Label label={documentTrainingClass.label} />
</span>
<Toggle
disabled={!canEdit}
on={$documentTraining?.enabled}
on:change={(event) => toggleTraining(event.detail)}
/>
</header>
{#if $documentTraining !== null && $documentTraining.enabled}
{@const trainingAttribute = hierarchy.getAttribute(documentTrainingClass._id, 'training')}
<span class="fs-title text-normal">
<Label label={trainingAttribute.label} />
</span>
<TrainingRefEditor
kind="regular"
width="min-content"
size="medium"
readonly={!canEdit}
value={$documentTraining.training}
onChange={(trainingRef) => {
void updateTraining({ training: trainingRef ?? null })
}}
/>
{@const rolesAttribute = hierarchy.getAttribute(documentTrainingClass._id, 'roles')}
<span class="fs-title text-normal">
<Label label={rolesAttribute.label} />
</span>
<TrainingRequestRolesEditor
kind="regular"
width="max-content"
value={$documentTraining.roles}
onChange={(roles) => {
void updateTraining({ roles })
}}
/>
{@const traineesAttribute = hierarchy.getAttribute(documentTrainingClass._id, 'trainees')}
<span class="fs-title text-normal">
<Label label={traineesAttribute.label} />
</span>
<UserBoxItems
items={$documentTraining.trainees}
label={traineesAttribute.label}
readonly={!canEdit}
size="card"
on:update={(event) => {
void updateTraining({ trainees: event.detail })
}}
/>
<div class="flex-row-center flex-gap-2">
<span class="whitespace-nowrap fs-title text-normal">
<Label label={documentsRes.string.ToBePassedWithin} />
</span>
<NullablePositiveNumberEditor
kind="regular"
width="min-content"
value={$documentTraining.maxAttempts}
readonly={!canEdit}
onChange={(maxAttempts) => {
void updateTraining({ maxAttempts })
}}
/>
<Label label={documentsRes.string.AttemptsAnd} />
<NullablePositiveNumberEditor
kind="regular"
width="min-content"
value={$documentTraining.dueDays}
readonly={!canEdit}
onChange={(dueDays) => {
void updateTraining({ dueDays })
}}
/>
<Label label={documentsRes.string.DaysAfterEffectiveDate} />
</div>
{/if}
</section>
</div>
</Scroller>
<style lang="scss">
.content {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
gap: 2rem;
min-width: 0;
min-height: 0;
padding: 1.5rem 3.25rem 4rem;
}
.section {
display: flex;
flex-direction: column;
flex-shrink: 0;
flex-wrap: nowrap;
gap: 1rem;
min-width: 0;
min-height: 0;
}
</style>
@@ -0,0 +1,69 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { Scroller } from '@hcengineering/ui'
import { Ref } from '@hcengineering/core'
import { ControlledDocument, ControlledDocumentState, DocumentState } from '@hcengineering/controlled-documents'
import { Employee } from '@hcengineering/contact'
import { getClient } from '@hcengineering/presentation'
import DocTeam from './DocTeam.svelte'
export let controlledDoc: ControlledDocument
export let editable: boolean = true
$: canChangeCoAuthors =
editable && controlledDoc.state === DocumentState.Draft && controlledDoc.controlledState == null
$: canChangeReviewers =
editable && controlledDoc.state === DocumentState.Draft && controlledDoc.controlledState == null
$: canChangeApprovers =
editable &&
((controlledDoc.state === DocumentState.Draft && controlledDoc.controlledState == null) ||
controlledDoc.controlledState === ControlledDocumentState.InReview ||
controlledDoc.controlledState === ControlledDocumentState.Reviewed)
const client = getClient()
async function handleUpdate ({
detail
}: {
detail: { type: 'reviewers' | 'approvers', users: Ref<Employee>[] }
}): Promise<void> {
const { type, users } = detail
await client.update(controlledDoc, { [type]: users })
}
</script>
{#if controlledDoc}
<Scroller>
<div class="content">
<DocTeam
space={controlledDoc.space}
{controlledDoc}
{canChangeCoAuthors}
{canChangeReviewers}
{canChangeApprovers}
on:update={handleUpdate}
/>
</div>
</Scroller>
{/if}
<style lang="scss">
.content {
padding: 1.5rem 3.25rem;
}
</style>
@@ -0,0 +1,72 @@
<script lang="ts">
// TODO: Refactor to use State component from 'StateTag'
import { DocumentStateTagType } from '../../../utils'
export let type: DocumentStateTagType
</script>
<div class="root {type}">
<span class="label"><slot /></span>
</div>
<style lang="scss">
.root {
padding: 0.125rem 0.5rem;
display: flex;
border-radius: 0.25rem;
border: 1px solid var(--theme-docs-contrast-color);
width: max-content;
&.inProgress {
background: var(--theme-state-primary-background-color);
border-color: var(--theme-state-primary-border-color);
.label {
color: var(--theme-state-primary-color);
}
}
&.draft {
background: var(--theme-state-regular-background-color);
border-color: var(--theme-state-regular-border-color);
.label {
color: var(--theme-state-regular-color);
}
}
&.obsolete {
background: var(--theme-state-ghost-background-color);
border-color: var(--theme-state-ghost-border-color);
.label {
color: var(--theme-state-ghost-color);
}
}
&.effective {
background: var(--theme-state-positive-background-color);
border-color: var(--theme-state-positive-border-color);
.label {
color: var(--theme-state-positive-color);
}
}
&.rejected {
background: var(--theme-state-negative-background-color);
border-color: var(--theme-state-negative-border-color);
.label {
color: var(--theme-state-negative-color);
}
}
}
.label {
font-size: 0.75rem;
font-weight: 500;
line-height: 1rem;
}
</style>
@@ -0,0 +1,58 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { Document } from '@hcengineering/controlled-documents'
import { getClient } from '@hcengineering/presentation'
import { EditBox } from '@hcengineering/ui'
import view from '@hcengineering/view'
import plugin from '../../../plugin'
export let value: Document | undefined
export let readonly = true
let abstract = value?.abstract
const client = getClient()
const handleUpdateAbstract = () => {
if (readonly) {
return
}
if (value === undefined || value === null) {
return
}
if (value.abstract === abstract) {
return
}
client.update(value, { abstract }, true)
}
</script>
{#if value}
<EditBox
value={abstract}
disabled={readonly}
placeholder={readonly ? view.string.LabelNA : plugin.string.AbstractPlaceholder}
fullSize
on:value={(event) => {
abstract = event.detail
}}
on:blur={handleUpdateAbstract}
/>
{/if}
@@ -0,0 +1,50 @@
<!--
// Copyright © 2024 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.
-->
<script lang="ts">
import { AttachmentsDocumentSection, DocumentSection } from '@hcengineering/controlled-documents'
import { Attachments } from '@hcengineering/attachment-resources'
import { Scroller } from '@hcengineering/ui'
import { createEventDispatcher, onMount } from 'svelte'
export let value: DocumentSection
export let readonly = false
export let showHeader = true
export let withScroll = true
const dispatch = createEventDispatcher()
onMount(() => {
dispatch('open', {})
})
$: attachmentSection = value as AttachmentsDocumentSection
$: attachmentsProps = {
objectId: value._id,
space: value.space,
_class: value._class,
attachments: attachmentSection.attachments ?? 0,
readonly,
showHeader
}
</script>
{#if withScroll}
<Scroller autoscroll>
<div class="mr-6">
<Attachments {...attachmentsProps} />
</div>
</Scroller>
{:else}
<Attachments {...attachmentsProps} />
{/if}
@@ -0,0 +1,300 @@
<!--
// 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.
-->
<script lang="ts">
import { merge } from 'effector'
import { createEventDispatcher, onDestroy, tick } from 'svelte'
import { CollaborativeDocumentSection } from '@hcengineering/controlled-documents'
import attachment, { Attachment } from '@hcengineering/attachment'
import chunter from '@hcengineering/chunter'
import { navigate } from '@hcengineering/ui'
import { CollaborativeDoc, Ref, generateId, Blob } from '@hcengineering/core'
import view from '@hcengineering/view'
import { getResource, setPlatformStatus, unknownError } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import {
CollaboratorEditor,
FocusExtension,
Heading,
HeadingsExtension,
IsEmptyContentExtension,
NodeHighlightExtension,
NodeHighlightType,
TextNodeAction,
highlightUpdateCommand
} from '@hcengineering/text-editor'
import { getCollaborationUser, getObjectLinkFragment } from '@hcengineering/view-resources'
import {
$documentCommentHighlightedLocation as highlighted,
$areDocumentCommentPopupsOpened as arePopupsOpened,
$canAddDocumentComments as canAddDocumentComments,
$canViewDocumentComments as canViewDocumentComments,
$controlledDocument as controlledDocument,
$controlledDocumentTemplate as controlledDocumentTemplate,
$groupedDocumentComments as groupedDocumentComments,
$isEditable as isEditable,
documentCommentsDisplayRequested,
documentCommentsHighlightUpdated,
documentCommentsLocationNavigateRequested,
showAddCommentPopupFx
} from '../../../stores/editors/document'
export let value: CollaborativeDocumentSection
export let onHeadings: (headings: Heading[]) => void
const client = getClient()
const user = getCollaborationUser()
let selectedNodeId: string | null | undefined = undefined
let textEditor: CollaboratorEditor
let isFocused = false
let isEmpty = true
const handleRefreshHighlight = () => {
if (!textEditor) {
return
}
textEditor.commands()?.command(highlightUpdateCommand())
}
const unsubscribeHighlightRefresh = merge([highlighted, groupedDocumentComments.updates]).subscribe({
next: () => {
handleRefreshHighlight()
}
})
const unsubscribeNavigateToLocation = documentCommentsLocationNavigateRequested.subscribe({
// eslint-disable-next-line @typescript-eslint/no-misused-promises
next: async ({ nodeId, sectionKey }) => {
if (sectionKey !== value.key || !nodeId) {
handleRefreshHighlight()
return
}
if (!textEditor) {
return
}
await tick()
const element = textEditor.getNodeElement(nodeId)
if (element) {
element.scrollIntoView({ behavior: 'smooth' })
}
}
})
const handleGetNodeId = () => {
if (isEmpty || !textEditor) {
return null
}
if (!selectedNodeId) {
const nodeId = generateId()
if (textEditor.setNodeUuid(nodeId)) {
selectedNodeId = nodeId
}
}
return selectedNodeId
}
const handleNodeHighlight = (id: string) => {
if ($highlighted) {
const { sectionKey, nodeId } = $highlighted
if (nodeId === id && sectionKey === value.key) {
return { type: NodeHighlightType.WARNING, isActive: true }
}
}
if ($groupedDocumentComments.hasDocumentComments(value.key, id)) {
return { type: NodeHighlightType.WARNING }
}
return null
}
const handleCommentAction = (): void => {
if (isEmpty) {
return
}
if (!$canAddDocumentComments) {
return
}
if (!selectedNodeId) {
selectedNodeId = handleGetNodeId()
}
if (selectedNodeId) {
showAddCommentPopupFx({
element: textEditor.getNodeElement(selectedNodeId),
nodeId: selectedNodeId,
sectionKey: value.key
})
textEditor.selectNode(selectedNodeId)
}
}
const handleShowDocumentComments = (uuid: string) => {
if (!uuid) {
return
}
if (!$groupedDocumentComments.hasDocumentComments(value.key, uuid)) {
return
}
documentCommentsDisplayRequested({
element: textEditor.getNodeElement(uuid),
nodeId: uuid,
sectionKey: value.key
})
}
const handleExtensions = () => [
FocusExtension.configure({
onFocus (focused) {
isFocused = focused
}
}),
IsEmptyContentExtension.configure({
onChange (empty) {
isEmpty = empty
}
}),
HeadingsExtension.configure({
prefixId: `section-${value._id}`,
onChange: onHeadings
}),
NodeHighlightExtension.configure({
isHighlightModeOn: () => $canViewDocumentComments || $canAddDocumentComments,
getNodeHighlight: handleNodeHighlight,
onNodeSelected: (uuid: string | null) => {
if (selectedNodeId !== uuid) {
selectedNodeId = uuid
}
if (isFocused) {
documentCommentsHighlightUpdated(
selectedNodeId && $groupedDocumentComments.hasDocumentComments(value.key, uuid)
? { sectionKey: value.key, nodeId: selectedNodeId }
: null
)
}
},
onNodeClicked: (uuid: string) => {
if (selectedNodeId !== uuid) {
selectedNodeId = uuid
}
if (!$arePopupsOpened && $canViewDocumentComments && selectedNodeId) {
handleShowDocumentComments(selectedNodeId)
}
}
})
]
const dispatch = createEventDispatcher()
async function createEmbedding (
file: File,
section: CollaborativeDocumentSection
): Promise<{ file: Ref<Blob>, type: string } | undefined> {
if ($controlledDocument === undefined || $controlledDocument === null) {
return undefined
}
try {
const uploadFile = await getResource(attachment.helper.UploadFile)
const uuid = await uploadFile(file)
const attachmentId: Ref<Attachment> = generateId()
await client.addCollection(
attachment.class.Attachment,
section.space,
section._id,
section._class,
'attachments',
{
file: uuid,
name: file.name,
type: file.type,
size: file.size,
lastModified: file.lastModified
},
attachmentId
)
return { file: uuid, type: file.type }
} catch (err: any) {
await setPlatformStatus(unknownError(err))
} finally {
dispatch('change')
}
}
const commentAction: TextNodeAction = {
id: '#comment',
icon: chunter.icon.Chunter,
label: chunter.string.Message,
action: handleCommentAction
}
onDestroy(() => {
unsubscribeHighlightRefresh()
unsubscribeNavigateToLocation()
})
let collaborativeDoc: CollaborativeDoc | undefined
$: if ($controlledDocument !== null) {
collaborativeDoc = $controlledDocument.content
}
let initialCollaborativeDoc: CollaborativeDoc | undefined
$: if ($controlledDocumentTemplate !== null) {
initialCollaborativeDoc = $controlledDocumentTemplate.content
}
</script>
{#if collaborativeDoc}
{#key value._id}
<CollaboratorEditor
bind:this={textEditor}
{collaborativeDoc}
{initialCollaborativeDoc}
{user}
readonly={!$isEditable}
field={value.collaboratorSectionId}
textNodeActions={$canAddDocumentComments && !isEmpty ? [commentAction] : []}
editorAttributes={{ style: 'padding: 0 2em; margin: 0 -2em;' }}
overflow="none"
canShowPopups={!$arePopupsOpened}
onExtensions={handleExtensions}
on:open-document={async (event) => {
const doc = await client.findOne(event.detail._class, { _id: event.detail._id })
if (doc != null) {
const location = await getObjectLinkFragment(client.getHierarchy(), doc, {}, view.component.EditDoc)
navigate(location)
}
}}
attachFile={async (file) => {
return await createEmbedding(file, value)
}}
/>
{/key}
{/if}
@@ -0,0 +1,87 @@
<!--
// Copyright © 2020 Anticrm Platform Contributors.
//
// 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.
-->
<script lang="ts">
import { afterUpdate } from 'svelte'
import type { IntlString } from '@hcengineering/platform'
import { translate } from '@hcengineering/platform'
import documentsRes from '../../../plugin'
export let width: string | undefined = undefined
export let height: string | undefined = undefined
export let value: string | undefined = undefined
export let placeholder: IntlString = documentsRes.string.EditDescription
export let placeholderParam: any | undefined = undefined
export let disabled: boolean = false
let input: HTMLElement
let phTraslate: string = ''
$: translate(placeholder, placeholderParam ?? {}).then((res) => {
phTraslate = res
})
afterUpdate(() => {
if (value !== '') {
input.style.height = 'auto'
input.style.height = `${input.scrollHeight + 2}px`
}
})
$: if (!disabled && input) {
input.focus()
}
</script>
<div class="textarea" style:width style:height>
<textarea
maxlength="240"
rows="1"
bind:value
bind:this={input}
placeholder={phTraslate}
{disabled}
on:keydown
on:change
on:keydown
on:keypress
on:blur
/>
</div>
<style lang="scss">
.textarea {
display: flex;
flex-direction: column;
margin-top: 0.25rem;
textarea {
padding: 0.62rem 1rem;
font-family: inherit;
font-size: inherit;
outline: none;
resize: none;
overflow: hidden;
border: 1px solid var(--theme-docs-description-border-color);
border-radius: 0.375rem;
&:disabled {
padding: 0.25rem 0.5rem 0.38rem 0.5rem;
border: 1px solid transparent;
font-family: inherit;
font-size: inherit;
background-color: var(--theme-docs-frozen-description-color);
}
}
}
</style>
@@ -0,0 +1,70 @@
<!--
// Copyright © 2020 Anticrm Platform Contributors.
//
// 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.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { DocumentSection } from '@hcengineering/controlled-documents'
import { ModernDialog, IconClose } from '@hcengineering/ui'
import { StyledTextArea } from '@hcengineering/text-editor'
import { MessageViewer, getClient } from '@hcengineering/presentation'
import documentsRes from '../../../plugin'
import GuidanceEditing from '../../icons/GuidanceEditing.svelte'
import { type GuidanceEditorMode } from '../../../utils'
export let section: DocumentSection
export let index: number
export let width: string | undefined = undefined
export let mode: GuidanceEditorMode = 'readonly'
const dispatch = createEventDispatcher()
function handleCloseClick (): void {
const reopenMode: GuidanceEditorMode | undefined = mode === 'canEdit' ? 'editing' : undefined
close(reopenMode)
}
function close (reopenMode?: GuidanceEditorMode): void {
dispatch('close', { reopenMode, guidance: reopenMode == null ? guidance : undefined })
}
export function onOutsideClick (): void {
close()
}
const client = getClient()
const h = client.getHierarchy()
const title = `${index}. ${section.title}`
let guidance = h.as(section, documentsRes.mixin.DocumentTemplateSection).guidance
let textArea: StyledTextArea
</script>
<ModernDialog
label={getEmbeddedLabel(title)}
closeIcon={mode !== 'canEdit' ? IconClose : GuidanceEditing}
withoutFooter
on:close={handleCloseClick}
{width}
shadow={mode !== 'editing'}
>
{#if mode === 'editing'}
<StyledTextArea bind:this={textArea} isScrollable={false} bind:content={guidance} showButtons={false} />
{:else}
<!-- TODO: use StyledTextArea.setEditable when it's fixed -->
<MessageViewer message={guidance ?? ''} />
{/if}
</ModernDialog>
@@ -0,0 +1,39 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import chunter, { type ChatMessage } from '@hcengineering/chunter'
import { Ref, generateId } from '@hcengineering/core'
import { ReferenceInput } from '@hcengineering/text-editor'
import {
addDocumentCommentFx,
$controlledDocumentSections as controlledDocumentSections
} from '../../../stores/editors/document'
export let sectionKey: string
export let nodeId: string | undefined
const dispatch = createEventDispatcher()
let messageId: Ref<ChatMessage> = generateId()
$: section = $controlledDocumentSections.find((section) => section.key === sectionKey)
async function handleMessage (event: CustomEvent<string>): Promise<void> {
const comment = await addDocumentCommentFx({ content: event.detail, section, messageId, nodeId })
messageId = generateId()
dispatch('close', comment)
}
</script>
{#if section}
<div class="text-editor-popup w-85">
<ReferenceInput
focusable
kindSend="primary"
placeholder={chunter.string.AddCommentPlaceholder}
on:message={handleMessage}
/>
</div>
{/if}
@@ -0,0 +1,113 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import documents, { Document } from '@hcengineering/controlled-documents'
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
import { Button, EditBox, Label } from '@hcengineering/ui'
import IconWarning from '../../icons/IconWarning.svelte'
import documentsRes from '../../../plugin'
export let object: Document
const client = getClient()
const dispatch = createEventDispatcher()
let code = object.code
const docCodesQuery = createQuery()
let docCodes: Record<string, string> = {}
docCodesQuery.query(
documents.class.Document,
{},
(res) => {
docCodes = {}
for (const doc of res) {
if (doc._id === object._id || doc.code === '' || doc.code === undefined) {
continue
}
docCodes[doc.code] = doc.title
}
},
{
projection: { code: 1, title: 1 }
}
)
$: isUnique = code != null && docCodes[code] === undefined
$: isFilled = code != null && code !== ''
$: isSame = object.code === code
$: canSubmit = isFilled && isUnique && !isSame
async function handleSubmit (): Promise<void> {
if (!canSubmit) {
return
}
await client.update(object, { code })
dispatch('close')
}
</script>
{#if object}
<div class="text-editor-popup min-w-112">
<div class="p-6 bottom-divider">
<div class="text-base font-medium primary-text-color pb-2">
<Label label={documentsRes.string.ChangeCode} />
</div>
<div class="flex-column flex-gap-8 pt-6">
<EditBox
autoFocus
placeholder={documentsRes.string.DocumentCodePlaceholder}
bind:value={code}
kind="large-style"
/>
{#if !isUnique}
<div class="error">
<IconWarning size="small" />
<Label label={documentsRes.string.CodeInUse} />
<span class="name">{docCodes[code]}</span>
</div>
{/if}
</div>
</div>
<div class="flex justify-end items-center flex-gap-2 pr-6 pl-6 pt-4 pb-4">
<Button kind="regular" label={presentation.string.Cancel} on:click={() => dispatch('close')} />
<Button kind="primary" disabled={!canSubmit} label={presentation.string.Change} on:click={handleSubmit} />
</div>
</div>
{/if}
<style lang="scss">
.primary-text-color {
color: var(--theme-text-primary-color);
}
.error {
display: flex;
gap: 0.25rem;
margin-top: 0.375rem;
color: var(--negative-button-default);
font-size: 0.75rem;
line-height: 1rem;
}
.name {
font-weight: 500;
}
</style>
@@ -0,0 +1,123 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { translate } from '@hcengineering/platform'
import documents, { DocumentTemplate, TEMPLATE_PREFIX } from '@hcengineering/controlled-documents'
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
import { Button, EditBox, Label } from '@hcengineering/ui'
import IconWarning from '../../icons/IconWarning.svelte'
import documentsRes from '../../../plugin'
export let object: DocumentTemplate
const dispatch = createEventDispatcher()
const client = getClient()
let prefix = object.docPrefix
let templatesCodeTitle = ''
translate(documents.string.SysTemplate, {}).then(
(res) => {
templatesCodeTitle = res
templateDocPrefixes[TEMPLATE_PREFIX] = templatesCodeTitle
},
(err) => {
console.warn(`Cannot load translation for: ${documents.string.SysTemplate}. Error: ${err})`)
}
)
const templateDocPrefixesQuery = createQuery()
let templateDocPrefixes: Record<string, string> = {}
templateDocPrefixesQuery.query(
documents.mixin.DocumentTemplate,
{ _id: { $ne: object._id } },
(res) => {
templateDocPrefixes = { [TEMPLATE_PREFIX]: templatesCodeTitle }
for (const t of res) {
templateDocPrefixes[t.docPrefix] = t.title
}
},
{
projection: { docPrefix: 1, title: 1 }
}
)
$: isUnique = prefix != null && templateDocPrefixes[prefix] === undefined
$: isFilled = prefix != null && prefix !== ''
$: isSame = object.docPrefix === prefix
$: canSubmit = isFilled && isUnique && !isSame
async function handleSubmit (): Promise<void> {
if (!canSubmit) {
return
}
await client.updateMixin(object._id, documents.class.Document, object.space, documents.mixin.DocumentTemplate, {
docPrefix: prefix
})
dispatch('close')
}
</script>
{#if object}
<div class="text-editor-popup min-w-112">
<div class="p-6 bottom-divider">
<div class="text-base font-medium primary-text-color pb-2">
<Label label={documentsRes.string.ChangePrefix} />
</div>
<div class="flex-column flex-gap-8 pt-6">
<EditBox
autoFocus
placeholder={documentsRes.string.DocumentPrefixPlaceholder}
bind:value={prefix}
kind="large-style"
/>
{#if !isUnique}
<div class="error">
<IconWarning size="small" />
<Label label={documentsRes.string.CodeInUse} />
<span class="name">{templateDocPrefixes[prefix]}</span>
</div>
{/if}
</div>
</div>
<div class="flex justify-end items-center flex-gap-2 pr-6 pl-6 pt-4 pb-4">
<Button kind="regular" label={presentation.string.Cancel} on:click={() => dispatch('close')} />
<Button kind="primary" disabled={!canSubmit} label={presentation.string.Change} on:click={handleSubmit} />
</div>
</div>
{/if}
<style lang="scss">
.primary-text-color {
color: var(--theme-text-primary-color);
}
.error {
display: flex;
gap: 0.25rem;
margin-top: 0.375rem;
color: var(--negative-button-default);
font-size: 0.75rem;
line-height: 1rem;
}
.name {
font-weight: 500;
}
</style>
@@ -0,0 +1,137 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import documents, { Document } from '@hcengineering/controlled-documents'
import { Employee, PersonAccount } from '@hcengineering/contact'
import { EmployeeBox, EmployeePresenter, personAccountByIdStore } from '@hcengineering/contact-resources'
import core, { Ref, Space } from '@hcengineering/core'
import presentation, { createQuery, getClient } from '@hcengineering/presentation'
import { Button, Icon, Label } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
import { canChangeDocumentOwner, isDocOwner } from '../../../utils'
import Info from '../../icons/Info.svelte'
import DocumentVersionPresenter from '../presenters/DocumentVersionPresenter.svelte'
import StatePresenter from '../presenters/StatePresenter.svelte'
export let object: Document
let space: Space | undefined
let owner: Ref<Employee> | undefined = undefined
let canChange = false
const client = getClient()
const query = createQuery()
const dispatch = createEventDispatcher()
const handleChangeOwner = async () => {
if (!canChange || !owner || !object || owner === object.owner) {
return
}
await client.update(object, { owner })
dispatch('close')
}
$: query.query(core.class.Space, { _id: object.space }, (res) => {
;[space] = res
})
$: if (object) {
void canChangeDocumentOwner(object).then((value) => {
canChange = value
})
}
$: isOwner = isDocOwner(object)
$: members = space?.members ?? []
$: employees = members
.map((m) => $personAccountByIdStore.get(m as Ref<PersonAccount>)?.person as Ref<Employee>)
.filter((p) => p !== undefined)
$: docQuery = space?.private ?? false ? { active: true, _id: { $in: employees } } : { active: true }
</script>
{#if object}
<div class="text-editor-popup min-w-112">
<div class="p-6 bottom-divider">
<div class="text-base font-medium primary-text-color pb-2">
<Label label={documents.string.ChangeOwner} />
</div>
<div class="flex flex-gap-1 hint text-sm">
<Label label={documents.string.ChangeOwnerHintBeginning} />
<div class="flex flex-gap-1 primary-text-color fs-bold">
<span>{object.title}</span>
<div><DocumentVersionPresenter value={object} /></div>
<div>[<StatePresenter value={object} showTag={false} />]</div>
</div>
<Label label={documents.string.ChangeOwnerHintEnd} />
</div>
<div class="flex flex-gap-4 items-center pt-6">
{#if !isOwner}
<div class="fs-bold primary-text-color">
<EmployeePresenter value={object.owner} avatarSize="card" noUnderline disabled colorInherit />
</div>
<Icon icon={view.icon.ArrowRight} size="medium" fill="var(--theme-progress-color)" />
{/if}
<EmployeeBox
bind:value={owner}
{docQuery}
label={documents.string.SelectOwner}
readonly={!canChange}
showNavigate={false}
allowDeselect={false}
/>
</div>
</div>
<div class="flex items-center flex-between pr-6 pl-6 pt-4 pb-4">
<div class="flex flex-gap-2 items-center max-w-60 p-1 text-xs pr-4">
{#if isOwner}
<div class="warning-sign">
<Info size="small" />
</div>
<Label label={documents.string.ChangeOwnerWarning} />
{/if}
</div>
<div class="flex justify-end items-center flex-gap-2">
<Button kind="regular" label={presentation.string.Cancel} on:click={() => dispatch('close')} />
<Button
kind={!isOwner ? 'primary' : 'dangerous'}
disabled={!canChange || !owner || owner === object.owner}
label={presentation.string.Change}
on:click={handleChangeOwner}
/>
</div>
</div>
</div>
{/if}
<style lang="scss">
.hint {
color: var(--theme-dark-color);
}
.warning-sign {
color: var(--theme-docs-warning-icon-color);
}
.primary-text-color {
color: var(--theme-text-primary-color);
}
</style>
@@ -0,0 +1,68 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { DropdownIntlItem, DropdownLabelsIntl, Label, Toggle } from '@hcengineering/ui'
import documents, { type DocumentComment } from '@hcengineering/controlled-documents'
import {
$documentCommentsFilter as documentCommentsFilter,
documentCommentsShowResolvedToggled,
documentCommentsSortByChanged,
documentCommentsSortingAttributes
} from '../../../stores/editors/document'
import { getClient } from '@hcengineering/presentation'
const hierarchy = getClient().getHierarchy()
const sortingOptions: DropdownIntlItem[] = documentCommentsSortingAttributes.map((attr) => {
const attribute = hierarchy.getAttribute(documents.class.DocumentComment, attr)
return {
id: attr,
label: attribute.label
}
})
const handleShowResolvedToggled = (): void => {
documentCommentsShowResolvedToggled()
}
const handleSortingChanged = (event: CustomEvent<keyof DocumentComment>) => {
documentCommentsSortByChanged(event.detail)
}
</script>
<div class="antiCard dialog menu">
<div class="antiCard-menu__spacer" />
<div
class="antiCard-menu__item hoverable ordering"
on:click={handleShowResolvedToggled}
on:keydown={handleShowResolvedToggled}
>
<span class="overflow-label"><Label label={documents.string.ShowResolved} /></span>
<Toggle on={$documentCommentsFilter.showResolved} on:change={handleShowResolvedToggled} />
</div>
<div class="antiCard-menu__item ordering">
<span class="overflow-label"><Label label={documents.string.Ordering} /></span>
<DropdownLabelsIntl
kind={'regular'}
size={'medium'}
items={sortingOptions}
selected={$documentCommentsFilter.sortBy}
justify="left"
on:selected={handleSortingChanged}
/>
</div>
<div class="antiCard-menu__spacer" />
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import { $groupedDocumentComments as groupedDocumentComments } from '../../../stores/editors/document'
import DocumentCommentThread from '../right-panel/DocumentCommentThread.svelte'
export let sectionKey: string
export let nodeId: string | undefined
$: documentComments = $groupedDocumentComments.getDocumentComments(sectionKey, nodeId)
</script>
<div class="text-editor-popup max-h-80 min-w-100 overflow-y-auto">
{#each documentComments as object}
<DocumentCommentThread value={object} />
{/each}
</div>
@@ -0,0 +1,36 @@
<!--
// Copyright © 2024 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.
-->
<script lang="ts">
import { AttachmentsDocumentSection } from '@hcengineering/controlled-documents'
import { WithLookup } from '@hcengineering/core'
import { getPanelURI, Icon } from '@hcengineering/ui'
import documents from '../../../plugin'
export let value: WithLookup<AttachmentsDocumentSection> | undefined
export let inline = false
</script>
{#if value}
<a
class="flex-presenter"
href="#{getPanelURI(documents.component.EditDoc, value.attachedTo, value.attachedToClass, 'content')}"
class:inline-presenter={inline}
>
<div class="icon">
<Icon icon={documents.icon.Document} size={'small'} />
</div>
<span class="label nowrap">{value.title}</span>
</a>
{/if}
@@ -0,0 +1,25 @@
<script lang="ts">
import documents, { DocumentCategory } from '@hcengineering/controlled-documents'
import { Ref } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Label } from '@hcengineering/ui'
import view from '@hcengineering/view'
export let value: Ref<DocumentCategory> | undefined
let category: DocumentCategory | undefined = undefined
const client = getClient()
$: if (value) {
client.findOne(documents.class.DocumentCategory, { _id: value }).then((result) => {
category = result
})
}
</script>
{#if category}
{category.title}
{:else}
<Label label={view.string.LabelNA} />
{/if}
@@ -0,0 +1,36 @@
<!--
// Copyright © 2024 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.
-->
<script lang="ts">
import { CollaborativeDocumentSection } from '@hcengineering/controlled-documents'
import { WithLookup } from '@hcengineering/core'
import { getPanelURI, Icon } from '@hcengineering/ui'
import documents from '../../../plugin'
export let value: WithLookup<CollaborativeDocumentSection> | undefined
export let inline = false
</script>
{#if value}
<a
class="flex-presenter"
href="#{getPanelURI(documents.component.EditDoc, value.attachedTo, value.attachedToClass, 'content')}"
class:inline-presenter={inline}
>
<div class="icon">
<Icon icon={documents.icon.Document} size={'small'} />
</div>
<span class="label nowrap">{value.title}</span>
</a>
{/if}
@@ -0,0 +1,20 @@
<script lang="ts">
import { ControlledDocumentState } from '@hcengineering/controlled-documents'
import { controlledDocumentStatesOrder } from '../../../utils'
import StatePresenter from './StatePresenter.svelte'
export let value: Map<number, Map<ControlledDocumentState, ControlledDocumentState[]>>
let states: ControlledDocumentState[] = []
$: states = Array.from(value.values())
.map(([_, states]) => states[0])
.sort(
(state1, state2) => controlledDocumentStatesOrder.indexOf(state1) - controlledDocumentStatesOrder.indexOf(state2)
)
</script>
<div class="flex-presenter flex-gap-1-5">
{#each states as state}
<StatePresenter value={state} />
{/each}
</div>
@@ -0,0 +1,56 @@
<!--
// Copyright © 2024 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.
-->
<script lang="ts">
import { type DocumentTemplate } from '@hcengineering/controlled-documents'
import { Label, eventToHTMLElement, showPopup } from '@hcengineering/ui'
import view from '@hcengineering/view'
import ChangeDocPrefixPopup from '../popups/ChangeDocPrefixPopup.svelte'
export let value: DocumentTemplate
export let editable: boolean = false
function handleClick (event: MouseEvent): void {
if (!editable) {
return
}
event?.preventDefault()
event?.stopPropagation()
showPopup(
ChangeDocPrefixPopup,
{
object: value
},
eventToHTMLElement(event)
)
}
</script>
{#if value}
<a
class="flex-presenter inline-presenter noBold"
class:no-underline={!editable}
class:cursor-inherit={!editable}
href={undefined}
on:click={handleClick}
>
{value.docPrefix}
</a>
{:else}
<Label label={view.string.LabelNA} />
{/if}
@@ -0,0 +1,73 @@
<!--
// Copyright © 2024 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.
-->
<script lang="ts">
import { createEventDispatcher } from 'svelte'
import { Document } from '@hcengineering/controlled-documents'
import { WithLookup } from '@hcengineering/core'
import { getEmbeddedLabel } from '@hcengineering/platform'
import { getPanelURI, tooltip } from '@hcengineering/ui'
import DocumentIcon from '../../icons/DocumentIcon.svelte'
import documents from '../../../plugin'
export let value: WithLookup<Document> | undefined
export let inline = false
export let isGray = false
export let isRegular = false
export let withIcon = false
export let withTitle = false
export let disableLink = false
export let editable = false
$: documentCode = value !== undefined ? value.code : ''
$: noUnderline = disableLink && !editable
$: title = withTitle ? `${documentCode} ${value?.title}` : documentCode
const dispatch = createEventDispatcher()
function handleClick (event: MouseEvent): void {
if (!editable) {
return
}
dispatch('edit', event)
}
</script>
{#if value}
<a
class="flex-presenter"
href={!disableLink ? `#${getPanelURI(documents.component.EditDoc, value._id, value._class, 'content')}` : undefined}
class:inline-presenter={inline}
class:noBold={isRegular}
class:no-underline={noUnderline}
class:cursor-inherit={noUnderline}
use:tooltip={{ label: getEmbeddedLabel(title) }}
on:click={handleClick}
>
{#if withIcon}
<div class="icon">
<DocumentIcon size="small" />
</div>
{/if}
<span class="label nowrap" class:no-underline={noUnderline} class:label-gray={isGray}>{title}</span>
</a>
{/if}
<style lang="scss">
.label-gray {
color: var(--theme-halfcontent-color);
}
</style>
@@ -0,0 +1,25 @@
<script lang="ts">
import documents, { Document } from '@hcengineering/controlled-documents'
import { Ref } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Label } from '@hcengineering/ui'
import view from '@hcengineering/view'
export let value: Ref<Document> | undefined
let document: Document | undefined = undefined
const client = getClient()
$: if (value) {
client.findOne(documents.class.Document, { _id: value }).then((result) => {
document = result
})
}
</script>
{#if document}
{document.title}
{:else}
<Label label={view.string.LabelNA} />
{/if}
@@ -0,0 +1,12 @@
<script lang="ts">
import { WithLookup } from '@hcengineering/core'
import { Document } from '@hcengineering/controlled-documents'
import { Label } from '@hcengineering/ui'
import document from '../../../plugin'
export let value: WithLookup<Document> | undefined
</script>
{#if value}
<Label label={document.string.VersionValue} params={{ major: value.major, minor: value.minor }} />
{/if}
@@ -0,0 +1,80 @@
<!--
// Copyright © 2024 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.
-->
<script lang="ts">
import documents, { Document } from '@hcengineering/controlled-documents'
import { Employee } from '@hcengineering/contact'
import { PersonPresenter } from '@hcengineering/contact-resources'
import { Ref } from '@hcengineering/core'
import { IntlString } from '@hcengineering/platform'
import { getClient } from '@hcengineering/presentation'
import { eventToHTMLElement, showPopup } from '@hcengineering/ui'
import { checkMyPermission, permissionsStore } from '@hcengineering/view-resources'
import document from '../../../plugin'
import ChangeOwnerPopup from '../popups/ChangeOwnerPopup.svelte'
import { getCurrentEmployee } from '../../../utils'
export let _id: Ref<Employee> | undefined
export let value: Employee | null | undefined
export let object: Document
export let isEditable: boolean = false
export let shouldShowLabel: boolean = false
export let defaultName: IntlString | undefined = undefined
const client = getClient()
const me = getCurrentEmployee()
$: canChangeOwner =
isEditable &&
(object.owner === me ||
checkMyPermission(documents.permission.UpdateDocumentOwner, object.space, $permissionsStore))
const handleOwnerChanged = async (result: Employee | null | undefined) => {
if (!isEditable || !result || result._id === object.owner) {
return
}
await client.update(object, { owner: result._id })
}
const handleOwnerEditorOpened = async (event: MouseEvent) => {
if (!canChangeOwner) {
return
}
event?.preventDefault()
event?.stopPropagation()
showPopup(
ChangeOwnerPopup,
{
object
},
eventToHTMLElement(event),
handleOwnerChanged
)
}
</script>
<PersonPresenter
{defaultName}
value={_id ?? value?._id}
disabled={!canChangeOwner}
avatarSize={'x-small'}
shouldShowPlaceholder={true}
shouldShowName={shouldShowLabel}
tooltipLabels={{ personLabel: document.string.AssignedTo, placeholderLabel: document.string.Unassigned }}
onEdit={canChangeOwner ? handleOwnerEditorOpened : undefined}
/>
@@ -0,0 +1,18 @@
<script lang="ts">
import { DocumentState } from '@hcengineering/controlled-documents'
import StatePresenter from './StatePresenter.svelte'
import { documentStatesOrder } from '../../../utils'
export let value: Map<number, Map<DocumentState, DocumentState[]>>
let states: DocumentState[] = []
$: states = Array.from(value.values())
.map(([_, states]) => states[0])
.sort((state1, state2) => documentStatesOrder.indexOf(state1) - documentStatesOrder.indexOf(state2))
</script>
<div class="flex-presenter flex-gap-1-5">
{#each states as state}
<StatePresenter value={state} />
{/each}
</div>
@@ -0,0 +1,62 @@
<script lang="ts">
import { WithLookup } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { themeStore } from '@hcengineering/ui'
import {
ControlledDocument,
ControlledDocumentState,
Document,
DocumentState,
isControlledDocument
} from '@hcengineering/controlled-documents'
import DocumentStatusTag from '../common/DocumentStatusTag.svelte'
import {
statesTags,
controlledStatesTags,
DocumentStateTagType,
TranslatedControlledDocStates,
TranslatedDocumentStates,
getTranslatedControlledDocStates,
getTranslatedDocumentStates
} from '../../../utils'
export let value: WithLookup<Document> | WithLookup<ControlledDocument> | ControlledDocumentState | DocumentState
export let showTag = true
const client = getClient()
let docStates: TranslatedDocumentStates | undefined
let controlledDocStates: TranslatedControlledDocStates | undefined
async function getTranslatedLabels (lang: string) {
docStates = await getTranslatedDocumentStates(lang)
controlledDocStates = await getTranslatedControlledDocStates(lang)
}
$: getTranslatedLabels($themeStore.language)
let text: string = ''
let type: DocumentStateTagType
$: if (docStates && controlledDocStates) {
if (typeof value === 'string') {
text = controlledDocStates[value as ControlledDocumentState] || docStates[value as DocumentState]
type = controlledStatesTags[value as ControlledDocumentState] || statesTags[value as DocumentState]
} else if (isControlledDocument(client, value) && value.controlledState != null) {
text = controlledDocStates[value.controlledState]
type = controlledStatesTags[value.controlledState]
} else {
text = docStates[value.state]
type = statesTags[value.state]
}
}
</script>
{#if docStates !== undefined && controlledDocStates !== undefined}
{#if showTag}
<DocumentStatusTag {type}>{text}</DocumentStatusTag>
{:else}
{text}
{/if}
{/if}
@@ -0,0 +1,33 @@
<!--
// Copyright © 2024 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.
-->
<script lang="ts">
import { Document } from '@hcengineering/controlled-documents'
import { WithLookup } from '@hcengineering/core'
import { getPanelURI } from '@hcengineering/ui'
import documents from '../../../plugin'
export let value: WithLookup<Document>
export let inline = false
</script>
{#if value}
<a
class="flex-presenter"
href="#{getPanelURI(documents.component.EditDoc, value._id, value._class, 'content')}"
class:inline-presenter={inline}
>
<span class="label nowrap">{value.title}</span>
</a>
{/if}
@@ -0,0 +1,64 @@
<script lang="ts">
import documents from '@hcengineering/controlled-documents'
import { Label, Button, showPopup } from '@hcengineering/ui'
import TeamPopup from '../../TeamPopup.svelte'
import {
$canSendForApproval as canSendForApproval,
$controlledDocument as controlledDocument
} from '../../../stores/editors/document'
import documentsRes from '../../../plugin'
import { TeamPopupData } from '../../../utils'
function onSendDocRequest (): void {
if ($controlledDocument == null) {
return
}
const teamPopupData: TeamPopupData = {
controlledDoc: $controlledDocument,
requestClass: documents.class.DocumentApprovalRequest
}
showPopup(TeamPopup, teamPopupData, 'center')
}
</script>
<div class="add-approval-message">
<div class="message-title"><Label label={documentsRes.string.AddApprovalTitle} /></div>
<div><Label label={documentsRes.string.AddApprovalDescription1} /></div>
<ul>
<li><Label label={documentsRes.string.AddApprovalDescription2} /></li>
<li><Label label={documentsRes.string.AddApprovalDescription3} /></li>
<li><Label label={documentsRes.string.AddApprovalDescription4} /></li>
</ul>
</div>
<Button
label={documentsRes.string.SendForApproval}
disabled={!$canSendForApproval}
kind="regular"
size="medium"
on:click={() => {
onSendDocRequest()
}}
/>
<style lang="scss">
.add-approval-message {
font-weight: 400;
line-height: 1.25rem;
margin-bottom: 1rem;
ul {
margin-block-start: 0.25rem;
margin-block-end: 0;
padding-inline-start: 1.5rem;
}
}
.message-title {
font-weight: 500;
line-height: 1.25rem;
margin-bottom: 1rem;
}
</style>
@@ -0,0 +1,195 @@
<script lang="ts">
import { slide } from 'svelte/transition'
import documents, { DocumentRequest } from '@hcengineering/controlled-documents'
import chunter from '@hcengineering/chunter'
import { PersonAccount } from '@hcengineering/contact'
import { PersonAccountRefPresenter } from '@hcengineering/contact-resources'
import { Ref } from '@hcengineering/core'
import { getClient } from '@hcengineering/presentation'
import { Chevron, Label } from '@hcengineering/ui'
import { $documentSnapshots as documentSnapshots } from '../../../stores/editors/document'
import documentsRes from '../../../plugin'
import ApprovedIcon from '../../icons/Approved.svelte'
import RejectedIcon from '../../icons/Rejected.svelte'
import CancelledIcon from '../../icons/Cancelled.svelte'
import WaitingIcon from '../../icons/Waiting.svelte'
export let request: DocumentRequest
export let initiallyExpanded: boolean = false
interface PersonalApproval {
account: Ref<PersonAccount>
approved: 'approved' | 'rejected' | 'cancelled' | 'waiting'
}
const client = getClient()
const hierarchy = client.getHierarchy()
let expanded: boolean = initiallyExpanded
let rejectingMessage: string | undefined
let approvals: PersonalApproval[] = []
$: if (request != null) {
void getRequestData()
}
$: type = hierarchy.isDerived(request._class, documents.class.DocumentApprovalRequest)
? documents.string.Approval
: documents.string.Review
async function getRequestData (): Promise<void> {
if (request !== undefined) {
approvals = await getApprovals(request)
const rejectingComment = await client.findOne(chunter.class.ChatMessage, {
attachedTo: request?._id,
attachedToClass: request?._class
})
rejectingMessage = rejectingComment?.message
}
}
async function getApprovals (req: DocumentRequest): Promise<PersonalApproval[]> {
const rejectedBy: PersonalApproval[] =
req.rejected !== undefined
? [
{
account: req.rejected,
approved: 'rejected'
}
]
: []
const approvedBy: PersonalApproval[] = req.approved.map((id) => ({
account: id,
approved: 'approved'
}))
const ignoredBy = req.requested
.filter((p) => p !== req?.rejected)
.filter((p) => !(req?.approved as string[]).includes(p))
.map(
(id): PersonalApproval => ({
account: id,
approved: req?.rejected !== undefined ? 'cancelled' : 'waiting'
})
)
return [...approvedBy, ...rejectedBy, ...ignoredBy]
}
$: snapshot = $documentSnapshots
.toReversed()
.find((s) => s.createdOn !== undefined && request.createdOn !== undefined && s.createdOn > request.createdOn)
const dtf = new Intl.DateTimeFormat('default', {
day: 'numeric',
month: 'short'
})
</script>
<button
class:bottom-divider={!expanded}
class="justify-start"
on:click={() => {
expanded = !expanded
}}
>
<div class="header flex-row-center flex-gap-1-5">
<span class="title">
{#if snapshot != null}
{snapshot?.name}
{:else}
<Label label={documentsRes.string.CurrentVersion} />
{/if}
</span>
<span></span>
<span><Label label={type} /></span>
<span></span>
<span class="date">{dtf.format(request?.modifiedOn)}</span>
<div class="chevron" class:visible={expanded}>
<Chevron outline {expanded} size={'small'} />
</div>
</div>
</button>
{#if expanded}
<div class="section" transition:slide|local>
{#each approvals as approver}
<div class="approver">
<PersonAccountRefPresenter value={approver.account} avatarSize="x-small" />
{#if approver.approved === 'approved'}
<ApprovedIcon size="medium" fill={'var(--theme-docs-accepted-color)'} />
{:else if approver.approved === 'rejected'}
<RejectedIcon size="medium" fill={'var(--negative-button-default)'} />
{:else if approver.approved === 'cancelled'}
<CancelledIcon size="medium" />
{:else if approver.approved === 'waiting'}
<WaitingIcon size="medium" />
{/if}
</div>
{#if rejectingMessage !== undefined && approver.approved === 'rejected'}
<div class="reject-message">{@html rejectingMessage}</div>
{/if}
{/each}
</div>
{/if}
<style lang="scss">
button:hover {
background-color: var(--theme-button-hovered);
&:hover {
.chevron {
visibility: visible;
}
}
}
.header {
font-size: 0.8125rem;
font-weight: 500;
padding: 0.75rem 1rem;
color: var(--theme-text-primary-color);
.title {
margin: 0 0.125rem;
}
.date {
font-weight: 400;
font-size: 0.75rem;
margin: 0 0.125rem;
color: var(--theme-dark-color);
}
.chevron {
margin-left: 0.25rem;
visibility: hidden;
&.visible {
visibility: visible;
}
}
}
.section {
color: var(--theme-text-primary-color);
padding: 0.75rem 1rem 1rem 1rem;
font-weight: 500;
flex-shrink: 0;
border-bottom: 1px solid var(--theme-divider-color);
.reject-message {
font-weight: 400;
padding: 0.625rem 1rem 0 2rem;
}
}
.approver {
display: flex;
align-items: center;
justify-content: space-between;
&:not(:first-child) {
margin-top: 1rem;
}
}
</style>
@@ -0,0 +1,86 @@
<script lang="ts">
import documents, {
ControlledDocumentState,
DocumentRequest,
DocumentState
} from '@hcengineering/controlled-documents'
import { SortingOrder } from '@hcengineering/core'
import { createQuery, getClient } from '@hcengineering/presentation'
import { Label, Scroller } from '@hcengineering/ui'
import { $controlledDocument as controlledDocument } from '../../../stores/editors/document'
import document from '../../../plugin'
import RightPanelTabHeader from './RightPanelTabHeader.svelte'
import DocumentApprovalItem from './DocumentApprovalItem.svelte'
import DocumentApprovalGuideItem from './DocumentApprovalGuideItem.svelte'
const client = getClient()
const hierarchy = client.getHierarchy()
let requests: DocumentRequest[] = []
let approvals: DocumentRequest[] = []
$: approvals = requests.filter((p) => hierarchy.isDerived(p._class, documents.class.DocumentApprovalRequest))
const query = createQuery()
$: query.query(
documents.class.DocumentRequest,
{
_class: {
$in: [documents.class.DocumentApprovalRequest, documents.class.DocumentReviewRequest]
},
attachedTo: $controlledDocument?._id
},
(result) => {
requests = result
},
{
sort: { createdOn: SortingOrder.Descending }
}
)
$: hasGuide =
$controlledDocument?.state === DocumentState.Draft &&
($controlledDocument?.controlledState == null ||
![
ControlledDocumentState.Approved,
ControlledDocumentState.Rejected,
ControlledDocumentState.InApproval
].includes($controlledDocument?.controlledState))
</script>
<RightPanelTabHeader>
<Label label={document.string.DocumentApprovals} />
</RightPanelTabHeader>
<Scroller>
{#if hasGuide}
<div class="p-4 bottom-divider">
<DocumentApprovalGuideItem />
</div>
{/if}
{#if requests.length > 0}
{#each requests as object, idx}
<DocumentApprovalItem request={object} initiallyExpanded={!hasGuide && idx === 0} />
{/each}
{/if}
{#if !hasGuide && approvals.length === 0}
<div class="no-approvals-message"><Label label={document.string.NoApprovalsDescription} /></div>
{/if}
</Scroller>
<style lang="scss">
.no-approvals-message {
opacity: 0.8;
font-weight: 400;
width: 100%;
color: var(--theme-text-primary-color);
padding: 1.5rem;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
text-align: center;
}
</style>
@@ -0,0 +1,91 @@
<!--
//
// Copyright © 2023 Xored
//
-->
<script lang="ts">
import documents, { DocumentComment } from '@hcengineering/controlled-documents'
import { ThreadView } from '@hcengineering/chunter-resources'
import { Button, IconCheckCircle } from '@hcengineering/ui'
import {
$canAddDocumentCommentsFeedback as canAddDocumentCommentsFeedback,
$controlledDocumentSectionIds as controlledDocumentSectionIds,
$controlledDocumentSections as controlledDocumentSections,
resolveCommentFx
} from '../../../stores/editors/document'
export let value: DocumentComment | undefined
export let highlighted = false
$: section =
value !== undefined
? $controlledDocumentSections.find((item) => value !== undefined && item.key === value.sectionKey)
: null
$: sectionIndex = section != null ? $controlledDocumentSectionIds.indexOf(section._id) + 1 : null
const handleResolveComment = async (): Promise<void> => {
if (value === undefined) {
return
}
await resolveCommentFx({ comment: value, resolved: !isResolved(value) })
}
$: resolved = isResolved(value)
function isResolved (val: DocumentComment | undefined): boolean {
return val?.resolved !== undefined && val.resolved
}
</script>
{#if value !== undefined}
<div class:highlighted class="root">
<div class="header pt-2 pb-2 pl-4 pr-4 flex-between">
<div>
{#if value?.index}
<span>#{value.index}</span>
<span></span>
{/if}
{#if sectionIndex !== null}
<span>{sectionIndex}.</span>
{/if}
{#if section}
<span>{section.title}</span>
{/if}
</div>
{#if $canAddDocumentCommentsFeedback}
<div class="tools">
<Button
icon={resolved ? IconCheckCircle : documents.icon.CheckmarkCircle}
iconProps={{ size: 'medium', fill: resolved ? 'var(--theme-docs-accepted-color)' : undefined }}
kind="icon"
showTooltip={{ label: resolved ? documents.string.Unresolve : documents.string.Resolve }}
on:click={handleResolveComment}
/>
</div>
{/if}
</div>
<ThreadView _id={value._id} showHeader={false} />
</div>
{/if}
<style lang="scss">
.root {
.tools {
visibility: hidden;
}
&:hover {
.tools {
visibility: visible;
}
}
}
.header {
font-size: 0.875rem;
font-weight: 500;
}
.highlighted {
background-color: var(--theme-docs-comment-highlighted-color);
}
</style>
@@ -0,0 +1,114 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import chunter from '@hcengineering/chunter'
import { Ref } from '@hcengineering/core'
import { Button, Label, showPopup } from '@hcengineering/ui'
import documents, { type DocumentComment } from '@hcengineering/controlled-documents'
import { onDestroy } from 'svelte'
import {
$documentCommentHighlightedLocation as highlightedLocation,
$documentComments as documentComments,
$isDocumentCommentsFilterDirty as isFilterDirty,
documentCommentsLocationNavigateRequested,
documentCommentsNavigateRequested
} from '../../../stores/editors/document'
import { isDocumentCommentAttachedTo } from '../../../utils'
import DocumentCommentThread from './DocumentCommentThread.svelte'
import CommentFilterSettingsPopup from '../popups/CommentFilterSettingsPopup.svelte'
import RightPanelTabHeader from './RightPanelTabHeader.svelte'
const elements: Record<Ref<DocumentComment>, HTMLElement> = {}
const handleScrollIntoView = (item: DocumentComment): void => {
if (!item) {
return
}
const itemElement = elements[item._id]
if (itemElement) {
itemElement.scrollIntoView({ behavior: 'smooth' })
}
}
const unsubscribeNavigateTo = documentCommentsNavigateRequested.subscribe({
next: ({ value }) => {
if (!value) {
return
}
handleScrollIntoView(value)
}
})
const handleItemHighlight = (item: DocumentComment) => () => {
if (item.sectionKey === undefined) {
return
}
documentCommentsLocationNavigateRequested({
sectionKey: item.sectionKey,
nodeId: item.nodeId ?? null
})
}
const handleOpenFilter = (ev?: Event) => {
showPopup(CommentFilterSettingsPopup, {}, ev?.target as HTMLElement)
}
onDestroy(unsubscribeNavigateTo)
</script>
<RightPanelTabHeader>
<div class="flex-between w-full">
<Label label={chunter.string.Comments} />
<div class="configure-button">
<Button icon={documents.icon.Configure} kind="ghost" on:click={handleOpenFilter} />
{#if $isFilterDirty}
<div class="dirty-mark" />
{/if}
</div>
</div>
</RightPanelTabHeader>
{#if $documentComments.length > 0}
{#each $documentComments as object}
<div
bind:this={elements[object._id]}
on:click={handleItemHighlight(object)}
on:keydown={handleItemHighlight(object)}
>
<DocumentCommentThread
value={object}
highlighted={!!$highlightedLocation && isDocumentCommentAttachedTo(object, $highlightedLocation)}
/>
</div>
{/each}
{/if}
<style lang="scss">
.configure-button {
position: relative;
}
.dirty-mark {
position: absolute;
top: 0.375rem;
right: 0.375rem;
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background-color: var(--highlight-red);
}
</style>
@@ -0,0 +1,159 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import documents, { type Document, type DocumentTemplate } from '@hcengineering/controlled-documents'
import { PersonPresenter } from '@hcengineering/contact-resources'
import { DateRangeMode } from '@hcengineering/core'
import { DatePresenter, Label, Scroller, eventToHTMLElement, showPopup } from '@hcengineering/ui'
import { getClient } from '@hcengineering/presentation'
import documentsRes from '../../../plugin'
import {
$controlledDocument as controlledDocument,
$isEditable as isEditable,
$projectRef as projectRef
} from '../../../stores/editors/document'
import CategoryPresenter from '../presenters/CategoryPresenter.svelte'
import DocumentTitlePresenter from '../presenters/DocumentTitlePresenter.svelte'
import DocumentVersionPresenter from '../presenters/DocumentVersionPresenter.svelte'
import OwnerPresenter from '../presenters/OwnerPresenter.svelte'
import StatePresenter from '../presenters/StatePresenter.svelte'
import DocumentPresenter from '../presenters/DocumentPresenter.svelte'
import ChangeDocCodePopup from '../popups/ChangeDocCodePopup.svelte'
import DocumentInfo from './info/DocumentInfo.svelte'
import RightPanelTabHeader from './RightPanelTabHeader.svelte'
import DocumentInfoLabel from './info/DocumentInfoLabel.svelte'
import AbstractEditor from '../editors/AbstractEditor.svelte'
import DocumentFlatHierarchy from './info/DocumentFlatHierarchy.svelte'
import DocumentPrefixPresenter from '../presenters/DocumentPrefixPresenter.svelte'
const client = getClient()
const hierarchy = client.getHierarchy()
function handleCodeEdit (event: MouseEvent): void {
event?.preventDefault()
event?.stopPropagation()
showPopup(
ChangeDocCodePopup,
{
object: $controlledDocument
},
eventToHTMLElement(event)
)
}
$: isDocCodeEditable =
$isEditable && $controlledDocument != null && $controlledDocument.major === 0 && $controlledDocument.minor === 0
$: isDocPrefixEditable = isDocCodeEditable
$: isTemplate =
$controlledDocument != null && hierarchy.hasMixin($controlledDocument, documents.mixin.DocumentTemplate)
let asTemplate: DocumentTemplate
$: if ($controlledDocument != null && isTemplate) {
asTemplate = hierarchy.as<Document, DocumentTemplate>($controlledDocument, documents.mixin.DocumentTemplate)
}
</script>
<RightPanelTabHeader>
<Label label={documentsRes.string.GeneralInfo} />
</RightPanelTabHeader>
{#if $controlledDocument && $projectRef}
<Scroller>
<div class="p-5 pt-6 w-full text-md bottom-divider">
<DocumentInfo label={documentsRes.string.ID}>
<DocumentPresenter
value={$controlledDocument}
isRegular
disableLink
editable={isDocCodeEditable}
on:edit={(e) => {
handleCodeEdit(e.detail)
}}
/>
</DocumentInfo>
<DocumentInfo label={documentsRes.string.Category}>
<CategoryPresenter value={$controlledDocument.category} />
</DocumentInfo>
{#if !isTemplate}
<DocumentInfo label={documentsRes.string.TemplateName}>
<DocumentTitlePresenter value={$controlledDocument.template} />
</DocumentInfo>
{/if}
{#if isTemplate}
<DocumentInfo label={documentsRes.string.DocumentPrefix}>
<DocumentPrefixPresenter value={asTemplate} editable={isDocPrefixEditable} />
</DocumentInfo>
{/if}
<DocumentInfo label={documentsRes.string.Version}>
<DocumentVersionPresenter value={$controlledDocument} />
</DocumentInfo>
<DocumentInfo label={documentsRes.string.Modified}>
<DatePresenter
value={$controlledDocument.modifiedOn}
editable={false}
showIcon={false}
mode={DateRangeMode.DATETIME}
kind="regular"
/>
</DocumentInfo>
<DocumentInfo label={documentsRes.string.Status}>
<StatePresenter value={$controlledDocument} showTag={false} />
</DocumentInfo>
<DocumentInfo label={documentsRes.string.Owner}>
<OwnerPresenter
_id={$controlledDocument.owner}
object={$controlledDocument}
isEditable={$isEditable}
value={undefined}
shouldShowLabel
/>
</DocumentInfo>
<DocumentInfo label={documentsRes.string.Author}>
<PersonPresenter value={$controlledDocument.author} disabled={true} />
</DocumentInfo>
</div>
<div class="flex-gap-2 p-5 pt-6 w-full text-md">
<div class="py-2">
<DocumentInfoLabel label={documentsRes.string.MetaAbstract} />
</div>
<div class="py-2">
<AbstractEditor value={$controlledDocument} readonly={!$isEditable} />
</div>
</div>
{#if $controlledDocument.space !== documents.space.UnsortedTemplates}
<div class="flex-gap-2 p-5 pt-6 w-full text-md top-divider">
<div class="py-2">
<DocumentInfoLabel label={documentsRes.string.DocumentInHierarchy} />
</div>
<DocumentFlatHierarchy document={$controlledDocument} project={$projectRef} />
</div>
{/if}
</Scroller>
{/if}
@@ -0,0 +1,29 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { RightPanelTab, $activeRightPanelTab as activeRightPanelTab } from '../../../stores/editors/document'
import DocumentInfoTab from './DocumentInfoTab.svelte'
import DocumentCommentsTab from './DocumentCommentsTab.svelte'
import DocumentApprovalsTab from './DocumentApprovalsTab.svelte'
</script>
{#if $activeRightPanelTab === RightPanelTab.INFO}
<DocumentInfoTab />
{:else if $activeRightPanelTab === RightPanelTab.COMMENT}
<DocumentCommentsTab />
{:else if $activeRightPanelTab === RightPanelTab.APPROVALS}
<DocumentApprovalsTab />
{/if}
@@ -0,0 +1,36 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { Button, IconClose } from '@hcengineering/ui'
import { rightPanelTabChanged } from '../../../stores/editors/document'
</script>
<div class="header h-12 flex-between min-h-12 pl-4 pr-4 font-medium text-md bottom-divider">
<slot />
<Button
kind="ghost"
icon={IconClose}
on:click={() => {
rightPanelTabChanged(null)
}}
/>
</div>
<style lang="scss">
.header {
font-size: 0.875rem;
}
</style>
@@ -0,0 +1,219 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { type Ref, SortingOrder, getCurrentAccount } from '@hcengineering/core'
import { createQuery } from '@hcengineering/presentation'
import { type PersonAccount } from '@hcengineering/contact'
import documents, {
type ControlledDocument,
type DocumentMeta,
type HierarchyDocument,
type Project,
type ProjectMeta
} from '@hcengineering/controlled-documents'
import { compareDocs, notEmpty } from '../../../../utils'
import DocumentFlatTreeElement from './DocumentFlatTreeElement.svelte'
export let document: HierarchyDocument | undefined
export let project: Ref<Project>
const currentUser = getCurrentAccount() as PersonAccount
const currentPerson = currentUser.person
let meta: ProjectMeta | undefined
const projectMetaQuery = createQuery()
$: if (document !== undefined && project !== undefined) {
projectMetaQuery.query(
documents.class.ProjectMeta,
{
project,
meta: document.attachedTo
},
(result) => {
;[meta] = result
}
)
}
let parentMetas: ProjectMeta[] | undefined
const parentMetasQuery = createQuery()
$: if (meta !== undefined) {
parentMetasQuery.query(
documents.class.ProjectMeta,
{
meta: { $in: meta.path },
project: meta.project
},
(result) => {
parentMetas = result
}
)
} else {
parentMetasQuery.unsubscribe()
}
let directChildrenMetas: ProjectMeta[] | undefined
const childrenMetasQuery = createQuery()
$: if (meta !== undefined) {
childrenMetasQuery.query(
documents.class.ProjectMeta,
{
parent: meta?.meta,
project: meta.project
},
(result) => {
if (meta === undefined) {
return
}
directChildrenMetas = result
}
)
} else {
childrenMetasQuery.unsubscribe()
}
let docs: Record<Ref<DocumentMeta>, ControlledDocument> = {}
const docsQuery = createQuery()
$: if (parentMetas !== undefined && directChildrenMetas !== undefined) {
docsQuery.query(
documents.class.ProjectDocument,
{
attachedTo: { $in: [...parentMetas.map((p) => p._id), ...directChildrenMetas.map((p) => p._id)] }
},
(result) => {
docs = {}
let lastTemplate: string | undefined = '###'
let lastSeqNumber = -1
for (const prjdoc of result) {
const doc = prjdoc.$lookup?.document as ControlledDocument | undefined
if (doc === undefined) continue
// TODO add proper fix, when document with no template copied, saved value is null
const template = doc.template ?? undefined
if (template === lastTemplate && doc.seqNumber === lastSeqNumber) {
continue
}
if (
doc.owner === currentPerson ||
doc.coAuthors.findIndex((emp) => emp === currentPerson) >= 0 ||
doc.approvers.findIndex((emp) => emp === currentPerson) >= 0 ||
doc.reviewers.findIndex((emp) => emp === currentPerson) >= 0
) {
docs[doc.attachedTo] = doc
lastTemplate = template
lastSeqNumber = doc.seqNumber
}
}
},
{
lookup: {
document: documents.class.ControlledDocument
},
sort: {
'$lookup.document.template': SortingOrder.Ascending,
'$lookup.document.seqNumber': SortingOrder.Ascending,
'$lookup.document.major': SortingOrder.Descending,
'$lookup.document.minor': SortingOrder.Descending,
'$lookup.document.patch': SortingOrder.Descending
}
}
)
} else {
docsQuery.unsubscribe()
}
let directChildrenDocs: ControlledDocument[] = []
$: if (directChildrenMetas !== undefined) {
directChildrenDocs = Object.values(docs).filter(
(d) => directChildrenMetas !== undefined && directChildrenMetas.findIndex((m) => m.meta === d.attachedTo) >= 0
)
directChildrenDocs.sort(compareDocs)
}
let parentDocs: ControlledDocument[] = []
$: if (meta !== undefined) {
parentDocs = [...meta.path]
.reverse()
.map((mId) => docs[mId])
.filter(notEmpty)
}
let levels: Array<[HierarchyDocument[], boolean]> = []
$: {
levels = []
if (parentDocs?.length > 0) {
levels.push([parentDocs, false])
}
if (document !== undefined) {
levels.push([[document], true])
}
if (directChildrenDocs?.length > 0) {
levels.push([directChildrenDocs, false])
}
}
</script>
{#if levels.length > 0}
{@const [firstDocs, firstHltd] = levels[0]}
<div class="root">
{#each firstDocs as doc}
<DocumentFlatTreeElement {doc} {project} highlighted={firstHltd} />
{/each}
{#if levels.length > 1}
{@const [secondDocs, secondHltd] = levels[1]}
<div class="container">
{#each secondDocs as doc}
<DocumentFlatTreeElement {doc} {project} highlighted={secondHltd} />
{/each}
{#if levels.length > 2}
{@const [thirdDocs, thirdHltd] = levels[2]}
<div class="container">
{#each thirdDocs as doc}
<DocumentFlatTreeElement {doc} {project} highlighted={thirdHltd} />
{/each}
</div>
{/if}
</div>
{/if}
</div>
{/if}
<style lang="scss">
.root {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.container {
display: flex;
flex-direction: column;
padding: 0 1rem;
border-left: 2px solid var(--theme-navpanel-border);
gap: 0.25rem;
}
</style>
@@ -0,0 +1,59 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import documents, { getDocumentName, type Document, type Project } from '@hcengineering/controlled-documents'
import { type Ref } from '@hcengineering/core'
import { Icon, navigate } from '@hcengineering/ui'
import { getProjectDocumentLink } from '../../../../navigation'
export let doc: Document
export let project: Ref<Project>
export let highlighted: boolean = false
const icon = documents.icon.Document
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="antiNav-element root"
on:click={() => {
const loc = getProjectDocumentLink(doc, project)
navigate(loc)
}}
>
{#if icon}
<div class="an-element__icon">
<Icon
{icon}
iconProps={{
fill: 'currentColor'
}}
size={'small'}
/>
</div>
{/if}
<span class="an-element__label" class:font-medium={highlighted}>
{getDocumentName(doc)}
</span>
</div>
<style lang="scss">
.root {
padding-left: 0;
}
</style>
@@ -0,0 +1,43 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { IntlString } from '@hcengineering/platform'
import DocumentInfoLabel from './DocumentInfoLabel.svelte'
export let label: IntlString
</script>
<div class="flex flex-gap-1 min-h-8 items-center w-full">
<div class="label">
<DocumentInfoLabel {label} />
</div>
<div class="field">
<slot />
</div>
</div>
<style lang="scss">
.label {
flex-basis: 38%;
flex-shrink: 0;
}
.field {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
min-width: 0;
}
</style>
@@ -0,0 +1,31 @@
<!--
// Copyright © 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.
-->
<script lang="ts">
import { IntlString } from '@hcengineering/platform'
import { Label } from '@hcengineering/ui'
export let label: IntlString
</script>
<div class="label">
<Label {label} />
</div>
<style lang="scss">
.label {
color: var(--theme-dark-color);
}
</style>
@@ -0,0 +1,3 @@
import { writable } from 'svelte/store'
export const scrollIntoSection = writable<number | undefined>()
@@ -0,0 +1,6 @@
export enum LogType {
CREATE_DRAFT,
DELETE_DRAFT,
CHANGE_STATE,
CHANGE_OWNER
}