From 6ca6d6fa30eb5bbf29f625ee7802a5057e9afad8 Mon Sep 17 00:00:00 2001 From: Ivan Blinov Date: Fri, 22 May 2026 16:01:17 +0300 Subject: [PATCH] fix: enhance time spent report popup and scrolling behavior Signed-off-by: Ivan Blinov --- models/tracker/src/index.ts | 19 +- models/tracker/src/migration.ts | 50 +- plugins/tracker-assets/lang/en.json | 26 + plugins/tracker-assets/lang/ru.json | 26 + .../timereport/IssueTaskFilterPopup.svelte | 64 ++ .../timereport/ProjectTimeReports.svelte | 36 + .../issues/timereport/TimeReportsView.svelte | 697 ++++++++++++++++++ .../issues/timereport/timeReportsExport.ts | 121 +++ plugins/tracker-resources/src/index.ts | 2 + plugins/tracker-resources/src/plugin.ts | 27 + plugins/workbench-resources/src/utils.ts | 31 +- 11 files changed, 1096 insertions(+), 3 deletions(-) create mode 100644 plugins/tracker-resources/src/components/issues/timereport/IssueTaskFilterPopup.svelte create mode 100644 plugins/tracker-resources/src/components/issues/timereport/ProjectTimeReports.svelte create mode 100644 plugins/tracker-resources/src/components/issues/timereport/TimeReportsView.svelte create mode 100644 plugins/tracker-resources/src/components/issues/timereport/timeReportsExport.ts diff --git a/models/tracker/src/index.ts b/models/tracker/src/index.ts index 44d1b97b506..bb265b292c8 100644 --- a/models/tracker/src/index.ts +++ b/models/tracker/src/index.ts @@ -309,6 +309,7 @@ function defineApplication ( issuesId: string componentsId: string milestonesId: string + timeReportsId: string templatesId: string labelsId: string } @@ -420,6 +421,12 @@ function defineApplication ( icon: tracker.icon.Milestone, component: tracker.component.Milestones }, + { + id: opt.timeReportsId, + label: tracker.string.TeamTimeReport, + icon: tracker.icon.TimeReport, + component: tracker.component.ProjectTimeReports + }, { id: opt.templatesId, label: tracker.string.IssueTemplates, @@ -501,6 +508,7 @@ export function createModel (builder: Builder): void { const issuesId = 'issues' const componentsId = 'components' const milestonesId = 'milestones' + const timeReportsId = 'timeReports' const templatesId = 'templates' const myIssuesId = 'my-issues' const allIssuesId = 'all-issues' @@ -624,7 +632,16 @@ export function createModel (builder: Builder): void { tracker.ids.IssueTemplateUpdatedActivityViewlet ) - defineApplication(builder, { myIssuesId, allIssuesId, issuesId, componentsId, milestonesId, templatesId, labelsId }) + defineApplication(builder, { + myIssuesId, + allIssuesId, + issuesId, + componentsId, + milestonesId, + timeReportsId, + templatesId, + labelsId + }) defineActions(builder, issuesId, componentsId, myIssuesId) diff --git a/models/tracker/src/migration.ts b/models/tracker/src/migration.ts index d07660e56c6..d2123dd8b43 100644 --- a/models/tracker/src/migration.ts +++ b/models/tracker/src/migration.ts @@ -38,15 +38,55 @@ import { DOMAIN_SPACE } from '@hcengineering/model-core' import { DOMAIN_TASK, migrateDefaultStatusesBase } from '@hcengineering/model-task' import tags from '@hcengineering/tags' import task from '@hcengineering/task' -import tracker, { +import { type Issue, type IssueStatus, type Project, TimeReportDayType, trackerId } from '@hcengineering/tracker' +import workbench, { type Application } from '@hcengineering/workbench' import { classicIssueTaskStatuses } from '.' +import tracker from './plugin' + +const TIME_REPORTS_SPECIAL_ID = 'timeReports' +const PROJECTS_SPACE_ID = 'projects' + +/** One-time patch for workspaces created before the tab existed in the model. */ +async function addTimeReportsTabToApp (client: MigrationUpgradeClient): Promise { + const tx = new TxOperations(client, core.account.System) + + const app = await tx.findOne(workbench.class.Application, { _id: tracker.app.Tracker }) + if (app === undefined) return + + const projectsSpace = app?.navigatorModel?.spaces?.find((s) => s.id === PROJECTS_SPACE_ID) + if (projectsSpace === undefined) return + if (projectsSpace.specials?.some((s) => s.id === TIME_REPORTS_SPECIAL_ID) === true) return + + const timeReportsTab = { + id: TIME_REPORTS_SPECIAL_ID, + label: tracker.string.TeamTimeReport, + icon: tracker.icon.TimeReport, + component: tracker.component.ProjectTimeReports + } + const specials = [...(projectsSpace.specials ?? [])] + const templatesIdx = specials.findIndex((s) => s.id === 'templates') + if (templatesIdx >= 0) { + specials.splice(templatesIdx, 0, timeReportsTab) + } else { + specials.push(timeReportsTab) + } + + const navigatorModel = { ...app.navigatorModel } + if (navigatorModel.spaces != null) { + navigatorModel.spaces = navigatorModel.spaces.map((space) => + space.id === PROJECTS_SPACE_ID ? { ...space, specials } : space + ) + } + + await tx.update(app, { navigatorModel }) +} async function createDefaultProject (tx: TxOperations): Promise { const current = await tx.findOne(tracker.class.Project, { @@ -409,6 +449,14 @@ export const trackerOperation: MigrateOperation = { const tx = new TxOperations(client, core.account.System) await createDefaults(tx) } + }, + { + state: 'add-time-reports-tab', + func: addTimeReportsTabToApp + }, + { + state: 'add-time-reports-tab-v2', + func: addTimeReportsTabToApp } ]) } diff --git a/plugins/tracker-assets/lang/en.json b/plugins/tracker-assets/lang/en.json index 4345dc5a0aa..b2481666085 100644 --- a/plugins/tracker-assets/lang/en.json +++ b/plugins/tracker-assets/lang/en.json @@ -228,6 +228,32 @@ "ReportedTime": "Spent time", "RemainingTime": "Remaining time", "TimeSpendReports": "Time spent reports", + "TimeReports": "Time reports", + "TeamTimeReport": "Team time report", + "TimeReportsFilterTask": "Filter by task", + "TimeReportsFilterTaskSearch": "Search task by title", + "TimeReportsAddTask": "Select task", + "TimeReportsClearTasks": "Clear tasks", + "TimeReportsFilterReporter": "Who logged time", + "TimeReportsFilterAssignees": "Filter tasks by assignee", + "TimeReportsFilterTracker": "Filter tasks by time tracker", + "TimeReportsDates": "Dates", + "TimeReportsWithRecordedTime": "With recorded time", + "TimeReportsAllTasks": "All tasks", + "TimeReportsColumnAssignees": "Selected assignees in filter", + "TimeReportsTitle": "Time spent ({count} tasks)", + "TimeReportsEmpty": "No tasks match the selected filters", + "TimeReportsOfMembers": "{selected} people of {total}", + "TimeReportsPrevPage": "Previous page", + "TimeReportsNextPage": "Next page", + "TimeReportsExportExcel": "Export to Excel", + "TimeReportsExportColIssueId": "Issue ID", + "TimeReportsExportColIssueTitle": "Issue title", + "TimeReportsExportColParentId": "Parent issue ID", + "TimeReportsExportColParentTitle": "Parent issue title", + "TimeReportsExportColReporter": "Time logged by", + "TimeReportsExportColHours": "Hours logged", + "TimeReportsExportTotal": "Total", "TimeSpendReport": "Time", "TimeSpendReportAdd": "Add time report", "TimeSpendReportDate": "Date", diff --git a/plugins/tracker-assets/lang/ru.json b/plugins/tracker-assets/lang/ru.json index 642877b385a..88a95896e77 100644 --- a/plugins/tracker-assets/lang/ru.json +++ b/plugins/tracker-assets/lang/ru.json @@ -228,6 +228,32 @@ "ReportedTime": "Потраченное времени", "RemainingTime": "Осталось времени", "TimeSpendReports": "Отчеты по времени", + "TimeReports": "Отчёты по времени", + "TeamTimeReport": "Отчёт по времени команды", + "TimeReportsFilterTask": "Фильтр по задаче", + "TimeReportsFilterTaskSearch": "Поиск задачи по названию", + "TimeReportsAddTask": "Выбрать задачу", + "TimeReportsClearTasks": "Сбросить задачи", + "TimeReportsFilterReporter": "Кто списывал время", + "TimeReportsFilterAssignees": "Фильтр задач по исполнителю", + "TimeReportsFilterTracker": "Фильтр задач по таймтрекеру", + "TimeReportsDates": "Даты", + "TimeReportsWithRecordedTime": "С учтённым временем", + "TimeReportsAllTasks": "Все задачи", + "TimeReportsColumnAssignees": "Выбранные исполнители в фильтре", + "TimeReportsTitle": "Затраты времени ({count} задач)", + "TimeReportsEmpty": "Нет задач по выбранным фильтрам", + "TimeReportsOfMembers": "{selected} человек из {total}", + "TimeReportsPrevPage": "Предыдущая страница", + "TimeReportsNextPage": "Следующая страница", + "TimeReportsExportExcel": "Выгрузить в Excel", + "TimeReportsExportColIssueId": "Номер задачи", + "TimeReportsExportColIssueTitle": "Название задачи", + "TimeReportsExportColParentId": "Номер родительской задачи", + "TimeReportsExportColParentTitle": "Название родительской задачи", + "TimeReportsExportColReporter": "Исполнитель (кто списывал время)", + "TimeReportsExportColHours": "Списано часов", + "TimeReportsExportTotal": "Итого", "TimeSpendReport": "Время", "TimeSpendReportAdd": "Добавить затраченное время", "TimeSpendReportDate": "Дата", diff --git a/plugins/tracker-resources/src/components/issues/timereport/IssueTaskFilterPopup.svelte b/plugins/tracker-resources/src/components/issues/timereport/IssueTaskFilterPopup.svelte new file mode 100644 index 00000000000..d19cc4761cf --- /dev/null +++ b/plugins/tracker-resources/src/components/issues/timereport/IssueTaskFilterPopup.svelte @@ -0,0 +1,64 @@ + + + + dispatch('close', e.detail)} +> + +
+ {#if issue?.$lookup?.status} +
+ +
+ {/if} + {issue.identifier} + {issue.title} +
+
+
diff --git a/plugins/tracker-resources/src/components/issues/timereport/ProjectTimeReports.svelte b/plugins/tracker-resources/src/components/issues/timereport/ProjectTimeReports.svelte new file mode 100644 index 00000000000..373787567f5 --- /dev/null +++ b/plugins/tracker-resources/src/components/issues/timereport/ProjectTimeReports.svelte @@ -0,0 +1,36 @@ + + + +
+ +
+ + diff --git a/plugins/tracker-resources/src/components/issues/timereport/TimeReportsView.svelte b/plugins/tracker-resources/src/components/issues/timereport/TimeReportsView.svelte new file mode 100644 index 00000000000..13027605b3b --- /dev/null +++ b/plugins/tracker-resources/src/components/issues/timereport/TimeReportsView.svelte @@ -0,0 +1,697 @@ + + + +
+
+
+ {#if project} + + + {/if} + { + assigneeFilter = refs + loading = true + page = 0 + }} + /> +
+
+ { + page = 0 + }} + /> +
+
+
+ { + reporterFilter = refs + page = 0 + }} + /> +
+
+ +
+ + + +
+
+
+
+ +
+ { + page = 0 + }} + /> +
+ {#if taskFilter.length > 0} +
+ {#each taskFilter as task (task._id)} +
+ + +
+ {/each} +
+ {/if} + +
+ + +
+
+
+ + {#if loading} + + {:else if filteredRows.length === 0} +
+ {:else} + + + + + + + + {#each orderedColumnEmployees as emp, colIdx (emp._id)} + + {/each} + + + + {#each pageRows as row (row.issue._id)} + + + + + {#each columnEmployeeRefs as empRef (empRef)} + + {/each} + + {/each} + +
+
+ + {formatSpentTime(colTotals[colIdx], true)} +
+
+ + + + {formatSpentTime(row.total, true)}{formatSpentTime(row.cells.get(empRef) ?? 0, true)}
+
+ + {#if totalPages > 1} + + {/if} + {/if} +
+ + diff --git a/plugins/tracker-resources/src/components/issues/timereport/timeReportsExport.ts b/plugins/tracker-resources/src/components/issues/timereport/timeReportsExport.ts new file mode 100644 index 00000000000..c4cbff1c63f --- /dev/null +++ b/plugins/tracker-resources/src/components/issues/timereport/timeReportsExport.ts @@ -0,0 +1,121 @@ +// +// Copyright © 2022 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { type Issue } from '@hcengineering/tracker' + +/** Semicolon — opens correctly in Excel (RU locale). */ +const EXCEL_SEPARATOR = ';' + +export interface TimeReportExportHeaders { + issueId: string + issueTitle: string + parentId: string + parentTitle: string + hours: string + total: string +} + +/** Decimal hours for Excel RU, e.g. 4.5 h → "4,5". */ +export function formatHoursDecimal (hours: number): string { + if (hours <= 0) return '0' + const rounded = Math.round(hours * 100) / 100 + const normalized = Number.isInteger(rounded) ? rounded.toString() : rounded.toFixed(2).replace(/\.?0+$/, '') + return normalized.replace('.', ',') +} + +function escapeCsvCell (value: string): string { + const data = value.replace(/(\r\n|\n|\r)/gm, ' ').replace(/"/g, '""') + return `"${data}"` +} + +function buildCsvRow (cells: string[], separator: string): string { + return cells.map(escapeCsvCell).join(separator) +} + +function getParentInfo (issue: Issue): { parentId: string, parentTitle: string } { + const parent = issue.parents?.[0] + if (parent === undefined) return { parentId: '', parentTitle: '' } + return { + parentId: parent.identifier ?? '', + parentTitle: parent.parentTitle ?? '' + } +} + +export function buildTimeReportsCsv (params: { + projectName: string + dateFrom: number + dateTo: number + headers: TimeReportExportHeaders + rows: Array<{ + issue: Issue + hours: number + }> +}): string { + const { projectName, dateFrom, dateTo, headers, rows } = params + const lines: string[] = [] + + lines.push(buildCsvRow([projectName], EXCEL_SEPARATOR)) + lines.push( + buildCsvRow( + [`${new Date(dateFrom).toLocaleDateString()} — ${new Date(dateTo).toLocaleDateString()}`], + EXCEL_SEPARATOR + ) + ) + lines.push('') + lines.push( + buildCsvRow( + [headers.issueId, headers.issueTitle, headers.parentId, headers.parentTitle, headers.hours], + EXCEL_SEPARATOR + ) + ) + + for (const row of rows) { + const parent = getParentInfo(row.issue) + lines.push( + buildCsvRow( + [ + row.issue.identifier ?? '', + row.issue.title ?? '', + parent.parentId, + parent.parentTitle, + formatHoursDecimal(row.hours) + ], + EXCEL_SEPARATOR + ) + ) + } + + const totalHours = rows.reduce((sum, row) => sum + row.hours, 0) + lines.push('') + lines.push( + buildCsvRow(['', '', '', headers.total, formatHoursDecimal(totalHours)], EXCEL_SEPARATOR) + ) + + return lines.join('\n') +} + +export function downloadTimeReportsCsv (csv: string, filename: string): void { + const link = document.createElement('a') + link.style.display = 'none' + link.setAttribute('href', 'data:text/csv;charset=utf-8,%EF%BB%BF' + encodeURIComponent(csv)) + link.setAttribute('download', filename) + document.body.appendChild(link) + link.click() + document.body.removeChild(link) +} + +export function sanitizeFilenamePart (value: string): string { + return value.replace(/[^\w\u0400-\u04FF.-]+/g, '_').slice(0, 80) +} diff --git a/plugins/tracker-resources/src/index.ts b/plugins/tracker-resources/src/index.ts index 071a13ac73f..bc536131e89 100644 --- a/plugins/tracker-resources/src/index.ts +++ b/plugins/tracker-resources/src/index.ts @@ -106,6 +106,7 @@ import { formatIssueValue } from './issueTableFormatter' import MilestoneEditor from './components/milestones/MilestoneEditor.svelte' import MilestonePresenter from './components/milestones/MilestonePresenter.svelte' import Milestones from './components/milestones/Milestones.svelte' +import ProjectTimeReports from './components/issues/timereport/ProjectTimeReports.svelte' import MilestoneSelector from './components/milestones/MilestoneSelector.svelte' import MilestoneStatusEditor from './components/milestones/MilestoneStatusEditor.svelte' import MilestoneStatusIcon from './components/milestones/MilestoneStatusIcon.svelte' @@ -440,6 +441,7 @@ export default async (): Promise => ({ CreateIssue, CreateIssueTemplate, Milestones, + ProjectTimeReports, MilestonePresenter, EditMilestone, MilestoneStatusPresenter, diff --git a/plugins/tracker-resources/src/plugin.ts b/plugins/tracker-resources/src/plugin.ts index eed088c59d5..21add80eca9 100644 --- a/plugins/tracker-resources/src/plugin.ts +++ b/plugins/tracker-resources/src/plugin.ts @@ -270,6 +270,32 @@ export default mergeIds(trackerId, tracker, { TimeSpendReportDate: '' as IntlString, TimeSpendReportValue: '' as IntlString, TimeSpendReportDescription: '' as IntlString, + TimeReports: '' as IntlString, + TeamTimeReport: '' as IntlString, + TimeReportsFilterTask: '' as IntlString, + TimeReportsFilterTaskSearch: '' as IntlString, + TimeReportsAddTask: '' as IntlString, + TimeReportsClearTasks: '' as IntlString, + TimeReportsFilterReporter: '' as IntlString, + TimeReportsFilterAssignees: '' as IntlString, + TimeReportsFilterTracker: '' as IntlString, + TimeReportsDates: '' as IntlString, + TimeReportsWithRecordedTime: '' as IntlString, + TimeReportsAllTasks: '' as IntlString, + TimeReportsColumnAssignees: '' as IntlString, + TimeReportsTitle: '' as IntlString, + TimeReportsEmpty: '' as IntlString, + TimeReportsOfMembers: '' as IntlString, + TimeReportsPrevPage: '' as IntlString, + TimeReportsNextPage: '' as IntlString, + TimeReportsExportExcel: '' as IntlString, + TimeReportsExportColIssueId: '' as IntlString, + TimeReportsExportColIssueTitle: '' as IntlString, + TimeReportsExportColParentId: '' as IntlString, + TimeReportsExportColParentTitle: '' as IntlString, + TimeReportsExportColReporter: '' as IntlString, + TimeReportsExportColHours: '' as IntlString, + TimeReportsExportTotal: '' as IntlString, TimeSpendDays: '' as IntlString, TimeSpendHours: '' as IntlString, TimeSpendMinutes: '' as IntlString, @@ -352,6 +378,7 @@ export default mergeIds(trackerId, tracker, { RelationsPopup: '' as AnyComponent, MilestoneRefPresenter: '' as AnyComponent, Milestones: '' as AnyComponent, + ProjectTimeReports: '' as AnyComponent, MilestonePresenter: '' as AnyComponent, MilestoneStatusPresenter: '' as AnyComponent, MilestoneStatusEditor: '' as AnyComponent, diff --git a/plugins/workbench-resources/src/utils.ts b/plugins/workbench-resources/src/utils.ts index a2e5b067356..2c240035532 100644 --- a/plugins/workbench-resources/src/utils.ts +++ b/plugins/workbench-resources/src/utils.ts @@ -39,7 +39,9 @@ import { setMetadataLocalStorage } from '@hcengineering/ui' import view from '@hcengineering/view' -import workbench, { type Application, type NavigatorModel } from '@hcengineering/workbench' +import { type Asset, type IntlString } from '@hcengineering/platform' +import { type AnyComponent } from '@hcengineering/ui/src/types' +import workbench, { type Application, type NavigatorModel, type SpecialNavModel } from '@hcengineering/workbench' import { derived, writable } from 'svelte/store' export const workspaceCreating = writable(undefined) @@ -173,6 +175,30 @@ export const currentWorkspaceStore = derived( } ) +// Tracker team time report: static model has the tab, but old workspaces keep an +// Application doc without it until a model tx applies (often skipped on upgrade). +const TRACKER_TIME_REPORTS_TAB: SpecialNavModel = { + id: 'timeReports', + label: 'tracker:string:TeamTimeReport' as IntlString, + icon: 'tracker:icon:TimeReport' as Asset, + component: 'tracker:component:ProjectTimeReports' as AnyComponent +} + +function ensureTrackerTimeReportsTab (model: NavigatorModel | undefined): NavigatorModel | undefined { + if (model === undefined) return model + const spaces = model.spaces?.map((space) => { + if (space.id !== 'projects') return space + const specials = space.specials ?? [] + if (specials.some((s) => s.id === TRACKER_TIME_REPORTS_TAB.id)) return space + const next = [...specials] + const templatesIdx = next.findIndex((s) => s.id === 'templates') + if (templatesIdx >= 0) next.splice(templatesIdx, 0, TRACKER_TIME_REPORTS_TAB) + else next.push(TRACKER_TIME_REPORTS_TAB) + return { ...space, specials: next } + }) + return spaces !== undefined ? { ...model, spaces } : model +} + /** * @public */ @@ -204,6 +230,9 @@ export async function buildNavModel ( } } } + if (currentApplication?.alias === 'tracker') { + newNavModel = ensureTrackerTimeReportsTab(newNavModel) + } return newNavModel }