@things-factory/kpi 9.0.15 → 9.0.16

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.
@@ -3,6 +3,7 @@ import '@material/web/button/elevated-button.js'
3
3
  import '@operato/data-grist/ox-grist.js'
4
4
  import '@operato/data-grist/ox-filters-form.js'
5
5
  import '@operato/data-grist/ox-record-creator.js'
6
+ import './kpi-viz-editor.js'
6
7
 
7
8
  import { CommonButtonStyles, CommonHeaderStyles, CommonGristStyles, ScrollbarStyles } from '@operato/styles'
8
9
  import { PageView, store } from '@operato/shell'
@@ -21,6 +22,7 @@ import gql from 'graphql-tag'
21
22
 
22
23
  import { KpiImporter } from './kpi-importer'
23
24
  import { KpiGradeEditor } from './kpi-grade-editor'
25
+ import { KpiVizEditor } from './kpi-viz-editor'
24
26
 
25
27
  @customElement('kpi-list-page')
26
28
  export class KpiListPage extends connect(store)(localize(i18next)(ScopedElementsMixin(PageView))) {
@@ -52,7 +54,8 @@ export class KpiListPage extends connect(store)(localize(i18next)(ScopedElements
52
54
  static get scopedElements() {
53
55
  return {
54
56
  'kpi-importer': KpiImporter,
55
- 'kpi-grade-editor': KpiGradeEditor
57
+ 'kpi-grade-editor': KpiGradeEditor,
58
+ 'kpi-viz-editor': KpiVizEditor
56
59
  }
57
60
  }
58
61
 
@@ -237,7 +240,30 @@ export class KpiListPage extends connect(store)(localize(i18next)(ScopedElements
237
240
  width: 60
238
241
  },
239
242
  { type: 'string', name: 'state', header: '상태', record: { editable: false }, width: 100 },
240
- { type: 'string', name: 'vizType', header: '시각화유형', record: { editable: true }, width: 120 },
243
+ {
244
+ type: 'string',
245
+ name: 'vizType',
246
+ header: '시각화 설정',
247
+ record: {
248
+ editable: false,
249
+ renderer: (v, c, r) => {
250
+ const vizType = r.vizType || 'CARD'
251
+ const vizMeta = r.vizMeta || {}
252
+ const color = vizMeta.color || '#2196f3'
253
+ const icon = vizMeta.icon || 'dashboard'
254
+
255
+ return html`
256
+ <div style="display:flex;align-items:center;gap:8px;">
257
+ <span style="color:${color};cursor:pointer;" @click=${() => this._editViz(r)}> ${vizType} </span>
258
+ <md-icon style="color:${color};font-size:16px;cursor:pointer;" @click=${() => this._editViz(r)}>
259
+ ${icon}
260
+ </md-icon>
261
+ </div>
262
+ `
263
+ }
264
+ },
265
+ width: 150
266
+ },
241
267
  { type: 'string', name: 'schedule', header: '스케줄', record: { editable: true }, width: 120 },
242
268
  { type: 'string', name: 'scheduleId', header: '스케줄ID', record: { editable: false }, width: 120 },
243
269
  { type: 'string', name: 'timezone', header: '타임존', record: { editable: true }, width: 100 },
@@ -295,6 +321,8 @@ export class KpiListPage extends connect(store)(localize(i18next)(ScopedElements
295
321
  description
296
322
  active
297
323
  grades
324
+ vizType
325
+ vizMeta
298
326
  category {
299
327
  id
300
328
  name
@@ -518,4 +546,52 @@ export class KpiListPage extends connect(store)(localize(i18next)(ScopedElements
518
546
  })
519
547
  }
520
548
  }
549
+
550
+ async _editViz(kpi: any) {
551
+ const popup = await openPopup(
552
+ html`
553
+ <kpi-viz-editor
554
+ .kpi=${kpi}
555
+ .onSave=${(vizType: string, vizMeta: any) => this._onVizUpdated(kpi.id, vizType, vizMeta)}
556
+ .onCancel=${() => popup.close()}
557
+ ></kpi-viz-editor>
558
+ `,
559
+ {
560
+ title: `${kpi.name} - 시각화 설정`,
561
+ size: 'large'
562
+ }
563
+ )
564
+ }
565
+
566
+ async _onVizUpdated(kpiId: string, vizType: string, vizMeta: any) {
567
+ try {
568
+ const response = await client.mutate({
569
+ mutation: gql`
570
+ mutation ($id: String!, $patch: KpiPatch!) {
571
+ updateKpi(id: $id, patch: $patch) {
572
+ id
573
+ name
574
+ vizType
575
+ vizMeta
576
+ }
577
+ }
578
+ `,
579
+ variables: {
580
+ id: kpiId,
581
+ patch: { vizType, vizMeta }
582
+ }
583
+ })
584
+
585
+ if (!response.errors) {
586
+ this.grist.fetch()
587
+ notify({
588
+ message: '시각화 설정이 성공적으로 업데이트되었습니다.'
589
+ })
590
+ }
591
+ } catch (error) {
592
+ notify({
593
+ message: '시각화 설정 업데이트 중 오류가 발생했습니다.'
594
+ })
595
+ }
596
+ }
521
597
  }
@@ -0,0 +1,353 @@
1
+ import '@material/web/button/elevated-button.js'
2
+ import '@material/web/select/outlined-select.js'
3
+ import '@material/web/textfield/outlined-text-field.js'
4
+ import '@material/web/icon/icon.js'
5
+
6
+ import { PageView } from '@operato/shell'
7
+ import { css, html, LitElement } from 'lit'
8
+ import { customElement, property } from 'lit/decorators.js'
9
+ import { client } from '@operato/graphql'
10
+ import { i18next, localize } from '@operato/i18n'
11
+ import { notify } from '@operato/layout'
12
+ import { OxPopup } from '@operato/popup'
13
+ import gql from 'graphql-tag'
14
+ import { CommonHeaderStyles, ScrollbarStyles } from '@operato/styles'
15
+
16
+ const VIZ_TYPES = [
17
+ { value: 'CARD', label: '카드', icon: 'dashboard' },
18
+ { value: 'GAUGE', label: '게이지', icon: 'speed' },
19
+ { value: 'PROGRESS', label: '진행률', icon: 'linear_scale' },
20
+ { value: 'BAR', label: '막대 차트', icon: 'bar_chart' },
21
+ { value: 'LINE', label: '선 차트', icon: 'show_chart' },
22
+ { value: 'PIE', label: '파이 차트', icon: 'pie_chart' },
23
+ { value: 'DONUT', label: '도넛 차트', icon: 'donut_large' },
24
+ { value: 'RADAR', label: '레이더 차트', icon: 'radar' },
25
+ { value: 'BULLET', label: '불릿 차트', icon: 'track_changes' },
26
+ { value: 'THERMOMETER', label: '온도계', icon: 'thermostat' },
27
+ { value: 'SPEEDOMETER', label: '속도계', icon: 'speed' },
28
+ { value: 'ICON', label: '아이콘', icon: 'emoji_events' },
29
+ { value: 'BADGE', label: '배지', icon: 'badge' },
30
+ { value: 'TEXT', label: '텍스트', icon: 'text_fields' },
31
+ { value: 'TABLE', label: '테이블', icon: 'table_chart' }
32
+ ]
33
+
34
+ @customElement('kpi-viz-editor')
35
+ export class KpiVizEditor extends localize(i18next)(LitElement) {
36
+ static styles = [
37
+ CommonHeaderStyles,
38
+ ScrollbarStyles,
39
+ css`
40
+ :host {
41
+ display: flex;
42
+ flex-direction: column;
43
+ background-color: var(--md-sys-color-surface, #f4f6fa);
44
+ }
45
+
46
+ .viz-editor {
47
+ flex: 1;
48
+ display: flex;
49
+ flex-direction: column;
50
+ overflow-y: auto;
51
+
52
+ background: white;
53
+ padding: 24px;
54
+ }
55
+
56
+ .form-group {
57
+ margin-bottom: 20px;
58
+ }
59
+
60
+ .form-group label {
61
+ display: block;
62
+ margin-bottom: 8px;
63
+ font-weight: 500;
64
+ color: #555;
65
+ }
66
+
67
+ .form-options {
68
+ flex: 1;
69
+ display: flex;
70
+ flex-direction: row;
71
+ }
72
+
73
+ .viz-type-grid {
74
+ display: grid;
75
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
76
+ gap: 12px;
77
+ margin-bottom: 20px;
78
+ }
79
+
80
+ .viz-type-option {
81
+ display: flex;
82
+ flex-direction: column;
83
+ align-items: center;
84
+ padding: 16px 12px;
85
+ border: 2px solid #e0e0e0;
86
+ border-radius: 8px;
87
+ cursor: pointer;
88
+ transition: all 0.2s;
89
+ text-align: center;
90
+ }
91
+
92
+ .viz-type-option:hover {
93
+ border-color: #2196f3;
94
+ background: #f5f9ff;
95
+ }
96
+
97
+ .viz-type-option.selected {
98
+ border-color: #2196f3;
99
+ background: #e3f2fd;
100
+ }
101
+
102
+ .viz-type-option md-icon {
103
+ font-size: 24px;
104
+ margin-bottom: 8px;
105
+ color: #666;
106
+ }
107
+
108
+ .viz-type-option.selected md-icon {
109
+ color: #2196f3;
110
+ }
111
+
112
+ .viz-type-option .label {
113
+ font-size: 0.9rem;
114
+ font-weight: 500;
115
+ color: #333;
116
+ }
117
+
118
+ .viz-meta-section {
119
+ margin-top: 24px;
120
+ padding-top: 20px;
121
+ border-top: 1px solid #eee;
122
+ }
123
+
124
+ .color-picker {
125
+ display: flex;
126
+ gap: 12px;
127
+ align-items: center;
128
+ margin-bottom: 16px;
129
+ }
130
+
131
+ .color-input {
132
+ width: 60px;
133
+ height: 40px;
134
+ border: none;
135
+ border-radius: 6px;
136
+ cursor: pointer;
137
+ }
138
+
139
+ .buttons {
140
+ display: flex;
141
+ gap: 12px;
142
+ justify-content: flex-end;
143
+ margin-top: 24px;
144
+ padding-top: 20px;
145
+ border-top: 1px solid #eee;
146
+ }
147
+
148
+ .preview {
149
+ flex: 1;
150
+ margin: 30px 16px 16px 16px;
151
+ padding: 16px;
152
+ background: #f8f9fa;
153
+ border-radius: 8px;
154
+ border: 1px solid #e9ecef;
155
+ }
156
+
157
+ .preview h4 {
158
+ margin: 0 0 12px 0;
159
+ color: #495057;
160
+ font-size: 0.95rem;
161
+ }
162
+
163
+ .footer span {
164
+ font-size: 0.8em;
165
+ color: var(--md-sys-color-on-surface);
166
+ line-height: 1.5;
167
+ padding: 10px;
168
+ }
169
+ `
170
+ ]
171
+
172
+ @property({ type: Object }) kpi: any = null
173
+ @property({ type: String }) selectedVizType: string = 'CARD'
174
+ @property({ type: Object }) vizMeta: any = {}
175
+ @property({ type: Object }) onSave: Function = () => {}
176
+ @property({ type: Object }) onCancel: Function = () => {}
177
+
178
+ connectedCallback() {
179
+ super.connectedCallback()
180
+ if (this.kpi) {
181
+ this.selectedVizType = this.kpi.vizType || 'CARD'
182
+ this.vizMeta = this.kpi.vizMeta || {}
183
+ }
184
+ }
185
+
186
+ _selectVizType(type: string) {
187
+ this.selectedVizType = type
188
+ this.requestUpdate()
189
+ }
190
+
191
+ _updateVizMeta(key: string, value: any) {
192
+ this.vizMeta = { ...this.vizMeta, [key]: value }
193
+ this.requestUpdate()
194
+ }
195
+
196
+ _renderPreview() {
197
+ const kpiValue = this.kpi?.value?.value || 75
198
+ const targetValue = this.kpi?.targetValue || 100
199
+ const unit = this.kpi?.unit || ''
200
+ const color = this.vizMeta.color || '#2196f3'
201
+ const icon = this.vizMeta.icon || 'trending_up'
202
+
203
+ switch (this.selectedVizType) {
204
+ case 'CARD':
205
+ return html`
206
+ <div
207
+ style="display:flex;align-items:center;gap:12px;padding:16px;background:white;border-radius:8px;border:1px solid #e0e0e0;"
208
+ >
209
+ <md-icon style="color:${color};font-size:32px;">${icon}</md-icon>
210
+ <div>
211
+ <div style="font-size:1.5rem;font-weight:bold;color:${color};">${kpiValue}${unit}</div>
212
+ <div style="font-size:0.9rem;color:#666;">목표: ${targetValue}${unit}</div>
213
+ </div>
214
+ </div>
215
+ `
216
+ case 'GAUGE':
217
+ const percentage = Math.min((kpiValue / targetValue) * 100, 100)
218
+ return html`
219
+ <div style="text-align:center;padding:16px;">
220
+ <div
221
+ style="width:120px;height:60px;border-radius:60px 60px 0 0;background:conic-gradient(${color} 0deg ${percentage *
222
+ 3.6}deg, #e0e0e0 ${percentage * 3.6}deg 360deg);margin:0 auto;position:relative;"
223
+ >
224
+ <div
225
+ style="position:absolute;bottom:0;left:50%;transform:translateX(-50%);font-size:1.2rem;font-weight:bold;color:${color};"
226
+ >
227
+ ${kpiValue}${unit}
228
+ </div>
229
+ </div>
230
+ </div>
231
+ `
232
+ case 'PROGRESS':
233
+ const progressPercentage = Math.min((kpiValue / targetValue) * 100, 100)
234
+ return html`
235
+ <div style="padding:16px;">
236
+ <div style="background:#e0e0e0;height:20px;border-radius:10px;overflow:hidden;">
237
+ <div style="background:${color};height:100%;width:${progressPercentage}%;transition:width 0.3s;"></div>
238
+ </div>
239
+ <div style="text-align:center;margin-top:8px;font-weight:bold;color:${color};">
240
+ ${kpiValue}${unit} / ${targetValue}${unit}
241
+ </div>
242
+ </div>
243
+ `
244
+ case 'ICON':
245
+ return html`
246
+ <div style="text-align:center;padding:16px;">
247
+ <md-icon style="color:${color};font-size:48px;">${icon}</md-icon>
248
+ <div style="font-size:1.2rem;font-weight:bold;color:${color};margin-top:8px;">${kpiValue}${unit}</div>
249
+ </div>
250
+ `
251
+ default:
252
+ return html` <div style="padding:16px;text-align:center;color:#666;">${this.selectedVizType} 미리보기</div> `
253
+ }
254
+ }
255
+
256
+ render() {
257
+ return html`
258
+ <div class="viz-editor">
259
+ <div class="form-group">
260
+ <label>시각화 타입 선택</label>
261
+ <div class="viz-type-grid">
262
+ ${VIZ_TYPES.map(
263
+ type => html`
264
+ <div
265
+ class="viz-type-option ${this.selectedVizType === type.value ? 'selected' : ''}"
266
+ @click=${() => this._selectVizType(type.value)}
267
+ >
268
+ <md-icon>${type.icon}</md-icon>
269
+ <div class="label">${type.label}</div>
270
+ </div>
271
+ `
272
+ )}
273
+ </div>
274
+ </div>
275
+
276
+ <div class="form-options">
277
+ <div class="viz-meta-section">
278
+ <label>시각화 옵션</label>
279
+
280
+ <div class="form-group">
281
+ <label>색상</label>
282
+ <div class="color-picker">
283
+ <input
284
+ type="color"
285
+ class="color-input"
286
+ .value=${this.vizMeta.color || '#2196f3'}
287
+ @change=${(e: any) => this._updateVizMeta('color', e.target.value)}
288
+ />
289
+ <md-outlined-text-field
290
+ label="색상 코드"
291
+ .value=${this.vizMeta.color || '#2196f3'}
292
+ @change=${(e: any) => this._updateVizMeta('color', e.target.value)}
293
+ ></md-outlined-text-field>
294
+ </div>
295
+ </div>
296
+
297
+ <div class="form-group">
298
+ <label>아이콘</label>
299
+ <md-outlined-text-field
300
+ label="Material Icons 이름"
301
+ .value=${this.vizMeta.icon || 'trending_up'}
302
+ @change=${(e: any) => this._updateVizMeta('icon', e.target.value)}
303
+ ></md-outlined-text-field>
304
+ </div>
305
+
306
+ <div class="form-group">
307
+ <label>최소값</label>
308
+ <md-outlined-text-field
309
+ type="number"
310
+ label="최소값"
311
+ .value=${this.vizMeta.minValue || 0}
312
+ @change=${(e: any) => this._updateVizMeta('minValue', parseFloat(e.target.value))}
313
+ ></md-outlined-text-field>
314
+ </div>
315
+
316
+ <div class="form-group">
317
+ <label>최대값</label>
318
+ <md-outlined-text-field
319
+ type="number"
320
+ label="최대값"
321
+ .value=${this.vizMeta.maxValue || 100}
322
+ @change=${(e: any) => this._updateVizMeta('maxValue', parseFloat(e.target.value))}
323
+ ></md-outlined-text-field>
324
+ </div>
325
+
326
+ <div class="form-group">
327
+ <label>소수점 자릿수</label>
328
+ <md-outlined-text-field
329
+ type="number"
330
+ label="소수점 자릿수"
331
+ .value=${this.vizMeta.decimalPlaces || 0}
332
+ @change=${(e: any) => this._updateVizMeta('decimalPlaces', parseInt(e.target.value))}
333
+ ></md-outlined-text-field>
334
+ </div>
335
+ </div>
336
+
337
+ <div class="preview">
338
+ <h4>미리보기</h4>
339
+ ${this._renderPreview()}
340
+ </div>
341
+ </div>
342
+ </div>
343
+
344
+ <div class="footer">
345
+ <div filler></div>
346
+ <button type="button" @click=${this.onCancel}><md-icon>cancel</md-icon>취소</button>
347
+ <button type="button" @click=${() => this.onSave(this.selectedVizType, this.vizMeta)} done>
348
+ <md-icon>save</md-icon>저장
349
+ </button>
350
+ </div>
351
+ `
352
+ }
353
+ }
@@ -5,6 +5,7 @@ import { ScrollbarStyles } from '@operato/styles'
5
5
  import { client } from '@operato/graphql'
6
6
  import gql from 'graphql-tag'
7
7
  import { state } from 'lit/decorators.js'
8
+ import '@material/web/icon/icon.js'
8
9
 
9
10
  import './kpi-performance-summary'
10
11
  import './kpi-grade-visualization'
@@ -110,6 +111,8 @@ export class KpiDashboardPage extends PageView {
110
111
  targetValue
111
112
  unit
112
113
  grades
114
+ vizType
115
+ vizMeta
113
116
  histories(limit: 1) {
114
117
  version
115
118
  updatedAt
@@ -174,6 +177,130 @@ export class KpiDashboardPage extends PageView {
174
177
  this.modalKpiName = ''
175
178
  }
176
179
 
180
+ _renderKpiValue(kpi: any) {
181
+ const kpiValue = kpi.value?.value ?? 0
182
+ const targetValue = kpi.targetValue ?? 100
183
+ const unit = kpi.unit ?? ''
184
+ const vizType = kpi.vizType || 'CARD'
185
+ const vizMeta = kpi.vizMeta || {}
186
+ const color = vizMeta.color || '#3a3ad6'
187
+ const icon = vizMeta.icon || 'trending_up'
188
+ const minValue = vizMeta.minValue || 0
189
+ const maxValue = vizMeta.maxValue || 100
190
+ const decimalPlaces = vizMeta.decimalPlaces || 0
191
+
192
+ const formattedValue = typeof kpiValue === 'number' ? kpiValue.toFixed(decimalPlaces) : kpiValue
193
+
194
+ switch (vizType) {
195
+ case 'GAUGE':
196
+ const gaugePercentage = Math.min(((kpiValue - minValue) / (maxValue - minValue)) * 100, 100)
197
+ return html`
198
+ <div style="text-align:center;margin:16px 0;">
199
+ <div
200
+ style="width:120px;height:60px;border-radius:60px 60px 0 0;background:conic-gradient(${color} 0deg ${gaugePercentage *
201
+ 3.6}deg, #e0e0e0 ${gaugePercentage * 3.6}deg 360deg);margin:0 auto;position:relative;"
202
+ >
203
+ <div
204
+ style="position:absolute;bottom:0;left:50%;transform:translateX(-50%);font-size:1.2rem;font-weight:bold;color:${color};"
205
+ >
206
+ ${formattedValue}${unit}
207
+ </div>
208
+ </div>
209
+ </div>
210
+ `
211
+
212
+ case 'PROGRESS':
213
+ const progressPercentage = Math.min(((kpiValue - minValue) / (maxValue - minValue)) * 100, 100)
214
+ return html`
215
+ <div style="margin:16px 0;">
216
+ <div style="background:#e0e0e0;height:20px;border-radius:10px;overflow:hidden;">
217
+ <div style="background:${color};height:100%;width:${progressPercentage}%;transition:width 0.3s;"></div>
218
+ </div>
219
+ <div style="text-align:center;margin-top:8px;font-weight:bold;color:${color};font-size:1.2rem;">
220
+ ${formattedValue}${unit}
221
+ </div>
222
+ </div>
223
+ `
224
+
225
+ case 'ICON':
226
+ return html`
227
+ <div style="text-align:center;margin:16px 0;">
228
+ <md-icon style="color:${color};font-size:48px;">${icon}</md-icon>
229
+ <div style="font-size:1.5rem;font-weight:bold;color:${color};margin-top:8px;">${formattedValue}${unit}</div>
230
+ </div>
231
+ `
232
+
233
+ case 'THERMOMETER':
234
+ const thermoPercentage = Math.min(((kpiValue - minValue) / (maxValue - minValue)) * 100, 100)
235
+ return html`
236
+ <div style="text-align:center;margin:16px 0;">
237
+ <div
238
+ style="width:40px;height:120px;background:#e0e0e0;border-radius:20px;margin:0 auto;position:relative;overflow:hidden;"
239
+ >
240
+ <div
241
+ style="position:absolute;bottom:0;width:100%;height:${thermoPercentage}%;background:${color};border-radius:20px;"
242
+ ></div>
243
+ </div>
244
+ <div style="font-size:1.2rem;font-weight:bold;color:${color};margin-top:8px;">${formattedValue}${unit}</div>
245
+ </div>
246
+ `
247
+
248
+ case 'SPEEDOMETER':
249
+ const speedPercentage = Math.min(((kpiValue - minValue) / (maxValue - minValue)) * 100, 100)
250
+ const angle = (speedPercentage / 100) * 180 - 90 // -90도에서 90도까지
251
+ return html`
252
+ <div style="text-align:center;margin:16px 0;">
253
+ <div
254
+ style="width:120px;height:60px;border-radius:60px 60px 0 0;background:conic-gradient(${color} -90deg ${angle}deg, #e0e0e0 ${angle}deg 90deg);margin:0 auto;position:relative;"
255
+ >
256
+ <div
257
+ style="position:absolute;bottom:0;left:50%;transform:translateX(-50%);font-size:1.2rem;font-weight:bold;color:${color};"
258
+ >
259
+ ${formattedValue}${unit}
260
+ </div>
261
+ </div>
262
+ </div>
263
+ `
264
+
265
+ case 'BULLET':
266
+ const bulletPercentage = Math.min(((kpiValue - minValue) / (maxValue - minValue)) * 100, 100)
267
+ const targetPercentage = Math.min(((targetValue - minValue) / (maxValue - minValue)) * 100, 100)
268
+ return html`
269
+ <div style="margin:16px 0;">
270
+ <div style="background:#e0e0e0;height:30px;border-radius:15px;overflow:hidden;position:relative;">
271
+ <div style="background:${color};height:100%;width:${bulletPercentage}%;transition:width 0.3s;"></div>
272
+ <div
273
+ style="position:absolute;top:0;left:${targetPercentage}%;width:2px;height:100%;background:#333;"
274
+ ></div>
275
+ </div>
276
+ <div style="text-align:center;margin-top:8px;font-weight:bold;color:${color};font-size:1.2rem;">
277
+ ${formattedValue}${unit}
278
+ </div>
279
+ </div>
280
+ `
281
+
282
+ case 'TEXT':
283
+ return html`
284
+ <div style="text-align:center;margin:16px 0;">
285
+ <div style="font-size:1.5rem;font-weight:bold;color:${color};">${formattedValue}${unit}</div>
286
+ </div>
287
+ `
288
+
289
+ case 'BADGE':
290
+ return html`
291
+ <div style="text-align:center;margin:16px 0;">
292
+ <span
293
+ style="display:inline-block;padding:8px 16px;background:${color};color:white;border-radius:20px;font-size:1.2rem;font-weight:bold;"
294
+ >${formattedValue}${unit}</span
295
+ >
296
+ </div>
297
+ `
298
+
299
+ default: // CARD, BAR, LINE, PIE, DONUT, RADAR, TABLE
300
+ return html` <div class="kpi-value">${formattedValue}${unit}</div> `
301
+ }
302
+ }
303
+
177
304
  get context() {
178
305
  return {
179
306
  title: 'KPI 대시보드',
@@ -223,7 +350,7 @@ export class KpiDashboardPage extends PageView {
223
350
  kpi => html`
224
351
  <div class="kpi-card">
225
352
  <div class="kpi-name">${kpi.name}</div>
226
- <div class="kpi-value">${kpi.value?.value ?? '데이터 없음'}${kpi.unit ?? ''}</div>
353
+ ${this._renderKpiValue(kpi)}
227
354
  <div class="kpi-target">목표: ${kpi.targetValue ?? '-'}${kpi.unit ?? ''}</div>
228
355
  <!-- 등급 시각화 -->
229
356
  <div style="margin-top:8px;">
@@ -3,22 +3,25 @@ import '@material/web/button/elevated-button.js';
3
3
  import '@operato/data-grist/ox-grist.js';
4
4
  import '@operato/data-grist/ox-filters-form.js';
5
5
  import '@operato/data-grist/ox-record-creator.js';
6
+ import './kpi-viz-editor.js';
6
7
  import { PageView } from '@operato/shell';
7
8
  import { FetchOption } from '@operato/data-grist';
8
9
  import { KpiImporter } from './kpi-importer';
9
10
  import { KpiGradeEditor } from './kpi-grade-editor';
11
+ import { KpiVizEditor } from './kpi-viz-editor';
10
12
  declare const KpiListPage_base: (new (...args: any[]) => {
11
13
  _storeUnsubscribe: import("redux").Unsubscribe;
12
14
  connectedCallback(): void;
13
15
  disconnectedCallback(): void;
14
16
  stateChanged(_state: unknown): void;
15
17
  readonly isConnected: boolean;
16
- }) & (new (...args: any[]) => import("lit").LitElement) & typeof PageView & import("@open-wc/dedupe-mixin").Constructor<import("@open-wc/scoped-elements/types/src/types").ScopedElementsHost>;
18
+ }) & (new (...args: any[]) => import("lit").LitElement) & typeof PageView & import("@open-wc/dedupe-mixin").Constructor<import("@open-wc/scoped-elements/types/src/types.js").ScopedElementsHost>;
17
19
  export declare class KpiListPage extends KpiListPage_base {
18
20
  static styles: import("lit").CSSResult[];
19
21
  static get scopedElements(): {
20
22
  'kpi-importer': typeof KpiImporter;
21
23
  'kpi-grade-editor': typeof KpiGradeEditor;
24
+ 'kpi-viz-editor': typeof KpiVizEditor;
22
25
  };
23
26
  gristConfig: any;
24
27
  mode: 'CARD' | 'GRID' | 'LIST';
@@ -66,5 +69,7 @@ export declare class KpiListPage extends KpiListPage_base {
66
69
  importHandler(records: any): Promise<void>;
67
70
  _editGrades(kpi: any): Promise<void>;
68
71
  _onGradesUpdated(detail: any): Promise<void>;
72
+ _editViz(kpi: any): Promise<void>;
73
+ _onVizUpdated(kpiId: string, vizType: string, vizMeta: any): Promise<void>;
69
74
  }
70
75
  export {};