@things-factory/kpi 9.0.23 → 9.0.24

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