Subissue estimations (#2254)

Signed-off-by: Andrey Sobolev <haiodo@gmail.com>
This commit is contained in:
Andrey Sobolev 2022-08-22 08:03:32 +07:00 committed by GitHub
parent 854331ae5f
commit ccd2048ad0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 428 additions and 115 deletions

View File

@ -45,7 +45,7 @@ import attachment from '@anticrm/model-attachment'
import chunter from '@anticrm/model-chunter'
import core, { TAccount, TAttachedDoc, TDoc, TSpace } from '@anticrm/model-core'
import presentation from '@anticrm/model-presentation'
import view, { actionTemplates, createAction } from '@anticrm/model-view'
import view, { actionTemplates, createAction, ViewAction } from '@anticrm/model-view'
import workbench from '@anticrm/model-workbench'
import type { Asset, IntlString } from '@anticrm/platform'
import setting from '@anticrm/setting'
@ -58,6 +58,7 @@ export const DOMAIN_CHANNEL = 'channel' as Domain
export class TChannelProvider extends TDoc implements ChannelProvider {
label!: IntlString
icon?: Asset
action?: ViewAction
placeholder!: IntlString
}
@ -305,7 +306,8 @@ export function createModel (builder: Builder): void {
{
label: contact.string.LinkedIn,
icon: contact.icon.LinkedIn,
placeholder: contact.string.LinkedInPlaceholder
placeholder: contact.string.LinkedInPlaceholder,
action: contact.actionImpl.OpenChannel
},
contact.channelProvider.LinkedIn
)
@ -316,7 +318,8 @@ export function createModel (builder: Builder): void {
{
label: contact.string.Twitter,
icon: contact.icon.Twitter,
placeholder: contact.string.AtPlaceHolder
placeholder: contact.string.AtPlaceHolder,
action: contact.actionImpl.OpenChannel
},
contact.channelProvider.Twitter
)
@ -327,7 +330,8 @@ export function createModel (builder: Builder): void {
{
label: contact.string.GitHub,
icon: contact.icon.GitHub,
placeholder: contact.string.AtPlaceHolder
placeholder: contact.string.AtPlaceHolder,
action: contact.actionImpl.OpenChannel
},
contact.channelProvider.GitHub
)
@ -338,7 +342,8 @@ export function createModel (builder: Builder): void {
{
label: contact.string.Facebook,
icon: contact.icon.Facebook,
placeholder: contact.string.FacebookPlaceholder
placeholder: contact.string.FacebookPlaceholder,
action: contact.actionImpl.OpenChannel
},
contact.channelProvider.Facebook
)
@ -349,7 +354,8 @@ export function createModel (builder: Builder): void {
{
label: contact.string.Homepage,
icon: contact.icon.Homepage,
placeholder: contact.string.HomepagePlaceholder
placeholder: contact.string.HomepagePlaceholder,
action: contact.actionImpl.OpenChannel
},
contact.channelProvider.Homepage
)

View File

@ -83,6 +83,7 @@ export default mergeIds(contactId, contact, {
KickEmployee: '' as Ref<Action>
},
actionImpl: {
KickEmployee: '' as ViewAction
KickEmployee: '' as ViewAction,
OpenChannel: '' as ViewAction
}
})

View File

@ -307,6 +307,7 @@ export function createModel (builder: Builder): void {
label: recruit.string.Applications
},
'$lookup.company',
'$lookup.company.$lookup.channels',
'location',
'description',
{

View File

@ -55,7 +55,8 @@ import {
SprintStatus,
Team,
TimeSpendReport,
trackerId
trackerId,
IssueChildInfo
} from '@anticrm/tracker'
import { KeyBinding } from '@anticrm/view'
import tracker from './plugin'
@ -238,6 +239,8 @@ export class TIssue extends TAttachedDoc implements Issue {
@Prop(Collection(tracker.class.TimeSpendReport), tracker.string.TimeSpendReports)
reports!: number
declare childInfo: IssueChildInfo[]
}
/**

View File

@ -32,7 +32,11 @@ function $push (document: Doc, keyval: Record<string, PropertyType>): void {
if (typeof val === 'object') {
const arr = doc[key] as Array<any>
const desc = val as Position<PropertyType>
arr.splice(desc.$position, 0, ...desc.$each)
if ('$each' in desc) {
arr.splice(desc.$position ?? 0, 0, ...desc.$each)
} else {
arr.push(val)
}
} else {
doc[key].push(val)
}
@ -48,7 +52,20 @@ function $pull (document: Doc, keyval: Record<string, PropertyType>): void {
const arr = doc[key] as Array<any>
if (typeof keyval[key] === 'object') {
const { $in } = keyval[key] as PullArray<PropertyType>
doc[key] = arr.filter((val) => !$in.includes(val))
doc[key] = arr.filter((val) => {
if ($in !== undefined) {
return !$in.includes(val)
} else {
// We need to match all fields
for (const [kk, kv] of Object.entries(keyval[key])) {
if (val[kk] !== kv) {
return true
}
}
return false
}
})
} else {
doc[key] = arr.filter((val) => val !== keyval[key])
}

View File

@ -103,7 +103,7 @@ export interface TxMixin<D extends Doc, M extends D> extends TxCUD<D> {
* @public
*/
export type ArrayAsElement<T> = {
[P in keyof T]: T[P] extends Arr<infer X> ? X | PullArray<X> : never
[P in keyof T]: T[P] extends Arr<infer X> ? Partial<X> | PullArray<X> : never
}
/**

View File

@ -596,10 +596,12 @@ export class LiveQuery extends TxProcessor implements Client {
;(result as any)[key] = objects[0]
const nestedResult = {}
const parent = (result as any)[key]
await this.getLookupValue(_class, parent, nested, nestedResult)
Object.assign(parent, {
$lookup: nestedResult
})
if (parent !== undefined) {
await this.getLookupValue(_class, parent, nested, nestedResult)
Object.assign(parent, {
$lookup: nestedResult
})
}
} else {
const objects = await this.findAll(value, { _id: getObjectValue(tkey, doc) })
;(result as any)[key] = objects[0]

View File

@ -22,9 +22,9 @@
</script>
{#if attachments.length}
<div class="flex-col">
<div class="flex flex-wrap">
{#each attachments as attachment}
<div class="step-tb75">
<div class="p-2">
<AttachmentPreview value={attachment} isSaved={savedAttachmentsIds?.includes(attachment._id) ?? false} />
</div>
{/each}

View File

@ -13,23 +13,22 @@
// limitations under the License.
-->
<script lang="ts">
import { createEventDispatcher, onMount, afterUpdate } from 'svelte'
import type { IntlString } from '@anticrm/platform'
import { translate } from '@anticrm/platform'
import type { PopupOptions } from '@anticrm/ui'
import {
Button,
IconClose,
closeTooltip,
IconBlueCheck,
registerFocus,
createFocusManager,
FocusHandler,
IconArrowRight,
IconEdit
IconBlueCheck,
IconClose,
IconEdit,
registerFocus
} from '@anticrm/ui'
import IconCopy from './icons/Copy.svelte'
import { FocusHandler } from '@anticrm/ui'
import type { PopupOptions } from '@anticrm/ui'
import { afterUpdate, createEventDispatcher, onMount } from 'svelte'
import plugin from '../plugin'
import IconCopy from './icons/Copy.svelte'
export let value: string = ''
export let placeholder: IntlString
@ -101,8 +100,9 @@
style="width: 100%;"
on:keypress={(ev) => {
if (ev.key === 'Enter') {
ev.preventDefault()
ev.stopPropagation()
dispatch('close', value)
closeTooltip()
}
}}
on:change

View File

@ -32,6 +32,7 @@
Menu,
showPopup
} from '@anticrm/ui'
import { ViewAction } from '@anticrm/view'
import { createEventDispatcher, tick } from 'svelte'
import { getChannelProviders } from '../utils'
import ChannelEditor from './ChannelEditor.svelte'
@ -55,6 +56,7 @@
icon: Asset
value: string
presenter?: AnyComponent
action?: ViewAction
placeholder: IntlString
provider: Ref<ChannelProvider>
integration: boolean
@ -74,6 +76,7 @@
icon: provider.icon as Asset,
value: item.value,
presenter: provider.presenter,
action: provider.action,
placeholder: provider.placeholder,
provider: provider._id,
notification,

View File

@ -16,8 +16,10 @@
<script lang="ts">
import type { Channel } from '@anticrm/contact'
import { Doc } from '@anticrm/core'
import { getResource } from '@anticrm/platform'
import type { ButtonKind, ButtonSize } from '@anticrm/ui'
import { showPopup } from '@anticrm/ui'
import { ViewAction } from '@anticrm/view'
import ChannelsDropdown from './ChannelsDropdown.svelte'
export let value: Channel[] | Channel | null
@ -29,11 +31,15 @@
export let shape: 'circle' | undefined = 'circle'
export let object: Doc
function _open (ev: any) {
async function _open (ev: CustomEvent): Promise<void> {
if (ev.detail.presenter !== undefined && Array.isArray(value)) {
const channel = value[0]
if (channel !== undefined) {
showPopup(ev.detail.presenter, { _id: object._id, _class: object._class }, 'float')
showPopup(ev.detail.presenter, { _id: object._id, _class: object._class }, 'float')
}
if (ev.detail.action !== undefined && Array.isArray(value)) {
const action = await getResource(ev.detail.action as ViewAction)
const channel = value.find((it) => it.value === ev.detail.value)
if (action !== undefined && channel !== undefined) {
action(channel, ev)
}
}
}

View File

@ -14,41 +14,41 @@
// limitations under the License.
//
import { Contact, Employee, formatName } from '@anticrm/contact'
import { Channel, Contact, Employee, formatName } from '@anticrm/contact'
import { Class, Client, Ref } from '@anticrm/core'
import { leaveWorkspace } from '@anticrm/login-resources'
import { Resources } from '@anticrm/platform'
import { Avatar, getClient, MessageBox, ObjectSearchResult, UserInfo } from '@anticrm/presentation'
import { showPopup } from '@anticrm/ui'
import Channels from './components/Channels.svelte'
import ChannelsDropdown from './components/ChannelsDropdown.svelte'
import ChannelsEditor from './components/ChannelsEditor.svelte'
import ChannelsPresenter from './components/ChannelsPresenter.svelte'
import ChannelsView from './components/ChannelsView.svelte'
import ChannelsDropdown from './components/ChannelsDropdown.svelte'
import ContactPresenter from './components/ContactPresenter.svelte'
import Contacts from './components/Contacts.svelte'
import CreateEmployee from './components/CreateEmployee.svelte'
import CreateOrganization from './components/CreateOrganization.svelte'
import CreateOrganizations from './components/CreateOrganizations.svelte'
import CreatePerson from './components/CreatePerson.svelte'
import CreatePersons from './components/CreatePersons.svelte'
import EditMember from './components/EditMember.svelte'
import EditOrganization from './components/EditOrganization.svelte'
import EditPerson from './components/EditPerson.svelte'
import EmployeeAccountPresenter from './components/EmployeeAccountPresenter.svelte'
import EmployeeArrayEditor from './components/EmployeeArrayEditor.svelte'
import EmployeeBrowser from './components/EmployeeBrowser.svelte'
import EmployeeEditor from './components/EmployeeEditor.svelte'
import EmployeePresenter from './components/EmployeePresenter.svelte'
import MemberPresenter from './components/MemberPresenter.svelte'
import Members from './components/Members.svelte'
import OrganizationEditor from './components/OrganizationEditor.svelte'
import OrganizationPresenter from './components/OrganizationPresenter.svelte'
import OrganizationSelector from './components/OrganizationSelector.svelte'
import PersonEditor from './components/PersonEditor.svelte'
import PersonPresenter from './components/PersonPresenter.svelte'
import SocialEditor from './components/SocialEditor.svelte'
import contact from './plugin'
import EmployeePresenter from './components/EmployeePresenter.svelte'
import EmployeeBrowser from './components/EmployeeBrowser.svelte'
import EmployeeAccountPresenter from './components/EmployeeAccountPresenter.svelte'
import OrganizationEditor from './components/OrganizationEditor.svelte'
import PersonEditor from './components/PersonEditor.svelte'
import OrganizationSelector from './components/OrganizationSelector.svelte'
import Members from './components/Members.svelte'
import MemberPresenter from './components/MemberPresenter.svelte'
import EditMember from './components/EditMember.svelte'
import EmployeeArrayEditor from './components/EmployeeArrayEditor.svelte'
import EmployeeEditor from './components/EmployeeEditor.svelte'
import CreateEmployee from './components/CreateEmployee.svelte'
import { leaveWorkspace } from '@anticrm/login-resources'
export {
Channels,
@ -98,10 +98,16 @@ async function kickEmployee (doc: Employee): Promise<void> {
}
)
}
async function openChannelURL (doc: Channel): Promise<void> {
if (doc.value.startsWith('http://') || doc.value.startsWith('https://')) {
window.open(doc.value)
}
}
export default async (): Promise<Resources> => ({
actionImpl: {
KickEmployee: kickEmployee
KickEmployee: kickEmployee,
OpenChannel: openChannelURL
},
component: {
PersonEditor,

View File

@ -30,7 +30,7 @@ import {
import type { Asset, Plugin } from '@anticrm/platform'
import { IntlString, plugin } from '@anticrm/platform'
import type { AnyComponent } from '@anticrm/ui'
import { Viewlet } from '@anticrm/view'
import { ViewAction, Viewlet } from '@anticrm/view'
/**
* @public
@ -46,8 +46,16 @@ export interface Persons extends Space {}
* @public
*/
export interface ChannelProvider extends Doc, UXObject {
// Placeholder
placeholder: IntlString
// Presenter will be shown on click for channel
presenter?: AnyComponent
// Action to be performed if there is no presenter defined.
action?: ViewAction
// Integration type
integrationType?: Ref<Doc>
}

View File

@ -208,7 +208,9 @@
"TimeSpendReportValueTooltip": "Reported time in man days",
"TimeSpendReportDescription": "Description",
"TimeSpendValue": "{value}d",
"SprintPassed": "{from}d/{to}d"
"SprintPassed": "{from}d/{to}d",
"ChildEstimation": "Subissues Estimation",
"ChildReportedTime": "Subissues Time"
},
"status": {}
}

View File

@ -208,7 +208,9 @@
"TimeSpendReportValueTooltip": "Затраченное время в человеко днях",
"TimeSpendReportDescription": "Описание",
"TimeSpendValue": "{value}d",
"SprintPassed": "{from}d/{to}d"
"SprintPassed": "{from}d/{to}d",
"ChildEstimation": "Subissues Estimation",
"ChildReportedTime": "Subissues Time"
},
"status": {}
}

View File

@ -42,6 +42,7 @@
import SetParentIssueActionPopup from './SetParentIssueActionPopup.svelte'
import SprintSelector from './sprints/SprintSelector.svelte'
import { activeProject, activeSprint } from '../issues'
import EstimationEditor from './issues/timereport/EstimationEditor.svelte'
export let space: Ref<Team>
export let status: Ref<IssueStatus> | undefined = undefined
@ -71,7 +72,8 @@
parents: [],
reportedTime: 0,
estimation: 0,
reports: 0
reports: 0,
childInfo: []
}
const dispatch = createEventDispatcher()
@ -156,8 +158,9 @@
? [{ parentId: parentIssue._id, parentTitle: parentIssue.title }, ...parentIssue.parents]
: [],
reportedTime: 0,
estimation: 0,
reports: 0
estimation: object.estimation,
reports: 0,
childInfo: []
}
await client.addCollection(
@ -344,6 +347,7 @@
labels = labels.filter((it) => it._id !== evt.detail)
}}
/>
<EstimationEditor kind={'no-border'} size={'small'} value={object} />
<ProjectSelector value={object.project} onProjectIdChange={handleProjectIdChanged} />
<SprintSelector value={object.sprint} onSprintIdChange={handleSprintIdChanged} />
{#if object.dueDate !== null}

View File

@ -21,8 +21,12 @@
export let value: WithLookup<Issue>
// export let inline: boolean = false
export let disableClick = false
function handleIssueEditorOpened () {
if (disableClick) {
return
}
showPanel(tracker.component.EditIssue, value._id, value._class, 'content')
}
@ -39,7 +43,7 @@
</script>
{#if value}
<span class="issuePresenterRoot" title="title" on:click={handleIssueEditorOpened}>
<span class="issuePresenterRoot" class:noPointer={disableClick} title="title" on:click={handleIssueEditorOpened}>
{title}
</span>
{/if}
@ -56,6 +60,10 @@
color: var(--content-color);
cursor: pointer;
&.noPointer {
cursor: default;
}
&:hover {
color: var(--caption-color);
text-decoration: underline;

View File

@ -24,6 +24,7 @@
import AssigneeEditor from '../AssigneeEditor.svelte'
import StatusEditor from '../StatusEditor.svelte'
import PriorityEditor from '../PriorityEditor.svelte'
import EstimationEditor from '../timereport/EstimationEditor.svelte'
export let parentIssue: Issue
export let issueStatuses: WithLookup<IssueStatus>[]
@ -55,7 +56,12 @@
dueDate: null,
comments: 0,
subIssues: 0,
parents: []
parents: [],
sprint: parentIssue.sprint,
estimation: 0,
reportedTime: 0,
reports: 0,
childInfo: []
}
}
@ -209,6 +215,7 @@
labels = labels.filter((it) => it._id !== evt.detail)
}}
/>
<EstimationEditor kind={'no-border'} size={'small'} value={newIssue} />
</div>
<div class="buttons-group small-gap">
<Button label={presentation.string.Cancel} size="small" kind="transparent" on:click={close} />

View File

@ -279,7 +279,9 @@
<SubIssues {issue} {issueStatuses} {currentTeam} />
{/key}
</div>
<AttachmentDocList value={issue} />
<div class="mt-6">
<AttachmentDocList value={issue} />
</div>
{/if}
<span slot="actions-label">

View File

@ -32,6 +32,7 @@
import DueDateEditor from '../DueDateEditor.svelte'
import PriorityEditor from '../PriorityEditor.svelte'
import StatusEditor from '../StatusEditor.svelte'
import EstimationEditor from '../timereport/EstimationEditor.svelte'
export let issues: Issue[]
export let issueStatuses: WithLookup<IssueStatus>[]
@ -148,6 +149,7 @@
</span>
</div>
<div class="flex-center flex-no-shrink">
<EstimationEditor value={issue} kind={'list'} />
{#if issue.dueDate !== null}
<DueDateEditor value={issue} />
{/if}

View File

@ -13,15 +13,18 @@
// limitations under the License.
-->
<script lang="ts">
import { AttachedData } from '@anticrm/core'
import { getClient } from '@anticrm/presentation'
import { Issue } from '@anticrm/tracker'
import { Button, ButtonKind, ButtonSize, eventToHTMLElement, Label, showPopup } from '@anticrm/ui'
import EditBoxPopup from '@anticrm/view-resources/src/components/EditBoxPopup.svelte'
import { createEventDispatcher } from 'svelte'
import tracker from '../../../plugin'
import EstimationPopup from './EstimationPopup.svelte'
import EstimationProgressCircle from './EstimationProgressCircle.svelte'
export let value: Issue
export let value: Issue | AttachedData<Issue>
export let isEditable: boolean = true
export let kind: ButtonKind = 'link'
@ -39,16 +42,24 @@
return
}
showPopup(
EstimationPopup,
{ value: value.estimation, format: 'number', object: value },
eventToHTMLElement(event),
(res) => {
if (res != null) {
if (kind === 'list') {
showPopup(
EstimationPopup,
{ value: value.estimation, format: 'number', object: value },
eventToHTMLElement(event),
(res) => {
if (res != null) {
changeEstimation(res)
}
}
)
} else {
showPopup(EditBoxPopup, { value, format: 'number' }, eventToHTMLElement(event), (res) => {
if (res !== undefined) {
changeEstimation(res)
}
}
)
})
}
}
const changeEstimation = async (newEstimation: number | undefined) => {
@ -60,22 +71,67 @@
if ('_id' in value) {
await client.update(value, { estimation: newEstimation })
} else {
value.estimation = newEstimation
}
}
$: childReportTime = (value.childInfo ?? []).map((it) => it.reportedTime).reduce((a, b) => a + b, 0)
$: childEstimationTime = (value.childInfo ?? []).map((it) => it.estimation).reduce((a, b) => a + b, 0)
function hourFloor (value: number): number {
const days = Math.ceil(value)
const hours = value - days
return days + Math.floor(hours * 10) / 10
}
</script>
{#if value}
{#if kind === 'list'}
<div class="estimation-container" on:click={handleestimationEditorOpened}>
<div class="icon">
<EstimationProgressCircle value={value.reportedTime} max={value.estimation} />
<EstimationProgressCircle value={Math.max(value.reportedTime, childReportTime)} max={value.estimation} />
</div>
<span class="overflow-label label">
{#if value.reportedTime > 0}
<Label label={tracker.string.TimeSpendValue} params={{ value: value.reportedTime }} />
/
<span class="overflow-label label flex-row-center flex-nowrap text-md">
{#if value.reportedTime > 0 || childReportTime > 0}
{#if childReportTime}
{@const rchildReportTime = hourFloor(childReportTime)}
{@const reportDiff = rchildReportTime - hourFloor(value.reportedTime)}
{#if reportDiff !== 0 && value.reportedTime !== 0}
<div class="flex flex-nowrap mr-1" class:showError={reportDiff > 0}>
<Label label={tracker.string.TimeSpendValue} params={{ value: rchildReportTime }} />
</div>
<div class="romColor">
(<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.reportedTime) }} />)
</div>
{:else if value.reportedTime === 0}
<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(childReportTime) }} />
{:else}
<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.reportedTime) }} />
{/if}
{:else}
<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.reportedTime) }} />
{/if}
<div class="p-1">/</div>
{/if}
{#if childEstimationTime}
{@const childEstTime = Math.round(childEstimationTime)}
{@const estimationDiff = childEstTime - Math.round(value.estimation)}
{#if estimationDiff !== 0}
<div class="flex flex-nowrap mr-1" class:showWarning={estimationDiff !== 0}>
<Label label={tracker.string.TimeSpendValue} params={{ value: childEstTime }} />
</div>
{#if value.estimation !== 0}
<div class="romColor">
(<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.estimation) }} />)
</div>
{/if}
{:else}
<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.estimation) }} />
{/if}
{:else}
<Label label={tracker.string.TimeSpendValue} params={{ value: hourFloor(value.estimation) }} />
{/if}
<Label label={tracker.string.TimeSpendValue} params={{ value: value.estimation }} />
</span>
</div>
{:else}
@ -122,5 +178,15 @@
color: var(--caption-color) !important;
}
}
.showError {
color: var(--error-color) !important;
}
.showWarning {
color: var(--warning-color) !important;
}
.romColor {
color: var(--content-color) !important;
}
}
</style>

View File

@ -16,14 +16,15 @@
<script lang="ts">
import contact from '@anticrm/contact'
import { FindOptions } from '@anticrm/core'
import { Card } from '@anticrm/presentation'
import presentation, { Card } from '@anticrm/presentation'
import { Issue, TimeSpendReport } from '@anticrm/tracker'
import { Button, EditBox, EditStyle, eventToHTMLElement, IconAdd, Scroller, showPopup } from '@anticrm/ui'
import { Button, EditBox, EditStyle, eventToHTMLElement, IconAdd, Label, Scroller, showPopup } from '@anticrm/ui'
import { TableBrowser } from '@anticrm/view-resources'
import { createEventDispatcher } from 'svelte'
import tracker from '../../../plugin'
import IssuePresenter from '../IssuePresenter.svelte'
import ParentNamesPresenter from '../ParentNamesPresenter.svelte'
import TimeSpendReportPopup from './TimeSpendReportPopup.svelte'
import presentation from '@anticrm/presentation'
export let value: string | number | undefined
export let format: 'text' | 'password' | 'number'
@ -39,8 +40,9 @@
if (ev.key === 'Enter') dispatch('close', _value)
}
const options: FindOptions<TimeSpendReport> = {
lookup: { employee: contact.class.Employee }
lookup: { employee: contact.class.Employee, attachedTo: tracker.class.Issue }
}
$: childIds = Array.from((object.childInfo ?? []).map((it) => it.childId))
</script>
<Card
@ -54,6 +56,9 @@
dispatch('close', null)
}}
>
<svelte:fragment slot="header">
<IssuePresenter value={object} disableClick />
</svelte:fragment>
<div class="header no-border flex-col p-1">
<div class="flex-row-center flex-between">
<EditBox
@ -67,11 +72,28 @@
/>
</div>
</div>
<Label label={tracker.string.ChildEstimation} />:
<Scroller tableFade>
<TableBrowser
_class={tracker.class.Issue}
query={{ _id: { $in: childIds } }}
config={['', { key: '$lookup.attachedTo', presenter: ParentNamesPresenter }, 'estimation']}
{options}
/>
</Scroller>
<Label label={tracker.string.ReportedTime} />:
<Scroller tableFade>
<TableBrowser
_class={tracker.class.TimeSpendReport}
query={{ attachedTo: object._id }}
config={['', '$lookup.employee', 'date', 'description']}
query={{ attachedTo: { $in: [object._id, ...childIds] } }}
config={[
'$lookup.attachedTo',
{ key: '$lookup.attachedTo', presenter: ParentNamesPresenter },
'',
'$lookup.employee',
'date',
'description'
]}
{options}
/>
</Scroller>

View File

@ -25,9 +25,6 @@
export let greenColor: string = FernColor
export let overdueColor = FlamingoColor
if (value > max) value = max
if (value < min) value = min
const lenghtC: number = Math.PI * 14 - 1
$: procC = lenghtC / (max - min)
$: dashOffset = (Math.min(value, max) - min) * procC

View File

@ -36,12 +36,18 @@
function showReports (event: MouseEvent): void {
showPopup(ReportsPopup, { issue: object }, eventToHTMLElement(event))
}
$: childTime = (object.childInfo ?? []).map((it) => it.reportedTime).reduce((a, b) => a + b, 0)
</script>
{#if kind === 'link'}
<div class="link-container flex-between" on:click={showReports}>
{#if value !== undefined}
<span class="overflow-label">{value}</span>
<span class="overflow-label">
{value}
{#if childTime !== 0}
/ {childTime}
{/if}
</span>
{:else}
<span class="dark-color"><Label label={placeholder} /></span>
{/if}
@ -50,7 +56,12 @@
</div>
</div>
{:else if value !== undefined}
<span class="overflow-label">{value}</span>
<span class="overflow-label">
{value}
{#if childTime !== 0}
/ {childTime}
{/if}
</span>
{:else}
<span class="dark-color"><Label label={placeholder} /></span>
{/if}

View File

@ -20,6 +20,7 @@
import { Button, eventToHTMLElement, IconAdd, Scroller, showPopup } from '@anticrm/ui'
import { TableBrowser } from '@anticrm/view-resources'
import tracker from '../../../plugin'
import IssuePresenter from '../IssuePresenter.svelte'
import TimeSpendReportPopup from './TimeSpendReportPopup.svelte'
export let issue: Issue
@ -27,7 +28,10 @@
return true
}
const options: FindOptions<TimeSpendReport> = {
lookup: { employee: contact.class.Employee }
lookup: {
attachedTo: tracker.class.Issue,
employee: contact.class.Employee
}
}
function addReport (event: MouseEvent): void {
showPopup(
@ -45,11 +49,23 @@
okAction={() => {}}
okLabel={presentation.string.Ok}
>
<svelte:fragment slot="header">
<IssuePresenter value={issue} disableClick />
</svelte:fragment>
<Scroller tableFade>
<TableBrowser
_class={tracker.class.TimeSpendReport}
query={{ attachedTo: issue._id }}
config={['', '$lookup.employee', 'description', 'date', 'modifiedOn', 'modifiedBy']}
query={{ attachedTo: { $in: [issue._id, ...issue.childInfo.map((it) => it.childId)] } }}
config={[
'$lookup.attachedTo',
'$lookup.attachedTo.title',
'',
'$lookup.employee',
'description',
'date',
'modifiedOn',
'modifiedBy'
]}
{options}
/>
</Scroller>

View File

@ -55,12 +55,15 @@
)
}
$: totalEstimation = (issues ?? [{ estimation: 0 }])
$: ids = new Set(issues?.map((it) => it._id) ?? [])
$: noParents = issues?.filter((it) => !ids.has(it.attachedTo as Ref<Issue>))
$: totalEstimation = (noParents ?? [{ estimation: 0 }])
.map((it) => it.estimation)
.reduce((it, cur) => {
return it + cur
})
$: totalReported = (issues ?? [{ reportedTime: 0 }])
$: totalReported = (noParents ?? [{ reportedTime: 0 }])
.map((it) => it.reportedTime)
.reduce((it, cur) => {
return it + cur
@ -108,13 +111,15 @@
{@const now = Date.now()}
{#if sprint.startDate < now && now < sprint.targetDate}
<!-- Active sprint in time -->
<Label
label={tracker.string.SprintPassed}
params={{
from: getDayOfSprint(sprint.startDate, now),
to: getDayOfSprint(sprint.startDate, sprint.targetDate) - 1
}}
/>
<div class="ml-2">
<Label
label={tracker.string.SprintPassed}
params={{
from: getDayOfSprint(sprint.startDate, now),
to: getDayOfSprint(sprint.startDate, sprint.targetDate) - 1
}}
/>
</div>
{/if}
{/if}
<!-- <Label label={tracker.string.SprintDay} value={}/> -->

View File

@ -224,7 +224,10 @@ export default mergeIds(trackerId, tracker, {
TimeSpendReportValue: '' as IntlString,
TimeSpendReportDescription: '' as IntlString,
TimeSpendValue: '' as IntlString,
SprintPassed: '' as IntlString
SprintPassed: '' as IntlString,
ChildEstimation: '' as IntlString,
ChildReportedTime: '' as IntlString
},
component: {
NopeComponent: '' as AnyComponent,

View File

@ -160,10 +160,13 @@ export interface Issue extends AttachedDoc {
// Estimation in man days
estimation: number
// ReportedTime time, auto updated using trigger.
reportedTime: number
// Collection of reportedTime entries, for proper time estimations per person.
reports: number
childInfo: IssueChildInfo[]
}
/**
@ -191,6 +194,15 @@ export interface IssueParentInfo {
parentTitle: string
}
/**
* @public
*/
export interface IssueChildInfo {
childId: Ref<Issue>
estimation: number
reportedTime: number
}
/**
* @public
*/

View File

@ -21,10 +21,11 @@ import core, {
TxCreateDoc,
TxCUD,
TxProcessor,
TxUpdateDoc
TxUpdateDoc,
WithLookup
} from '@anticrm/core'
import { TriggerControl } from '@anticrm/server-core'
import tracker, { Issue, TimeSpendReport } from '@anticrm/tracker'
import tracker, { Issue, IssueParentInfo, TimeSpendReport } from '@anticrm/tracker'
async function updateSubIssues (
updateTx: TxUpdateDoc<Issue>,
@ -57,13 +58,32 @@ export async function OnIssueUpdate (tx: Tx, control: TriggerControl): Promise<T
}
}
if (actualTx._class !== core.class.TxUpdateDoc) {
return []
if (actualTx._class === core.class.TxCreateDoc) {
const createTx = actualTx as TxCreateDoc<Issue>
if (control.hierarchy.isDerived(createTx.objectClass, tracker.class.Issue)) {
const issue = TxProcessor.createDoc2Doc(createTx)
const res: Tx[] = []
for (const pinfo of issue.parents) {
res.push(
control.txFactory.createTxUpdateDoc(tracker.class.Issue, issue.space, pinfo.parentId, {
$push: {
childInfo: {
childId: issue._id,
estimation: issue.estimation,
reportedTime: issue.reportedTime
}
}
})
)
}
}
}
const updateTx = actualTx as TxUpdateDoc<Issue>
if (control.hierarchy.isDerived(updateTx.objectClass, tracker.class.Issue)) {
return await doIssueUpdate(updateTx, control)
if (actualTx._class === core.class.TxUpdateDoc) {
const updateTx = actualTx as TxUpdateDoc<Issue>
if (control.hierarchy.isDerived(updateTx.objectClass, tracker.class.Issue)) {
return await doIssueUpdate(updateTx, control)
}
}
return []
}
@ -80,11 +100,15 @@ async function doTimeReportUpdate (cud: TxCUD<TimeSpendReport>, tx: Tx, control:
switch (cud._class) {
case core.class.TxCreateDoc: {
const ccud = cud as TxCreateDoc<TimeSpendReport>
return [
const res = [
control.txFactory.createTxUpdateDoc<Issue>(parentTx.objectClass, parentTx.objectSpace, parentTx.objectId, {
$inc: { reportedTime: ccud.attributes.value }
})
]
const [currentIssue] = await control.findAll(tracker.class.Issue, { _id: parentTx.objectId }, { limit: 1 })
currentIssue.reportedTime += ccud.attributes.value
updateIssueParentEstimations(currentIssue, res, control, currentIssue.parents, currentIssue.parents)
return res
}
case core.class.TxUpdateDoc: {
const upd = cud as TxUpdateDoc<TimeSpendReport>
@ -96,16 +120,21 @@ async function doTimeReportUpdate (cud: TxCUD<TimeSpendReport>, tx: Tx, control:
})
).map(TxProcessor.extractTx)
const doc: TimeSpendReport | undefined = TxProcessor.buildDoc2Doc(logTxes)
const res: Tx[] = []
const [currentIssue] = await control.findAll(tracker.class.Issue, { _id: parentTx.objectId }, { limit: 1 })
if (doc !== undefined) {
return [
res.push(
control.txFactory.createTxUpdateDoc<Issue>(parentTx.objectClass, parentTx.objectSpace, parentTx.objectId, {
$inc: { reportedTime: -1 * doc.value }
}),
control.txFactory.createTxUpdateDoc<Issue>(parentTx.objectClass, parentTx.objectSpace, parentTx.objectId, {
$inc: { reportedTime: upd.operations.value }
$inc: { reportedTime: upd.operations.value - doc.value }
})
]
)
currentIssue.reportedTime -= doc.value
currentIssue.reportedTime += upd.operations.value
}
updateIssueParentEstimations(currentIssue, res, control, currentIssue.parents, currentIssue.parents)
return res
}
break
}
@ -118,11 +147,15 @@ async function doTimeReportUpdate (cud: TxCUD<TimeSpendReport>, tx: Tx, control:
).map(TxProcessor.extractTx)
const doc: TimeSpendReport | undefined = TxProcessor.buildDoc2Doc(logTxes)
if (doc !== undefined) {
return [
const res = [
control.txFactory.createTxUpdateDoc<Issue>(parentTx.objectClass, parentTx.objectSpace, parentTx.objectId, {
$inc: { reportedTime: -1 * doc.value }
})
]
const [currentIssue] = await control.findAll(tracker.class.Issue, { _id: parentTx.objectId }, { limit: 1 })
currentIssue.reportedTime -= doc.value
updateIssueParentEstimations(currentIssue, res, control, currentIssue.parents, currentIssue.parents)
return res
}
}
}
@ -132,13 +165,26 @@ async function doTimeReportUpdate (cud: TxCUD<TimeSpendReport>, tx: Tx, control:
async function doIssueUpdate (updateTx: TxUpdateDoc<Issue>, control: TriggerControl): Promise<Tx[]> {
const res: Tx[] = []
let currentIssue: WithLookup<Issue> | undefined
async function getCurrentIssue (): Promise<WithLookup<Issue>> {
if (currentIssue !== undefined) {
return currentIssue
}
// We need to remove estimation information from out parent issue
;[currentIssue] = await control.findAll(tracker.class.Issue, { _id: updateTx.objectId }, { limit: 1 })
return currentIssue
}
if (Object.prototype.hasOwnProperty.call(updateTx.operations, 'attachedTo')) {
const [newParent] = await control.findAll(
tracker.class.Issue,
{ _id: updateTx.operations.attachedTo as Ref<Issue> },
{ limit: 1 }
)
const updatedProject = newParent !== undefined ? newParent.project : null
const updatedSprint = newParent !== undefined ? newParent.sprint : null
const updatedParents =
newParent !== undefined ? [{ parentId: newParent._id, parentTitle: newParent.title }, ...newParent.parents] : []
@ -149,20 +195,44 @@ async function doIssueUpdate (updateTx: TxUpdateDoc<Issue>, control: TriggerCont
? {}
: { parents: [...issue.parents].slice(0, parentInfoIndex + 1).concat(updatedParents) }
return { ...parentsUpdate, project: updatedProject }
return { ...parentsUpdate, project: updatedProject, sprint: updatedSprint }
}
res.push(
control.txFactory.createTxUpdateDoc(updateTx.objectClass, updateTx.objectSpace, updateTx.objectId, {
parents: updatedParents,
project: updatedProject
project: updatedProject,
sprint: updatedSprint
}),
...(await updateSubIssues(updateTx, control, update))
)
// Remove from parent estimation list.
const issue = await getCurrentIssue()
updateIssueParentEstimations(issue, res, control, issue.parents, updatedParents)
}
if (Object.prototype.hasOwnProperty.call(updateTx.operations, 'project')) {
res.push(...(await updateSubIssues(updateTx, control, { project: updateTx.operations.project })))
res.push(
...(await updateSubIssues(updateTx, control, {
project: updateTx.operations.project,
sprint: updateTx.operations.sprint
}))
)
}
if (
Object.prototype.hasOwnProperty.call(updateTx.operations, 'estimation') ||
Object.prototype.hasOwnProperty.call(updateTx.operations, 'reportedTime')
) {
const issue = await getCurrentIssue()
issue.estimation = updateTx.operations.estimation ?? issue.estimation
issue.reportedTime = updateTx.operations.reportedTime ?? issue.reportedTime
updateIssueParentEstimations(issue, res, control, issue.parents, issue.parents)
}
if (Object.prototype.hasOwnProperty.call(updateTx.operations, 'sprint')) {
res.push(...(await updateSubIssues(updateTx, control, { sprint: updateTx.operations.sprint })))
}
if (Object.prototype.hasOwnProperty.call(updateTx.operations, 'title')) {
@ -181,3 +251,33 @@ async function doIssueUpdate (updateTx: TxUpdateDoc<Issue>, control: TriggerCont
return res
}
function updateIssueParentEstimations (
issue: WithLookup<Issue>,
res: Tx[],
control: TriggerControl,
sourceParents: IssueParentInfo[],
targetParents: IssueParentInfo[]
): void {
for (const pinfo of sourceParents) {
res.push(
control.txFactory.createTxUpdateDoc(tracker.class.Issue, issue.space, pinfo.parentId, {
$pull: {
childInfo: { childId: issue._id }
}
})
)
}
for (const pinfo of targetParents) {
res.push(
control.txFactory.createTxUpdateDoc(tracker.class.Issue, issue.space, pinfo.parentId, {
$push: {
childInfo: {
childId: issue._id,
estimation: issue.estimation,
reportedTime: issue.reportedTime
}
}
})
)
}
}

View File

@ -178,9 +178,10 @@ abstract class MongoAdapterBase extends TxProcessor {
parent?: string
): Promise<any | undefined> {
const fullKey = parent !== undefined ? parent + '.' + '_id' : '_id'
for (const key in lookup._id) {
const lid = lookup?._id ?? {}
for (const key in lid) {
const as = parent !== undefined ? parent + key : key
const value = lookup._id[key]
const value = lid[key]
let _class: Ref<Class<Doc>>
let attr = 'attachedTo'