@things-factory/kpi 9.0.23 → 9.0.25

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.
Files changed (76) hide show
  1. package/client/pages/kpi/kpi-viz-editor.ts +1 -1
  2. package/client/pages/kpi-category-value/kpi-category-value-list-page.ts +404 -0
  3. package/client/pages/kpi-metric-value/kpi-metric-value-editor-page.ts +763 -0
  4. package/client/pages/kpi-metric-value/kpi-metric-value-list-page.ts +12 -0
  5. package/client/pages/kpi-value/kpi-value-editor-page.ts +774 -0
  6. package/client/pages/kpi-value/kpi-value-list-page.ts +13 -0
  7. package/client/route.ts +16 -0
  8. package/dist-client/pages/kpi/kpi-viz-editor.d.ts +0 -1
  9. package/dist-client/pages/kpi/kpi-viz-editor.js +1 -1
  10. package/dist-client/pages/kpi/kpi-viz-editor.js.map +1 -1
  11. package/dist-client/pages/kpi-category-value/kpi-category-value-list-page.d.ts +63 -0
  12. package/dist-client/pages/kpi-category-value/kpi-category-value-list-page.js +393 -0
  13. package/dist-client/pages/kpi-category-value/kpi-category-value-list-page.js.map +1 -0
  14. package/dist-client/pages/kpi-metric-value/kpi-metric-value-editor-page.d.ts +58 -0
  15. package/dist-client/pages/kpi-metric-value/kpi-metric-value-editor-page.js +736 -0
  16. package/dist-client/pages/kpi-metric-value/kpi-metric-value-editor-page.js.map +1 -0
  17. package/dist-client/pages/kpi-metric-value/kpi-metric-value-list-page.d.ts +1 -0
  18. package/dist-client/pages/kpi-metric-value/kpi-metric-value-list-page.js +11 -0
  19. package/dist-client/pages/kpi-metric-value/kpi-metric-value-list-page.js.map +1 -1
  20. package/dist-client/pages/kpi-value/kpi-value-editor-page.d.ts +55 -0
  21. package/dist-client/pages/kpi-value/kpi-value-editor-page.js +748 -0
  22. package/dist-client/pages/kpi-value/kpi-value-editor-page.js.map +1 -0
  23. package/dist-client/pages/kpi-value/kpi-value-list-page.d.ts +9 -2
  24. package/dist-client/pages/kpi-value/kpi-value-list-page.js +12 -0
  25. package/dist-client/pages/kpi-value/kpi-value-list-page.js.map +1 -1
  26. package/dist-client/route.d.ts +1 -1
  27. package/dist-client/route.js +12 -0
  28. package/dist-client/route.js.map +1 -1
  29. package/dist-client/tsconfig.tsbuildinfo +1 -1
  30. package/dist-server/service/index.d.ts +4 -2
  31. package/dist-server/service/index.js +5 -0
  32. package/dist-server/service/index.js.map +1 -1
  33. package/dist-server/service/kpi-category/kpi-category-mutation.d.ts +2 -3
  34. package/dist-server/service/kpi-category/kpi-category-mutation.js +149 -78
  35. package/dist-server/service/kpi-category/kpi-category-mutation.js.map +1 -1
  36. package/dist-server/service/kpi-category/kpi-category-query.d.ts +1 -9
  37. package/dist-server/service/kpi-category/kpi-category-query.js +3 -165
  38. package/dist-server/service/kpi-category/kpi-category-query.js.map +1 -1
  39. package/dist-server/service/kpi-category-value/index.d.ts +6 -0
  40. package/dist-server/service/kpi-category-value/index.js +10 -0
  41. package/dist-server/service/kpi-category-value/index.js.map +1 -0
  42. package/dist-server/service/kpi-category-value/kpi-category-value-mutation.d.ts +8 -0
  43. package/dist-server/service/kpi-category-value/kpi-category-value-mutation.js +102 -0
  44. package/dist-server/service/kpi-category-value/kpi-category-value-mutation.js.map +1 -0
  45. package/dist-server/service/kpi-category-value/kpi-category-value-query.d.ts +13 -0
  46. package/dist-server/service/kpi-category-value/kpi-category-value-query.js +91 -0
  47. package/dist-server/service/kpi-category-value/kpi-category-value-query.js.map +1 -0
  48. package/dist-server/service/kpi-category-value/kpi-category-value-type.d.ts +19 -0
  49. package/dist-server/service/kpi-category-value/kpi-category-value-type.js +73 -0
  50. package/dist-server/service/kpi-category-value/kpi-category-value-type.js.map +1 -0
  51. package/dist-server/service/kpi-category-value/kpi-category-value.d.ts +19 -0
  52. package/dist-server/service/kpi-category-value/kpi-category-value.js +91 -0
  53. package/dist-server/service/kpi-category-value/kpi-category-value.js.map +1 -0
  54. package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js +20 -0
  55. package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js.map +1 -1
  56. package/dist-server/service/kpi-value/kpi-value-mutation.d.ts +1 -0
  57. package/dist-server/service/kpi-value/kpi-value-mutation.js +60 -0
  58. package/dist-server/service/kpi-value/kpi-value-mutation.js.map +1 -1
  59. package/dist-server/tsconfig.tsbuildinfo +1 -1
  60. package/package.json +5 -5
  61. package/server/service/index.ts +5 -0
  62. package/server/service/kpi-category/kpi-category-mutation.ts +154 -81
  63. package/server/service/kpi-category/kpi-category-query.ts +1 -155
  64. package/server/service/kpi-category-value/index.ts +7 -0
  65. package/server/service/kpi-category-value/kpi-category-value-mutation.ts +88 -0
  66. package/server/service/kpi-category-value/kpi-category-value-query.ts +62 -0
  67. package/server/service/kpi-category-value/kpi-category-value-type.ts +48 -0
  68. package/server/service/kpi-category-value/kpi-category-value.ts +79 -0
  69. package/server/service/kpi-metric-value/kpi-metric-value-mutation.ts +28 -0
  70. package/server/service/kpi-value/kpi-value-mutation.ts +66 -0
  71. package/things-factory.config.js +3 -0
  72. package/translations/en.json +3 -0
  73. package/translations/ja.json +3 -0
  74. package/translations/ko.json +3 -0
  75. package/translations/ms.json +3 -0
  76. package/translations/zh.json +3 -0
@@ -0,0 +1,763 @@
1
+ import '@material/web/icon/icon.js'
2
+ import '@material/web/button/elevated-button.js'
3
+ import '@material/web/textfield/outlined-text-field.js'
4
+
5
+ import { CommonButtonStyles, CommonHeaderStyles, ScrollbarStyles } from '@operato/styles'
6
+ import { PageView, store } from '@operato/shell'
7
+ import { css, html } from 'lit'
8
+ import { customElement, property, state } from 'lit/decorators.js'
9
+ import { ScopedElementsMixin } from '@open-wc/scoped-elements'
10
+ import { client } from '@operato/graphql'
11
+ import { i18next, localize } from '@operato/i18n'
12
+ import { notify } from '@operato/layout'
13
+ import { connect } from 'pwa-helpers/connect-mixin'
14
+ import gql from 'graphql-tag'
15
+
16
+ interface KpiMetricValueData {
17
+ metricId: string
18
+ metricName: string
19
+ periodType: string
20
+ values: { [date: string]: { value: number | null; isDirty?: boolean } }
21
+ }
22
+
23
+ interface EditorCell {
24
+ date: string
25
+ value: number | null
26
+ isEditable: boolean
27
+ isHighlighted: boolean
28
+ }
29
+
30
+ @customElement('kpi-metric-value-editor-page')
31
+ export class KpiMetricValueEditorPage extends connect(store)(localize(i18next)(ScopedElementsMixin(PageView))) {
32
+ static styles = [
33
+ CommonHeaderStyles,
34
+ ScrollbarStyles,
35
+ css`
36
+ :host {
37
+ display: flex;
38
+ flex-direction: column;
39
+ padding: 20px;
40
+ overflow-x: auto;
41
+ }
42
+
43
+ .header {
44
+ display: flex;
45
+ gap: 16px;
46
+ align-items: center;
47
+ margin-bottom: 20px;
48
+ padding: 16px;
49
+ background: var(--md-sys-color-surface-container);
50
+ border-radius: 8px;
51
+ }
52
+
53
+ .controls {
54
+ display: flex;
55
+ gap: 12px;
56
+ align-items: center;
57
+ }
58
+
59
+ .table-container {
60
+ flex: 1;
61
+ overflow: auto;
62
+ border: 1px solid var(--md-sys-color-outline);
63
+ border-radius: 8px;
64
+ }
65
+
66
+ table {
67
+ width: 100%;
68
+ border-collapse: collapse;
69
+ min-width: max-content;
70
+ }
71
+
72
+ th {
73
+ background: var(--md-sys-color-surface-container-low);
74
+ font-weight: 500;
75
+ padding: 8px 12px;
76
+ border: 1px solid var(--md-sys-color-outline-variant);
77
+ min-width: 80px;
78
+ height: 120px;
79
+ vertical-align: middle;
80
+ }
81
+
82
+ td {
83
+ padding: 8px 12px;
84
+ border: 1px solid var(--md-sys-color-outline-variant);
85
+ min-width: 80px;
86
+ height: 60px;
87
+ text-align: right;
88
+ vertical-align: middle;
89
+ }
90
+
91
+ .metric-header {
92
+ position: sticky;
93
+ left: 0;
94
+ top: 0;
95
+ z-index: 3;
96
+ }
97
+
98
+ .metric-name {
99
+ position: sticky;
100
+ left: 0;
101
+ background: var(--md-sys-color-surface);
102
+ font-weight: 500;
103
+ min-width: 200px;
104
+ text-align: left;
105
+ z-index: 2;
106
+ }
107
+
108
+ tr:hover {
109
+ background: var(--md-sys-color-surface-container-high);
110
+ }
111
+
112
+ th.date-header {
113
+ position: sticky;
114
+ top: 0;
115
+ z-index: 2;
116
+ text-align: center;
117
+ font-size: 11px;
118
+ color: var(--md-sys-color-on-surface-variant);
119
+ writing-mode: vertical-rl;
120
+ text-orientation: mixed;
121
+ min-height: 120px;
122
+ display: flex;
123
+ align-items: center;
124
+ justify-content: center;
125
+ }
126
+
127
+ td.editable-cell {
128
+ cursor: pointer;
129
+ background: var(--md-sys-color-primary-container);
130
+ color: var(--md-sys-color-on-primary-container);
131
+ }
132
+
133
+ td.editable-cell:hover {
134
+ background: var(--md-sys-color-primary-container-high);
135
+ }
136
+
137
+ td.highlighted-cell {
138
+ background: var(--md-sys-color-secondary-container);
139
+ color: var(--md-sys-color-on-secondary-container);
140
+ font-weight: 500;
141
+ }
142
+
143
+ td.highlighted-cell:hover {
144
+ background: var(--md-sys-color-secondary-container-high);
145
+ }
146
+
147
+ td.disabled-cell {
148
+ background: var(--md-sys-color-surface-container);
149
+ color: var(--md-sys-color-on-surface-variant);
150
+ cursor: not-allowed;
151
+ }
152
+
153
+ .value-input {
154
+ width: 100%;
155
+ text-align: center;
156
+ border: none;
157
+ background: transparent;
158
+ color: inherit;
159
+ font-size: 12px;
160
+ padding: 2px;
161
+ }
162
+
163
+ .value-input:focus {
164
+ outline: 2px solid var(--md-sys-color-primary);
165
+ border-radius: 4px;
166
+ }
167
+
168
+ .period-type-badge {
169
+ font-size: 10px;
170
+ padding: 2px 6px;
171
+ border-radius: 4px;
172
+ background: var(--md-sys-color-tertiary-container);
173
+ color: var(--md-sys-color-on-tertiary-container);
174
+ margin-left: 8px;
175
+ }
176
+
177
+ .loading {
178
+ display: flex;
179
+ justify-content: center;
180
+ align-items: center;
181
+ height: 200px;
182
+ color: var(--md-sys-color-on-surface-variant);
183
+ }
184
+
185
+ .error {
186
+ color: var(--md-sys-color-error);
187
+ padding: 16px;
188
+ text-align: center;
189
+ }
190
+
191
+ .legend {
192
+ display: flex;
193
+ gap: 16px;
194
+ margin-top: 16px;
195
+ font-size: 12px;
196
+ }
197
+
198
+ .legend-item {
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 4px;
202
+ }
203
+
204
+ .legend-color {
205
+ width: 16px;
206
+ height: 16px;
207
+ border-radius: 2px;
208
+ }
209
+ `
210
+ ]
211
+
212
+ @property({ type: String }) group: string = ''
213
+ @property({ type: String }) startDate: string = ''
214
+ @property({ type: String }) endDate: string = ''
215
+
216
+ @state() private metrics: KpiMetricValueData[] = []
217
+ @state() private dates: string[] = []
218
+ @state() private loading: boolean = false
219
+ @state() private error: string = ''
220
+ @state() private editingCell: { metricId: string; date: string } | null = null
221
+ @state() private _existingValues: any[] = []
222
+
223
+ get context() {
224
+ return {
225
+ title: i18next.t('title.kpi metric value editor'),
226
+ actions: [
227
+ {
228
+ title: i18next.t('button.save'),
229
+ action: this._saveValues.bind(this),
230
+ ...CommonButtonStyles.save
231
+ },
232
+ {
233
+ title: i18next.t('button.cancel'),
234
+ action: this._cancel.bind(this),
235
+ ...CommonButtonStyles.cancel
236
+ }
237
+ ]
238
+ }
239
+ }
240
+
241
+ render() {
242
+ if (this.loading) {
243
+ return html`
244
+ <div class="loading">
245
+ <md-icon>hourglass_empty</md-icon>
246
+ <span>데이터를 불러오는 중...</span>
247
+ </div>
248
+ `
249
+ }
250
+
251
+ if (this.error) {
252
+ return html`
253
+ <div class="error">
254
+ <md-icon>error</md-icon>
255
+ <span>${this.error}</span>
256
+ </div>
257
+ `
258
+ }
259
+
260
+ return html`
261
+ <div class="header">
262
+ <div class="controls">
263
+ <md-outlined-text-field
264
+ label="그룹"
265
+ .value=${this.group}
266
+ @input=${(e: any) => (this.group = e.target.value)}
267
+ style="width: 150px;"
268
+ ></md-outlined-text-field>
269
+
270
+ <md-outlined-text-field
271
+ label="시작일"
272
+ type="date"
273
+ .value=${this.startDate}
274
+ @input=${(e: any) => (this.startDate = e.target.value)}
275
+ style="width: 150px;"
276
+ ></md-outlined-text-field>
277
+
278
+ <md-outlined-text-field
279
+ label="종료일"
280
+ type="date"
281
+ .value=${this.endDate}
282
+ @input=${(e: any) => (this.endDate = e.target.value)}
283
+ style="width: 150px;"
284
+ ></md-outlined-text-field>
285
+
286
+ <md-elevated-button @click=${this._loadData}>
287
+ <md-icon slot="icon">refresh</md-icon>
288
+ 새로고침
289
+ </md-elevated-button>
290
+ </div>
291
+ </div>
292
+
293
+ <div class="table-container">
294
+ <table style="border-collapse: collapse; width: 100%;">
295
+ <thead>
296
+ <tr>
297
+ <th
298
+ style="border: 1px solid #ccc; padding: 8px; background: #f5f5f5; min-width: 200px; left: 0; top: 0;"
299
+ class="metric-header"
300
+ >
301
+ Metric명
302
+ </th>
303
+ ${this.dates.map(
304
+ date => html`
305
+ <th
306
+ style="border: 1px solid #ccc; padding: 8px; background: #f5f5f5; min-width: 80px; position: sticky; top: 0;"
307
+ >
308
+ ${this._formatDate(date)}
309
+ </th>
310
+ `
311
+ )}
312
+ </tr>
313
+ </thead>
314
+ <tbody>
315
+ ${this.metrics.map(
316
+ metric => html`
317
+ <tr>
318
+ <td
319
+ style="border: 1px solid #ccc; padding: 8px; background: #fff; min-width: 200px;"
320
+ class="metric-name"
321
+ >
322
+ ${metric.metricName}
323
+ <span
324
+ style="font-size: 10px; padding: 2px 6px; background: #e0e0e0; border-radius: 4px; margin-left: 8px;"
325
+ >${metric.periodType}</span
326
+ >
327
+ </td>
328
+ ${this.dates.map(
329
+ date => html`
330
+ <td
331
+ style="border: 1px solid #ccc; padding: 8px; background: #e3f2fd; text-align: center; min-width: 80px; cursor: pointer;"
332
+ @click=${() => this._startEdit(metric.metricId, date)}
333
+ >
334
+ ${this._renderCellContent(metric, date)}
335
+ </td>
336
+ `
337
+ )}
338
+ </tr>
339
+ `
340
+ )}
341
+ </tbody>
342
+ </table>
343
+ </div>
344
+
345
+ <div class="legend">
346
+ <div class="legend-item">
347
+ <div class="legend-color" style="background: var(--md-sys-color-primary-container);"></div>
348
+ <span>편집 가능</span>
349
+ </div>
350
+ <div class="legend-item">
351
+ <div class="legend-color" style="background: var(--md-sys-color-secondary-container);"></div>
352
+ <span>하이라이트 (PeriodType에 따라)</span>
353
+ </div>
354
+ <div class="legend-item">
355
+ <div class="legend-color" style="background: var(--md-sys-color-surface-container);"></div>
356
+ <span>편집 불가</span>
357
+ </div>
358
+ </div>
359
+ `
360
+ }
361
+
362
+ private _renderCellContent(metric: KpiMetricValueData, date: string) {
363
+ const isEditing = this.editingCell?.metricId === metric.metricId && this.editingCell?.date === date
364
+
365
+ // periodType에 따라 값을 가져오는 방식 결정
366
+ let value: number | null = null
367
+
368
+ if (metric.periodType === 'DAY') {
369
+ // DAY인 경우 해당 날짜의 값을 사용
370
+ value = metric.values[date]?.value
371
+ } else if (metric.periodType === 'WEEK') {
372
+ // WEEK인 경우 해당 주의 월요일의 값을 사용
373
+ const weekKey = this._getWeekKey(date)
374
+ value = metric.values[weekKey]?.value
375
+ } else if (metric.periodType === 'MONTH') {
376
+ // MONTH인 경우 해당 월의 값을 사용
377
+ const monthKey = this._getMonthKey(date)
378
+ value = metric.values[monthKey]?.value
379
+ } else if (metric.periodType === 'QUARTER') {
380
+ // QUARTER인 경우 해당 분기의 값을 사용
381
+ const quarterKey = this._getQuarterKey(date)
382
+ value = metric.values[quarterKey]?.value
383
+ } else if (metric.periodType === 'YEAR') {
384
+ // YEAR인 경우 해당 연도의 값을 사용
385
+ const yearKey = this._getYearKey(date)
386
+ value = metric.values[yearKey]?.value
387
+ } else {
388
+ // 기본값: DAY와 동일
389
+ value = metric.values[date]?.value
390
+ }
391
+
392
+ if (isEditing) {
393
+ console.log('Rendering input for editing, value:', value)
394
+ return html`
395
+ <input
396
+ type="number"
397
+ step="0.01"
398
+ .value=${(value || '').toString()}
399
+ @blur=${(e: any) => this._finishEdit(metric.metricId, date, parseFloat(e.target.value) || 0)}
400
+ @keydown=${(e: any) => e.key === 'Enter' && e.target.blur()}
401
+ style="width: 100%; text-align: center; border: none; background: transparent;"
402
+ autofocus
403
+ />
404
+ `
405
+ }
406
+
407
+ return html`
408
+ <div style="display: flex; flex-direction: column; gap: 2px;">
409
+ <span style="font-weight: 500; color: ${value !== null && value !== undefined ? 'inherit' : '#999'};">
410
+ ${value !== null && value !== undefined ? value.toLocaleString() : '클릭하여 입력'}
411
+ </span>
412
+ </div>
413
+ `
414
+ }
415
+
416
+ private _formatDate(date: string): string {
417
+ const d = new Date(date)
418
+ return d.toLocaleDateString('ko-KR', { month: 'numeric', day: 'numeric' })
419
+ }
420
+
421
+ private _startEdit(metricId: string, date: string) {
422
+ this.editingCell = { metricId, date }
423
+ }
424
+
425
+ private _finishEdit(metricId: string, date: string, value: number) {
426
+ const metric = this.metrics.find(m => m.metricId === metricId)
427
+ if (metric) {
428
+ // periodType에 따라 저장할 키 결정
429
+ let storageKey = date
430
+
431
+ if (metric.periodType === 'WEEK') {
432
+ storageKey = this._getWeekKey(date)
433
+ } else if (metric.periodType === 'MONTH') {
434
+ storageKey = this._getMonthKey(date)
435
+ } else if (metric.periodType === 'QUARTER') {
436
+ storageKey = this._getQuarterKey(date)
437
+ } else if (metric.periodType === 'YEAR') {
438
+ storageKey = this._getYearKey(date)
439
+ }
440
+
441
+ if (!metric.values[storageKey]) {
442
+ metric.values[storageKey] = { value: 0, isDirty: false }
443
+ }
444
+
445
+ // 값이 변경되었는지 확인
446
+ const originalValue = this._findExistingValue(metricId, storageKey)?.value
447
+ const isChanged = originalValue !== value
448
+
449
+ metric.values[storageKey].value = value
450
+ metric.values[storageKey].isDirty = isChanged
451
+ }
452
+ this.editingCell = null
453
+ }
454
+
455
+ private async _loadData() {
456
+ if (!this.startDate || !this.endDate) {
457
+ this.error = '시작일, 종료일을 모두 입력해주세요.'
458
+ return
459
+ }
460
+
461
+ this.loading = true
462
+ this.error = ''
463
+
464
+ try {
465
+ // KPI Metric 목록 조회
466
+ const metricsResponse = await client.query({
467
+ query: gql`
468
+ query ($filters: [Filter!]) {
469
+ kpiMetrics(filters: $filters) {
470
+ items {
471
+ id
472
+ name
473
+ periodType
474
+ active
475
+ }
476
+ total
477
+ }
478
+ }
479
+ `,
480
+ variables: {
481
+ filters: [{ name: 'active', operator: 'eq', value: true }]
482
+ }
483
+ })
484
+
485
+ // KPI Metric Value 데이터 조회
486
+ const valuesResponse = await client.query({
487
+ query: gql`
488
+ query ($filters: [Filter!]) {
489
+ kpiMetricValues(filters: $filters) {
490
+ items {
491
+ id
492
+ metricId
493
+ valueDate
494
+ value
495
+ group
496
+ }
497
+ total
498
+ }
499
+ }
500
+ `,
501
+ variables: {
502
+ filters: [
503
+ ...(this.group ? [{ name: 'group', operator: 'eq', value: this.group }] : []),
504
+ { name: 'valueDate', operator: 'between', value: [this.startDate, this.endDate] }
505
+ ]
506
+ }
507
+ })
508
+
509
+ // 날짜 배열 생성
510
+ this.dates = this._generateDateArray(this.startDate, this.endDate)
511
+
512
+ // KPI Metric 목록을 기준으로 데이터 구성
513
+ this.metrics = metricsResponse.data.kpiMetrics.items.map((metric: any) => ({
514
+ metricId: metric.id,
515
+ metricName: metric.name,
516
+ periodType: metric.periodType,
517
+ values: {}
518
+ }))
519
+
520
+ // 기존 KPI Metric Value 데이터 저장
521
+ this._existingValues = valuesResponse.data.kpiMetricValues.items
522
+
523
+ // KPI Metric Value 데이터를 해당 Metric에 매핑
524
+ valuesResponse.data.kpiMetricValues.items.forEach((value: any) => {
525
+ const metric = this.metrics.find(m => m.metricId === value.metricId)
526
+ if (metric) {
527
+ metric.values[value.valueDate] = {
528
+ value: value.value,
529
+ isDirty: false
530
+ }
531
+ }
532
+ })
533
+
534
+ // KPI Metric Value가 없는 Metric도 빈 값으로 초기화
535
+ this.metrics.forEach(metric => {
536
+ this.dates.forEach(date => {
537
+ if (!metric.values[date]) {
538
+ metric.values[date] = { value: null, isDirty: false }
539
+ }
540
+ })
541
+ })
542
+
543
+ this.requestUpdate()
544
+ } catch (error) {
545
+ console.error('데이터 로드 중 오류:', error)
546
+ this.error = '데이터를 불러오는 중 오류가 발생했습니다.'
547
+ } finally {
548
+ this.loading = false
549
+ }
550
+ }
551
+
552
+ private _generateDateArray(startDate: string, endDate: string): string[] {
553
+ const dates: string[] = []
554
+ const start = new Date(startDate)
555
+ const end = new Date(endDate)
556
+
557
+ // 일별로 생성 (기본)
558
+ for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
559
+ dates.push(d.toLocaleDateString('sv-SE'))
560
+ }
561
+
562
+ return dates
563
+ }
564
+
565
+ private async _saveValues() {
566
+ try {
567
+ const patches: any[] = []
568
+
569
+ this.metrics.forEach(metric => {
570
+ Object.entries(metric.values).forEach(([date, data]) => {
571
+ // dirty 상태인 데이터만 처리
572
+ if (data.isDirty && data.value !== null && data.value !== undefined) {
573
+ const existingValue = this._findExistingValue(metric.metricId, date)
574
+
575
+ if (existingValue) {
576
+ patches.push({
577
+ id: existingValue.id,
578
+ value: data.value,
579
+ group: this.group,
580
+ cuFlag: 'M'
581
+ })
582
+ } else {
583
+ console.log(
584
+ 'Creating new value for metric:',
585
+ metric.metricName,
586
+ 'periodType:',
587
+ metric.periodType,
588
+ 'date:',
589
+ date
590
+ )
591
+ const normalizedDate = this._normalizeDateByPeriodType(date, metric.periodType)
592
+ console.log('Normalized date:', normalizedDate)
593
+ patches.push({
594
+ metricId: metric.metricId,
595
+ valueDate: normalizedDate,
596
+ value: data.value,
597
+ group: this.group,
598
+ cuFlag: '+'
599
+ })
600
+ }
601
+ }
602
+ })
603
+ })
604
+
605
+ if (patches.length === 0) {
606
+ notify({ message: '저장할 데이터가 없습니다.' })
607
+ return
608
+ }
609
+
610
+ // 디버깅: patches 내용 확인
611
+ console.log('Sending patches:', patches)
612
+
613
+ // 단일 mutation으로 생성과 업데이트 처리
614
+ const response = await client.mutate({
615
+ mutation: gql`
616
+ mutation ($patches: [KpiMetricValuePatch!]!) {
617
+ updateMultipleKpiMetricValue(patches: $patches) {
618
+ id
619
+ value
620
+ valueDate
621
+ metricId
622
+ }
623
+ }
624
+ `,
625
+ variables: { patches }
626
+ })
627
+
628
+ if (!response.errors) {
629
+ // 저장 후 dirty 상태 해제
630
+ response.data.updateMultipleKpiMetricValue.forEach((savedValue: any) => {
631
+ const metric = this.metrics.find(m => m.metricId === savedValue.metricId)
632
+ if (metric && metric.values[savedValue.valueDate]) {
633
+ metric.values[savedValue.valueDate].isDirty = false
634
+ }
635
+ })
636
+
637
+ notify({ message: 'KPI Metric 값이 성공적으로 저장되었습니다.' })
638
+ this.requestUpdate()
639
+ }
640
+ } catch (error) {
641
+ console.error('저장 중 오류:', error)
642
+ notify({ message: '저장 중 오류가 발생했습니다.' })
643
+ }
644
+ }
645
+
646
+ private _findExistingValue(metricId: string, date: string) {
647
+ // 기존 로드된 KPI Metric Value 데이터에서 찾기
648
+ return this._existingValues?.find((v: any) => v.metricId === metricId && v.valueDate === date)
649
+ }
650
+
651
+ private _getMonthKey(date: string): string {
652
+ const dateObj = new Date(date)
653
+ return `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}`
654
+ }
655
+
656
+ private _getQuarterKey(date: string): string {
657
+ const dateObj = new Date(date)
658
+ const quarter = Math.floor(dateObj.getMonth() / 3) + 1
659
+ return `${dateObj.getFullYear()}-Q${quarter}`
660
+ }
661
+
662
+ private _getYearKey(date: string): string {
663
+ const dateObj = new Date(date)
664
+ return `${dateObj.getFullYear()}`
665
+ }
666
+
667
+ private _getWeekKey(date: string): string {
668
+ const dateObj = new Date(date)
669
+ const dayOfWeek = dateObj.getDay()
670
+ const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1
671
+ const monday = new Date(dateObj)
672
+ monday.setDate(dateObj.getDate() - daysToMonday)
673
+ return monday.toLocaleDateString('sv-SE')
674
+ }
675
+
676
+ private _getMondayOfWeek(date: string): string {
677
+ const dateObj = new Date(date)
678
+ const dayOfWeek = dateObj.getDay()
679
+ const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1
680
+ const monday = new Date(dateObj)
681
+ monday.setDate(dateObj.getDate() - daysToMonday)
682
+ return monday.toLocaleDateString('sv-SE')
683
+ }
684
+
685
+ private _getFirstDayOfMonth(date: string): string {
686
+ const dateObj = new Date(date)
687
+ return new Date(dateObj.getFullYear(), dateObj.getMonth(), 1).toLocaleDateString('sv-SE')
688
+ }
689
+
690
+ private _getFirstDayOfQuarter(date: string): string {
691
+ const dateObj = new Date(date)
692
+ const quarter = Math.floor(dateObj.getMonth() / 3)
693
+ const firstMonthOfQuarter = quarter * 3
694
+ return new Date(dateObj.getFullYear(), firstMonthOfQuarter, 1).toLocaleDateString('sv-SE')
695
+ }
696
+
697
+ private _getFirstDayOfYear(date: string): string {
698
+ const dateObj = new Date(date)
699
+ return new Date(dateObj.getFullYear(), 0, 1).toLocaleDateString('sv-SE')
700
+ }
701
+
702
+ private _normalizeDateByPeriodType(date: string, periodType: string): string {
703
+ console.log('Normalizing date:', date, 'for periodType:', periodType)
704
+
705
+ const dateObj = new Date(date)
706
+
707
+ switch (periodType) {
708
+ case 'DAY':
709
+ console.log('DAY - returning original date:', date)
710
+ return date // 그대로 사용
711
+ case 'WEEK':
712
+ // 해당 주의 월요일로 설정
713
+ const dayOfWeek = dateObj.getDay()
714
+ const daysToMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1 // 일요일이면 6일 전, 아니면 dayOfWeek - 1
715
+ const monday = new Date(dateObj)
716
+ monday.setDate(dateObj.getDate() - daysToMonday)
717
+ const weeklyDate = monday.toLocaleDateString('sv-SE')
718
+ console.log('WEEK - returning monday:', weeklyDate)
719
+ return weeklyDate
720
+ case 'MONTH':
721
+ // 년-월 형식으로 설정 (예: "2025-01")
722
+ const monthlyDate = `${dateObj.getFullYear()}-${String(dateObj.getMonth() + 1).padStart(2, '0')}`
723
+ console.log('MONTH - returning year-month format:', monthlyDate)
724
+ return monthlyDate
725
+ case 'QUARTER':
726
+ // 분기 형식으로 설정 (예: "2025-Q1")
727
+ const quarter = Math.floor(dateObj.getMonth() / 3) + 1
728
+ const quarterlyDate = `${dateObj.getFullYear()}-Q${quarter}`
729
+ console.log('QUARTER - returning quarter format:', quarterlyDate)
730
+ return quarterlyDate
731
+ case 'YEAR':
732
+ // 년도 형식으로 설정 (예: "2025")
733
+ const yearlyDate = `${dateObj.getFullYear()}`
734
+ console.log('YEAR - returning year format:', yearlyDate)
735
+ return yearlyDate
736
+ default:
737
+ console.log('DEFAULT - returning original date:', date)
738
+ return date // 기본값
739
+ }
740
+ }
741
+
742
+ private _cancel() {
743
+ // 편집 취소 로직
744
+ this.editingCell = null
745
+ this._loadData() // 원본 데이터로 복원
746
+ }
747
+
748
+ async pageInitialized(lifecycle: any) {
749
+ // 기본값 설정 - 지난 1개월
750
+ if (!this.startDate) {
751
+ const today = new Date()
752
+ const oneMonthAgo = new Date(today.getFullYear(), today.getMonth() - 1, today.getDate())
753
+ this.startDate = oneMonthAgo.toLocaleDateString('sv-SE')
754
+ }
755
+ if (!this.endDate) {
756
+ const today = new Date()
757
+ this.endDate = today.toLocaleDateString('sv-SE')
758
+ }
759
+
760
+ // 페이지 초기화 시 자동으로 데이터 로드
761
+ await this._loadData()
762
+ }
763
+ }