@operato/gantt 8.0.0-beta.0 → 8.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ox-gantt.ts DELETED
@@ -1,535 +0,0 @@
1
- import { LitElement, html, css, PropertyValues, PropertyValueMap, nothing, TemplateResult } from 'lit'
2
- import * as d3 from 'd3'
3
- import { customElement, property, query, state } from 'lit/decorators.js'
4
- import { styles as MDTypeScaleStyles } from '@material/web/typography/md-typescale-styles'
5
- import { i18next, localize } from '@operato/i18n'
6
- import { ScrollbarStyles } from '@operato/styles'
7
-
8
- import './ox-gantt-chart.js'
9
- import './ox-resource-chart.js'
10
- import './ox-gantt-resize-splitter.js'
11
- import { OxGanttChart } from './ox-gantt-chart.js'
12
- import { OxResourceChart } from './ox-resource-chart.js'
13
- import { ColumnConfig, Task, TimeScale, Resource } from './types.js'
14
-
15
- @customElement('ox-gantt')
16
- export class OxGantt extends localize(i18next)(LitElement) {
17
- static styles = [
18
- MDTypeScaleStyles,
19
- ScrollbarStyles,
20
- css`
21
- :host {
22
- display: block;
23
- font-family: Arial, sans-serif;
24
- text-wrap: nowrap;
25
- position: relative;
26
- user-select: none;
27
- }
28
-
29
- .container {
30
- flex: 1;
31
-
32
- display: flex;
33
- flex-direction: column;
34
- width: 100%;
35
- height: 100%;
36
- box-sizing: border-box;
37
- }
38
-
39
- .resource-container {
40
- height: 300px;
41
-
42
- display: flex;
43
- overflow: hidden;
44
- box-sizing: border-box;
45
- }
46
-
47
- .resource-table-container {
48
- padding: 0;
49
- overflow: auto;
50
- overflow-y: overlay;
51
- box-sizing: border-box;
52
- border: 1px solid var(--ox-gantt-border-color, var(--md-sys-color-outline));
53
- overscroll-behavior: none; /* diabled rubber-band effect */
54
- }
55
-
56
- .resource-chart-container {
57
- flex: 1;
58
- padding: 0;
59
- box-sizing: border-box;
60
- border: 1px solid var(--ox-gantt-border-color, var(--md-sys-color-outline));
61
- overscroll-behavior: none;
62
- }
63
-
64
- .task-container {
65
- flex: 1;
66
-
67
- display: flex;
68
- overflow: hidden;
69
- box-sizing: border-box;
70
- user-select: none;
71
- overscroll-behavior: none;
72
- }
73
-
74
- .task-table-container {
75
- padding: 0;
76
- overflow: auto;
77
- overflow-y: overlay;
78
- box-sizing: border-box;
79
- border: 1px solid var(--ox-gantt-border-color, var(--md-sys-color-outline));
80
- overscroll-behavior: none; /* diabled rubber-band effect */
81
- }
82
-
83
- .task-chart-container {
84
- flex: 1;
85
- padding: 0;
86
- box-sizing: border-box;
87
- border: 1px solid var(--ox-gantt-border-color, var(--md-sys-color-outline));
88
- }
89
-
90
- table {
91
- width: 100%;
92
- border-collapse: collapse;
93
- border: 1px solid var(--ox-gantt-border-color, var(--md-sys-color-outline));
94
- margin: -1px;
95
- margin-right: 0;
96
- }
97
-
98
- thead th {
99
- position: sticky;
100
- top: -1px;
101
- z-index: 1;
102
- color: var(--ox-gantt-header-color, var(--md-sys-color-on-secondary-container));
103
- background-color: var(--ox-gantt-header-background-color, var(--md-sys-color-secondary-container));
104
- border-color: var(--ox-gantt-header-border-color, var(--md-sys-color-outline));
105
- text-transform: capitalize;
106
- }
107
-
108
- th,
109
- td {
110
- border: 1px solid var(--ox-gantt-border-color, var(--md-sys-color-outline));
111
- padding: 4px;
112
- text-align: left;
113
- white-space: nowrap;
114
- overflow: hidden;
115
- text-overflow: ellipsis;
116
- }
117
-
118
- th {
119
- height: 40px;
120
- box-sizing: border-box;
121
- text-align: center;
122
- }
123
-
124
- td {
125
- height: 30px;
126
- box-sizing: border-box;
127
- }
128
-
129
- tr.selected {
130
- color: var(--ox-gantt-selected-color, var(--md-sys-color-on-surface));
131
- background-color: var(--ox-gantt-selected-background-color, var(--md-sys-color-surface-variant));
132
- }
133
-
134
- .resource-table-container th {
135
- height: 30px;
136
- }
137
-
138
- svg.toggle-icon[collapsed] {
139
- transform: rotate(-90deg);
140
- }
141
-
142
- span[group] {
143
- cursor: pointer;
144
- user-select: none;
145
- }
146
- `
147
- ]
148
-
149
- @property({ type: Array }) tasks: Task[] = []
150
- @property({ type: Array }) resources: Resource[] = []
151
- @property({ type: String, attribute: 'from-date' })
152
- fromDate: string = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0] /* beginning of this year */
153
- @property({ type: String, attribute: 'to-date' })
154
- toDate: string = new Date(new Date().getFullYear(), 11, 31).toISOString().split('T')[0] /* end of this year */
155
- @property({ type: Boolean, attribute: 'extend-grid-lines' }) extendGridLines: boolean = false
156
- @property({ type: String, attribute: 'time-scale' }) timeScale: TimeScale = 'week-day'
157
- @property({ type: Object }) colorProvider: (task: Task) => string = (task: Task) => {
158
- const { style } = task
159
-
160
- return style || 'gray'
161
- }
162
- @property({ type: Object }) columnConfigProvider: () => ColumnConfig[] = function () {
163
- return [
164
- { name: 'id', label: i18next.t('label.gantt-task-id') || 'ID', visible: true, width: 50, order: 1 },
165
- { name: 'title', label: i18next.t('label.gantt-task-title') || 'title', visible: true, width: 150, order: 2 },
166
- { name: 'type', label: i18next.t('label.gantt-task-type') || 'type', visible: true, width: 100, order: 3 },
167
- {
168
- name: 'startDate',
169
- label: i18next.t('label.gantt-task-start-date') || 'start date',
170
- visible: true,
171
- width: 100,
172
- order: 4
173
- },
174
- {
175
- name: 'endDate',
176
- label: i18next.t('label.gantt-task-end-date') || 'end date',
177
- visible: true,
178
- width: 100,
179
- order: 5
180
- },
181
- {
182
- name: 'duration',
183
- label: i18next.t('label.gantt-task-duration') || 'duration',
184
- visible: true,
185
- width: 30,
186
- order: 6
187
- },
188
- { name: 'tags', label: i18next.t('label.gantt-task-tags') || 'tags', visible: true, width: 100, order: 7 },
189
- {
190
- name: 'progress',
191
- label: i18next.t('label.gantt-task-progress') || 'progress',
192
- visible: true,
193
- width: 100,
194
- order: 8
195
- },
196
- {
197
- name: 'dependsOn',
198
- label: i18next.t('label.gantt-task-previous') || 'previous',
199
- visible: true,
200
- width: 100,
201
- order: 9
202
- },
203
- {
204
- name: 'resources',
205
- label: i18next.t('label.gantt-task-resources') || 'resources',
206
- visible: true,
207
- width: 100,
208
- order: 10
209
- }
210
- ]
211
- }
212
-
213
- @state() selectedTaskId?: string
214
-
215
- @query('.task-container') taskContainer!: HTMLDivElement
216
- @query('.task-table-container') taskTableContainer!: HTMLDivElement
217
- @query('.resource-container') resourceContainer!: HTMLDivElement
218
- @query('.resource-table-container') resourceTableContainer!: HTMLDivElement
219
- @query('.task-table') taskTable!: HTMLTableElement
220
- @query('ox-gantt-chart') ganttChart!: OxGanttChart
221
- @query('ox-resource-chart') resourceChart!: OxResourceChart
222
-
223
- private _startWidth: number = 0
224
- private _startHeight: number = 0
225
- private _isDragging: boolean = false
226
-
227
- render() {
228
- return html`
229
- <div class="container md-typescale-body-medium">
230
- <div class="task-container">
231
- <div class="task-table-container" @scroll=${this.syncScroll}>${this.renderTaskTable()}</div>
232
- <ox-gantt-resize-splitter
233
- vertical
234
- @splitter-dragstart=${(e: CustomEvent) => this.resizeHorizontalStart(e)}
235
- @splitter-drag=${(e: CustomEvent) => this.resizeHorizontalDrag(e)}
236
- @splitter-dragend=${(e: CustomEvent) => this.resizeHorizontalEnd(e)}
237
- ></ox-gantt-resize-splitter>
238
- <ox-gantt-chart
239
- class="task-chart-container"
240
- @scroll=${this.syncScroll}
241
- .fromDate=${this.fromDate}
242
- .toDate=${this.toDate}
243
- .tasks=${this.tasks}
244
- .selectedTaskId=${this.selectedTaskId}
245
- .axisScale=${this.timeScale}
246
- .colorProvider=${this.colorProvider}
247
- ?extend-grid-lines=${this.extendGridLines}
248
- @task-clicked=${this.handleTaskClicked}
249
- @timescale-changed=${(e: CustomEvent) => this.syncTimeScale(e)}
250
- ></ox-gantt-chart>
251
- </div>
252
- <ox-gantt-resize-splitter
253
- horizontal
254
- @splitter-dragstart=${(e: CustomEvent) => this.resizeVerticalStart(e)}
255
- @splitter-drag=${(e: CustomEvent) => this.resizeVerticalDrag(e)}
256
- @splitter-dragend=${(e: CustomEvent) => this.resizeVerticalEnd(e)}
257
- ></ox-gantt-resize-splitter>
258
- <div class="resource-container">
259
- <div class="resource-table-container" @scroll=${this.syncScroll}>
260
- <table>
261
- <thead>
262
- <tr>
263
- <th style="width: 100px">${i18next.t('label.gantt-resource-type')}</th>
264
- <th>${i18next.t('label.gantt-resource-allocated')}</th>
265
- </tr>
266
- </thead>
267
- <tbody>
268
- ${this.resources.map(resource => this.renderResourceRow(resource))}
269
- </tbody>
270
- </table>
271
- </div>
272
- <ox-gantt-resize-splitter
273
- vertical
274
- @splitter-dragstart=${(e: CustomEvent) => this.resizeHorizontalStart(e)}
275
- @splitter-drag=${(e: CustomEvent) => this.resizeHorizontalDrag(e)}
276
- @splitter-dragend=${(e: CustomEvent) => this.resizeHorizontalEnd(e)}
277
- ></ox-gantt-resize-splitter>
278
- <ox-resource-chart
279
- class="resource-chart-container"
280
- .fromDate=${this.fromDate}
281
- .toDate=${this.toDate}
282
- .tasks=${this.tasks}
283
- .resources=${this.resources}
284
- .axisScale=${this.timeScale}
285
- @scroll=${this.syncScroll}
286
- ></ox-resource-chart>
287
- </div>
288
- </div>
289
- `
290
- }
291
-
292
- renderTaskTable() {
293
- const columnConfig = this.columnConfigProvider()
294
- if (!columnConfig) {
295
- return
296
- }
297
-
298
- // Sort columns by the configured order
299
- const sortedColumns = [...columnConfig].sort((a, b) => a.order - b.order)
300
-
301
- return html`
302
- <table class="task-table">
303
- <thead>
304
- <tr>
305
- ${sortedColumns.map(col =>
306
- col.visible ? html`<th style="width: ${col.width}px">${col.label}</th>` : nothing
307
- )}
308
- </tr>
309
- </thead>
310
- <tbody>
311
- ${this.renderTaskRows(this.tasks, 0, columnConfig)}
312
- </tbody>
313
- </table>
314
- `
315
- }
316
-
317
- renderTaskRows(tasks: Task[], level: number = 0, columnConfig: ColumnConfig[]): TemplateResult[] {
318
- return (tasks || []).flatMap(task => {
319
- if (task.collapsed) {
320
- return [this.renderTaskRow(task, level, columnConfig)]
321
- } else {
322
- return [
323
- this.renderTaskRow(task, level, columnConfig),
324
- ...this.renderTaskRows(task.children || [], level + 1, columnConfig)
325
- ]
326
- }
327
- })
328
- }
329
-
330
- renderTaskRow(task: Task, level: number, columnConfig: ColumnConfig[]) {
331
- const isGroupTask = task.children && task.children.length > 0
332
- const isCollapsed = !!task.collapsed
333
- const paddingLeft = level * 16 + 4 // 트리 구조를 위한 패딩
334
-
335
- return html`
336
- <tr class=${this.selectedTaskId === task.id ? 'selected' : ''} @click=${() => this.selectTask(task.id)}>
337
- ${columnConfig.map(col =>
338
- col.visible
339
- ? html`
340
- <td class="task-row" style="padding-left: ${col.name === 'title' ? paddingLeft : 4}px">
341
- ${col.name === 'title'
342
- ? html`
343
- ${isGroupTask
344
- ? html`
345
- <span class="toggle-icon" @click=${(e: Event) => this.toggleCollapse(e, task)} group>
346
- ${isGroupTask
347
- ? html`
348
- <svg
349
- class="toggle-icon"
350
- width="10"
351
- height="10"
352
- viewBox="0 0 10 10"
353
- xmlns="http://www.w3.org/2000/svg"
354
- ?collapsed=${isCollapsed}
355
- >
356
- <polygon
357
- points="0,1 10,1 5,9"
358
- fill="var(--md-sys-color-on-primary-container)"
359
- opacity="0.5"
360
- />
361
- </svg>
362
- `
363
- : nothing}
364
- </span>
365
- `
366
- : nothing}
367
- ${this.getTaskFieldValue(task, col.name)}
368
- `
369
- : this.getTaskFieldValue(task, col.name)}
370
- </td>
371
- `
372
- : nothing
373
- )}
374
- </tr>
375
- `
376
- }
377
-
378
- toggleCollapse(e: Event, task: Task) {
379
- e.stopPropagation() // 클릭 이벤트 전파 방지
380
-
381
- task.collapsed = !task.collapsed
382
- this.requestUpdate()
383
-
384
- this.ganttChart.tasks = [...this.tasks]
385
- }
386
-
387
- renderResourceRow(resource: Resource) {
388
- return html`
389
- <tr>
390
- <td>${resource.type}</td>
391
- <td>${resource.allocated}</td>
392
- </tr>
393
- `
394
- }
395
-
396
- getTaskFieldValue(task: Task, field: string) {
397
- switch (field) {
398
- case 'id':
399
- return task.id
400
- case 'title':
401
- return task.title
402
- case 'type':
403
- return task.type
404
- case 'startDate':
405
- return new Date(task.startDate).toISOString().split('T')[0]
406
- case 'endDate':
407
- return new Date(task.endDate).toISOString().split('T')[0]
408
- case 'duration':
409
- return task.duration || 0
410
- case 'tags':
411
- return task.tags?.join(', ') || ''
412
- case 'progress':
413
- return task.progress || ''
414
- case 'dependsOn':
415
- return task.dependsOn || ''
416
- case 'resources':
417
- return task.resources?.map(res => `${res.type}: ${res.allocated}`).join(', ') || ''
418
- default:
419
- return ''
420
- }
421
- }
422
-
423
- selectTask(taskId: string) {
424
- this.selectedTaskId = taskId
425
- }
426
-
427
- handleTaskClicked(event: CustomEvent) {
428
- this.selectedTaskId = event.detail.taskId
429
- }
430
-
431
- protected firstUpdated(_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>): void {
432
- this.taskTableContainer.style.width = '254px'
433
- this.resourceTableContainer.style.width = '254px'
434
- }
435
-
436
- updated(changedProperties: PropertyValues<this>) {
437
- if (changedProperties.has('tasks')) {
438
- this.resources = this.aggregateResources(this.tasks)
439
- }
440
- }
441
-
442
- aggregateResources(tasks: Task[]): Resource[] {
443
- const resourceMap: { [key: string]: number } = {}
444
-
445
- const traverseTasks = (tasks: Task[]) => {
446
- tasks.forEach(task => {
447
- if (task.resources) {
448
- const startDate = new Date(task.startDate)
449
- const endDate = new Date(task.endDate)
450
- const durationDays = d3.timeDay.count(startDate, d3.timeDay.offset(endDate, 1))
451
-
452
- task.resources.forEach(resource => {
453
- const totalAllocation = resource.allocated * durationDays
454
- if (!resourceMap[resource.type]) {
455
- resourceMap[resource.type] = 0
456
- }
457
- resourceMap[resource.type] += totalAllocation
458
- })
459
- }
460
-
461
- if (task.children) {
462
- traverseTasks(task.children)
463
- }
464
- })
465
- }
466
-
467
- traverseTasks(tasks)
468
-
469
- return Object.keys(resourceMap).map(type => ({
470
- type,
471
- allocated: resourceMap[type]
472
- }))
473
- }
474
-
475
- syncScroll(e: Event) {
476
- const target = e.target as HTMLElement
477
-
478
- if (target === this.taskTableContainer) {
479
- const newScrollTop = this.taskTableContainer.scrollTop
480
- this.ganttChart.setVerticalOffset(newScrollTop)
481
- } else if (target === this.ganttChart) {
482
- const newScrollTop = this.ganttChart.getVerticalOffset()
483
- this.taskTableContainer.scrollTop = newScrollTop
484
-
485
- const newScrollLeft = this.ganttChart.getHorizontalOffset()
486
- this.resourceChart.setHorizontalOffset(newScrollLeft)
487
- } else if (target === this.resourceChart) {
488
- const newScrollTop = this.resourceChart.getVerticalOffset()
489
- this.resourceTableContainer.scrollTop = newScrollTop
490
-
491
- const newScrollLeft = this.resourceChart.getHorizontalOffset()
492
- this.ganttChart.setHorizontalOffset(newScrollLeft)
493
- } else if (target === this.resourceTableContainer) {
494
- const newScrollTop = this.resourceTableContainer.scrollTop
495
- this.resourceChart.setVerticalOffset(newScrollTop)
496
- }
497
- }
498
-
499
- syncTimeScale(e: CustomEvent) {
500
- const detail = e.detail
501
- this.resourceChart.syncTimeScale(detail)
502
- }
503
-
504
- resizeHorizontalStart(e: CustomEvent) {
505
- this._startWidth = ((e.target as HTMLElement)?.previousElementSibling as HTMLElement)?.offsetWidth
506
- this._isDragging = true
507
- }
508
-
509
- resizeHorizontalDrag(e: CustomEvent) {
510
- if (!this._isDragging) return
511
- const delta = e.detail
512
- this.taskTableContainer.style.width = `${this._startWidth + delta.x}px`
513
- this.resourceTableContainer.style.width = `${this._startWidth + delta.x}px`
514
- }
515
-
516
- resizeHorizontalEnd(e: CustomEvent) {
517
- this._isDragging = false
518
- }
519
-
520
- resizeVerticalStart(e: CustomEvent) {
521
- this._startHeight = ((e.target as HTMLElement)?.nextElementSibling as HTMLElement)?.offsetHeight
522
- this._isDragging = true
523
- }
524
-
525
- resizeVerticalDrag(e: CustomEvent) {
526
- if (!this._isDragging) return
527
- const delta = e.detail
528
-
529
- this.resourceContainer.style.height = `${this._startHeight - delta.y}px`
530
- }
531
-
532
- resizeVerticalEnd(e: CustomEvent) {
533
- this._isDragging = false
534
- }
535
- }
@@ -1,147 +0,0 @@
1
- import { LitElement, html, css } from 'lit'
2
- import { customElement, property, query } from 'lit/decorators.js'
3
- import { ResourceChartController } from './resource-chart-controller'
4
- import { Task, Resource, TimeScale } from './types'
5
-
6
- import { styles as MDTypeScaleStyles } from '@material/web/typography/md-typescale-styles'
7
- import { ScrollbarStyles } from '@operato/styles'
8
-
9
- @customElement('ox-resource-chart')
10
- export class OxResourceChart extends LitElement {
11
- static styles = [
12
- MDTypeScaleStyles,
13
- ScrollbarStyles,
14
- css`
15
- :host {
16
- display: flex;
17
- overflow: hidden;
18
- position: relative;
19
- user-select: none;
20
- }
21
-
22
- div[chart-container] {
23
- flex-direction: column;
24
- overflow-x: auto;
25
- overflow-y: hidden;
26
- overscroll-behavior: none;
27
- }
28
-
29
- svg[header] {
30
- display: block;
31
- min-width: 100%;
32
- box-sizing: border-box;
33
- border-bottom: 1px solid var(--ox-gantt-header-border-color, var(--md-sys-color-outline));
34
- height: 30px;
35
- background-color: var(--ox-gantt-header-background-color, var(--md-sys-color-secondary-container));
36
- color: var(--ox-gantt-header-color, var(--md-sys-color-on-secondary-container));
37
- position: sticky;
38
- top: 0;
39
- z-index: 1;
40
- }
41
-
42
- svg[body] {
43
- display: block;
44
- min-width: 100%;
45
- }
46
-
47
- .grid line {
48
- stroke: #e0e0e0;
49
- }
50
-
51
- .grid path {
52
- stroke-width: 0;
53
- }
54
- `
55
- ]
56
-
57
- @property({ type: String }) fromDate: string = new Date(new Date().getFullYear(), 0, 1).toISOString().split('T')[0]
58
- @property({ type: String }) toDate: string = new Date(new Date().getFullYear(), 11, 31).toISOString().split('T')[0]
59
- @property({ type: String }) axisScale: TimeScale = 'week-day'
60
- @property({ type: Array }) resources: Resource[] = []
61
- @property({ type: Array }) tasks: Task[] = []
62
-
63
- @query('[chart-container]') container!: HTMLDivElement
64
- @query('svg[header]') svgHeader!: SVGSVGElement
65
- @query('svg[body]') svgBody!: SVGSVGElement
66
-
67
- private resourceChartController: ResourceChartController
68
-
69
- constructor() {
70
- super()
71
- this.resourceChartController = new ResourceChartController(this, this.fromDate, this.toDate, this.axisScale)
72
- }
73
-
74
- updated(changedProperties: Map<string | number | symbol, unknown>) {
75
- if (changedProperties.has('fromDate') || changedProperties.has('toDate')) {
76
- this.resourceChartController.updateRange(this.fromDate, this.toDate)
77
- }
78
-
79
- if (changedProperties.has('resources')) {
80
- this.resourceChartController.setResources(this.resources)
81
- this.updateChartHeight()
82
- }
83
-
84
- if (changedProperties.has('tasks')) {
85
- this.resourceChartController.setTasks(this.flattenTasks(this.tasks))
86
- this.updateChartHeight()
87
- }
88
-
89
- if (changedProperties.has('axisScale')) {
90
- this.resourceChartController.level = this.axisScale
91
- this.resourceChartController.updateTimeScale()
92
- }
93
-
94
- this.resourceChartController.drawAxes(this.svgHeader)
95
- this.resourceChartController.drawBody(this.svgBody)
96
- }
97
-
98
- updateChartHeight() {
99
- const resourceCount = this.resources.length
100
- const rowHeight = 30 // 리소스 하나당 할당된 높이 (픽셀)
101
- const minHeight = 200 // 최소 높이
102
- const chartHeight = Math.max(resourceCount * rowHeight, minHeight)
103
-
104
- this.svgBody.style.height = `${chartHeight}px`
105
- }
106
-
107
- render() {
108
- return html`
109
- <div
110
- chart-container
111
- @wheel=${(e: WheelEvent) => this.resourceChartController.handleWheelForBody(e, this.container)}
112
- @touchstart=${(e: TouchEvent) => this.resourceChartController.handleTouchStartForBody(e, this.container)}
113
- @touchmove=${(e: TouchEvent) => this.resourceChartController.handleTouchMoveForBody(e, this.container)}
114
- >
115
- <svg header></svg>
116
- <svg body></svg>
117
- </div>
118
- `
119
- }
120
-
121
- syncTimeScale(detail: { baseWidth: number; level: TimeScale }) {
122
- this.resourceChartController.syncTimeScale(detail)
123
- this.requestUpdate()
124
- }
125
-
126
- getHorizontalOffset(): number {
127
- return this.container.scrollLeft
128
- }
129
-
130
- setHorizontalOffset(offset: number) {
131
- this.container.scrollLeft = offset
132
- }
133
-
134
- getVerticalOffset(): number {
135
- return this.container.scrollTop
136
- }
137
-
138
- setVerticalOffset(offset: number) {
139
- this.container.scrollTop = offset
140
- }
141
-
142
- flattenTasks(tasks: Task[], level: number = 0): Task[] {
143
- return tasks.flatMap(task =>
144
- task.collapsed ? [{ ...task, level }] : [{ ...task, level }, ...this.flattenTasks(task.children || [], level + 1)]
145
- )
146
- }
147
- }