@things-factory/kpi 9.0.28 → 9.0.29

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