@things-factory/kpi 9.0.15 → 9.0.17

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 (28) hide show
  1. package/client/pages/kpi/kpi-grade-editor.ts +80 -207
  2. package/client/pages/kpi/kpi-list-page.ts +53 -11
  3. package/client/pages/kpi/kpi-viz-editor.ts +353 -0
  4. package/client/pages/kpi-dashboard/kpi-dashboard.ts +128 -1
  5. package/dist-client/pages/kpi/kpi-grade-editor.d.ts +13 -13
  6. package/dist-client/pages/kpi/kpi-grade-editor.js +84 -197
  7. package/dist-client/pages/kpi/kpi-grade-editor.js.map +1 -1
  8. package/dist-client/pages/kpi/kpi-list-page.d.ts +6 -2
  9. package/dist-client/pages/kpi/kpi-list-page.js +50 -10
  10. package/dist-client/pages/kpi/kpi-list-page.js.map +1 -1
  11. package/dist-client/pages/kpi/kpi-viz-editor.d.ts +20 -0
  12. package/dist-client/pages/kpi/kpi-viz-editor.js +364 -0
  13. package/dist-client/pages/kpi/kpi-viz-editor.js.map +1 -0
  14. package/dist-client/pages/kpi-dashboard/kpi-dashboard.d.ts +2 -0
  15. package/dist-client/pages/kpi-dashboard/kpi-dashboard.js +117 -1
  16. package/dist-client/pages/kpi-dashboard/kpi-dashboard.js.map +1 -1
  17. package/dist-client/tsconfig.tsbuildinfo +1 -1
  18. package/dist-server/service/index.d.ts +2 -2
  19. package/dist-server/service/kpi/kpi-type.d.ts +3 -3
  20. package/dist-server/service/kpi/kpi-type.js +4 -4
  21. package/dist-server/service/kpi/kpi-type.js.map +1 -1
  22. package/dist-server/service/kpi/kpi.d.ts +18 -1
  23. package/dist-server/service/kpi/kpi.js +26 -4
  24. package/dist-server/service/kpi/kpi.js.map +1 -1
  25. package/dist-server/tsconfig.tsbuildinfo +1 -1
  26. package/package.json +2 -2
  27. package/server/service/kpi/kpi-type.ts +7 -7
  28. package/server/service/kpi/kpi.ts +27 -4
@@ -1,20 +1,20 @@
1
1
  import '@material/web/button/elevated-button.js'
2
2
  import '@material/web/button/filled-button.js'
3
3
  import '@material/web/button/text-button.js'
4
- import '@material/web/textfield/outlined-text-field.js'
5
- import '@material/web/select/outlined-select.js'
6
- import '@material/web/select/select-option.js'
7
4
  import '@material/web/icon/icon.js'
5
+ import '@operato/data-grist/ox-grist.js'
6
+ import deepEquals from 'lodash-es/isEqual'
8
7
 
8
+ import gql from 'graphql-tag'
9
9
  import { LitElement, css, html } from 'lit'
10
- import { customElement, property, state } from 'lit/decorators.js'
11
- import { ScopedElementsMixin } from '@open-wc/scoped-elements'
10
+ import { customElement, property, state, query } from 'lit/decorators.js'
11
+ import { DataGrist } from '@operato/data-grist/ox-grist.js'
12
+ import { i18next, localize } from '@operato/i18n'
12
13
  import { client } from '@operato/graphql'
13
14
  import { notify } from '@operato/layout'
14
- import gql from 'graphql-tag'
15
- import { CommonButtonStyles, CommonHeaderStyles, CommonGristStyles, ScrollbarStyles } from '@operato/styles'
15
+ import { FetchOption } from '@operato/data-grist'
16
+ import { CommonHeaderStyles } from '@operato/styles'
16
17
 
17
- // KPI 등급 타입 정의 (서버와 동일한 구조)
18
18
  interface KpiGrade {
19
19
  name: string
20
20
  minValue: number
@@ -27,268 +27,141 @@ interface KpiGrade {
27
27
  type KpiGrades = KpiGrade[]
28
28
 
29
29
  @customElement('kpi-grade-editor')
30
- export class KpiGradeEditor extends ScopedElementsMixin(LitElement) {
30
+ export class KpiGradeEditor extends localize(i18next)(LitElement) {
31
31
  static styles = [
32
32
  CommonHeaderStyles,
33
- ScrollbarStyles,
34
33
  css`
35
34
  :host {
36
35
  display: flex;
37
36
  flex-direction: column;
38
- background-color: var(--md-sys-color-surface, #f4f6fa);
39
- }
40
37
 
41
- .grade-list {
42
- flex: 1;
43
- margin-bottom: 20px;
44
- overflow-y: auto;
45
- }
46
-
47
- .grade-item {
48
- display: flex;
49
- align-items: center;
50
- gap: 10px;
51
- padding: 10px;
52
- border: 1px solid #ddd;
53
- border-radius: 4px;
54
- margin-bottom: 10px;
55
- background: #f9f9f9;
56
- }
57
-
58
- .grade-item:hover {
59
- background: #f0f0f0;
60
- }
61
-
62
- .grade-inputs {
63
- display: flex;
64
- gap: 10px;
65
- flex: 1;
38
+ background-color: var(--md-sys-color-surface);
66
39
  }
67
40
 
68
- .grade-inputs md-outlined-text-field {
41
+ ox-grist {
69
42
  flex: 1;
70
43
  }
71
-
72
- .grade-actions {
73
- display: flex;
74
- gap: 5px;
75
- }
76
-
77
- .footer span {
78
- font-size: 0.8em;
79
- color: var(--md-sys-color-on-surface);
80
- line-height: 1.5;
81
- padding: 10px;
82
- }
83
44
  `
84
45
  ]
85
46
 
86
- @property({ type: Object }) kpi: any
87
- @state() grades: KpiGrades = []
88
- @state() isDirty = false
47
+ @property({ type: Object }) kpi: any = { grades: [] }
48
+
49
+ @state() grades: KpiGrades = this.kpi?.grades || []
50
+ @state() gristConfig: any = null
51
+ @query('ox-grist') grist!: DataGrist
89
52
 
90
- connectedCallback() {
91
- super.connectedCallback()
53
+ async firstUpdated() {
92
54
  if (this.kpi?.grades) {
93
55
  this.grades = [...this.kpi.grades]
94
56
  }
57
+
58
+ this.gristConfig = {
59
+ list: { fields: ['name', 'minValue', 'maxValue', 'score', 'color', 'description'] },
60
+ columns: [
61
+ { type: 'gutter', gutterName: 'row-selector', multiple: true, fixed: true },
62
+ { type: 'gutter', gutterName: 'sequence', fixed: true },
63
+ { type: 'gutter', gutterName: 'button', fixed: true, icon: 'add', handlers: { click: 'record-copy' } },
64
+ { type: 'gutter', gutterName: 'button', fixed: true, icon: 'arrow_upward', handlers: { click: 'move-up' } },
65
+ { type: 'gutter', gutterName: 'button', fixed: true, icon: 'arrow_downward', handlers: { click: 'move-down' } },
66
+ { type: 'string', name: 'name', header: '등급명', record: { editable: true }, width: 100 },
67
+ { type: 'number', name: 'minValue', header: '최소값', record: { editable: true }, width: 100 },
68
+ { type: 'number', name: 'maxValue', header: '최대값', record: { editable: true }, width: 100 },
69
+ { type: 'number', name: 'score', header: '점수', record: { editable: true }, width: 80 },
70
+ { type: 'color', name: 'color', header: '색상', record: { editable: true }, width: 100 },
71
+ { type: 'string', name: 'description', header: '설명', record: { editable: true }, width: 200 }
72
+ ],
73
+ rows: { selectable: { multiple: true } },
74
+ pagination: { infinite: true }
75
+ }
95
76
  }
96
77
 
97
78
  render() {
98
79
  return html`
99
- <div class="grade-list">${this.grades.map((grade, index) => this._renderGradeItem(grade, index))}</div>
100
-
80
+ <ox-grist .mode=${'GRID'} .config=${this.gristConfig} .fetchHandler=${this.fetchHandler.bind(this)}></ox-grist>
101
81
  <div class="footer">
102
- <button type="button" @click=${this._addGrade}><md-icon>add</md-icon>등급 추가</button>
103
82
  <div filler></div>
104
- <button type="button" @click=${this._cancel}><md-icon>cancel</md-icon>취소</button>
105
- <button type="button" @click=${this._save} ?disabled=${!this.isDirty} done><md-icon>save</md-icon>저장</button>
106
- </div>
107
- `
108
- }
109
-
110
- _renderGradeItem(grade: KpiGrade, index: number) {
111
- return html`
112
- <div class="grade-item">
113
- <div class="grade-inputs">
114
- <md-outlined-text-field
115
- label="등급명"
116
- value=${grade.name}
117
- @input=${(e: any) => this._updateGrade(index, 'name', e.target.value)}
118
- ></md-outlined-text-field>
119
-
120
- <md-outlined-text-field
121
- label="최소값"
122
- type="number"
123
- value=${grade.minValue}
124
- @input=${(e: any) => this._updateGrade(index, 'minValue', parseFloat(e.target.value))}
125
- ></md-outlined-text-field>
126
-
127
- <md-outlined-text-field
128
- label="최대값"
129
- type="number"
130
- value=${grade.maxValue}
131
- @input=${(e: any) => this._updateGrade(index, 'maxValue', parseFloat(e.target.value))}
132
- ></md-outlined-text-field>
133
-
134
- <md-outlined-text-field
135
- label="점수"
136
- type="number"
137
- value=${grade.score || ''}
138
- @input=${(e: any) => this._updateGrade(index, 'score', parseFloat(e.target.value))}
139
- ></md-outlined-text-field>
140
-
141
- <md-outlined-text-field
142
- label="색상"
143
- value=${grade.color || ''}
144
- @input=${(e: any) => this._updateGrade(index, 'color', e.target.value)}
145
- ></md-outlined-text-field>
146
- </div>
147
-
148
- <div class="grade-actions">
149
- <md-icon-button @click=${() => this._removeGrade(index)}>
150
- <md-icon>delete</md-icon>
151
- </md-icon-button>
152
- </div>
83
+ <button danger @click=${this._deleteGrades.bind(this)}>
84
+ <md-icon>delete</md-icon>${i18next.t('button.delete')}
85
+ </button>
86
+ <button done type="button" @click=${this._updateGrades}><md-icon>save</md-icon>저장</button>
153
87
  </div>
154
88
  `
155
89
  }
156
90
 
157
- _updateGrade(index: number, field: keyof KpiGrade, value: any) {
158
- this.grades[index] = { ...this.grades[index], [field]: value }
159
- this.isDirty = true
160
- this.requestUpdate()
161
- }
162
-
163
- _addGrade() {
164
- const newGrade: KpiGrade = {
165
- name: '',
166
- minValue: 0,
167
- maxValue: 0,
168
- score: 0,
169
- color: '#4caf50',
170
- description: ''
91
+ async fetchHandler({ page, limit, sorters = [] }: FetchOption) {
92
+ return {
93
+ total: this.grades.length,
94
+ records: this.grades
171
95
  }
172
- this.grades.push(newGrade)
173
- this.isDirty = true
174
- this.requestUpdate()
175
96
  }
176
97
 
177
- _removeGrade(index: number) {
178
- this.grades.splice(index, 1)
179
- this.isDirty = true
180
- this.requestUpdate()
181
- }
98
+ async _updateGrades() {
99
+ this.grades = this.grist.dirtyData.records
100
+ .map(patch => {
101
+ const { name, minValue, maxValue, score, color, description } = patch
102
+ return { name, minValue, maxValue, score, color, description }
103
+ })
104
+ .sort((a, b) => a.minValue - b.minValue) as any
182
105
 
183
- _loadTemplate(template: string) {
184
- switch (template) {
185
- case '5grade':
186
- this.grades = [
187
- { name: 'A', minValue: 0, maxValue: 1.5, score: 95, color: '#4caf50', description: '우수' },
188
- { name: 'B', minValue: 1.5, maxValue: 2.5, score: 85, color: '#ff9800', description: '양호' },
189
- { name: 'C', minValue: 2.5, maxValue: 3.5, score: 75, color: '#ffc107', description: '보통' },
190
- { name: 'D', minValue: 3.5, maxValue: 4.5, score: 65, color: '#ff9800', description: '미흡' },
191
- { name: 'E', minValue: 4.5, maxValue: 999, score: 55, color: '#f44336', description: '불량' }
192
- ]
193
- break
194
- case '3grade':
195
- this.grades = [
196
- { name: '우수', minValue: 0, maxValue: 1.5, score: 95, color: '#4caf50', description: '목표 달성' },
197
- { name: '양호', minValue: 1.5, maxValue: 2.5, score: 85, color: '#ff9800', description: '기준 달성' },
198
- { name: '미흡', minValue: 2.5, maxValue: 999, score: 75, color: '#f44336', description: '개선 필요' }
199
- ]
200
- break
201
- case 'continuous':
202
- this.grades = [
203
- { name: '0.999999', minValue: 0, maxValue: 0.025, score: 0.999999, color: '#4caf50' },
204
- { name: '0.944189368', minValue: 0.025, maxValue: 0.05, score: 0.944189368, color: '#4caf50' },
205
- { name: '0.888379735', minValue: 0.05, maxValue: 0.075, score: 0.888379735, color: '#4caf50' }
206
- ]
207
- break
208
- case 'clear':
209
- this.grades = []
210
- break
106
+ if (!this._validateGrades()) {
107
+ return
211
108
  }
212
- this.isDirty = true
213
- this.requestUpdate()
214
- }
215
109
 
216
- async _save() {
217
- try {
218
- // 등급 유효성 검사
219
- if (!this._validateGrades()) {
220
- return
221
- }
222
-
223
- // 정렬 (minValue 기준)
224
- this.grades.sort((a, b) => a.minValue - b.minValue)
225
-
226
- this.dispatchEvent(
227
- new CustomEvent('grades-updated', {
228
- detail: {
229
- kpiId: this.kpi.id,
230
- grades: this.grades
110
+ if (!deepEquals(this.kpi?.grades, this.grades)) {
111
+ try {
112
+ const response = await client.mutate({
113
+ mutation: gql`
114
+ mutation ($id: String!, $patch: KpiPatch!) {
115
+ updateKpi(id: $id, patch: $patch) {
116
+ id
117
+ name
118
+ grades
119
+ }
120
+ }
121
+ `,
122
+ variables: {
123
+ id: this.kpi.id,
124
+ patch: { grades: this.grades }
231
125
  }
232
126
  })
233
- )
234
127
 
235
- this.isDirty = false
236
- } catch (error) {
237
- notify({
238
- message: '등급 저장 중 오류가 발생했습니다.'
239
- })
128
+ this.grades = response.data.updateKpi.grades
129
+ this.grist.fetch()
130
+ } catch (error) {
131
+ notify({ message: '등급 저장 중 오류가 발생했습니다.' })
132
+ }
240
133
  }
241
134
  }
242
135
 
243
136
  _validateGrades(): boolean {
244
- // 최소 1개 등급 필요
245
137
  if (this.grades.length === 0) {
246
- notify({
247
- message: '최소 1개 이상의 등급을 설정해야 합니다.'
248
- })
138
+ notify({ message: '최소 1개 이상의 등급을 설정해야 합니다.' })
249
139
  return false
250
140
  }
251
-
252
- // 등급명 중복 체크
253
141
  const names = this.grades.map(g => g.name)
254
142
  const uniqueNames = new Set(names)
255
143
  if (names.length !== uniqueNames.size) {
256
- notify({
257
- message: '등급명이 중복되었습니다.'
258
- })
144
+ notify({ message: '등급명이 중복되었습니다.' })
259
145
  return false
260
146
  }
261
-
262
- // 값 범위 체크
263
147
  for (let i = 0; i < this.grades.length; i++) {
264
148
  const grade = this.grades[i]
265
149
  if (grade.minValue >= grade.maxValue) {
266
- notify({
267
- message: `등급 "${grade.name}"의 최소값이 최대값보다 크거나 같습니다.`
268
- })
150
+ notify({ message: `등급 "${grade.name}"의 최소값이 최대값보다 크거나 같습니다.` })
269
151
  return false
270
152
  }
271
-
272
- // 연속성 체크
273
153
  if (i > 0) {
274
154
  const prevGrade = this.grades[i - 1]
275
155
  if (prevGrade.maxValue !== grade.minValue) {
276
- notify({
277
- message: `등급 "${prevGrade.name}"과 "${grade.name}" 사이에 간격이 있습니다.`
278
- })
156
+ notify({ message: `등급 "${prevGrade.name}"과 "${grade.name}" 사이에 간격이 있습니다.` })
279
157
  return false
280
158
  }
281
159
  }
282
160
  }
283
-
284
161
  return true
285
162
  }
286
163
 
287
- _cancel() {
288
- // 팝업 닫기
289
- const popup = this.closest('ox-popup') as any
290
- if (popup && popup.close) {
291
- popup.close()
292
- }
164
+ async _deleteGrades() {
165
+ this.grist.deleteSelectedRecords(true)
293
166
  }
294
167
  }
@@ -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
@@ -475,20 +503,33 @@ export class KpiListPage extends connect(store)(localize(i18next)(ScopedElements
475
503
  }
476
504
 
477
505
  async _editGrades(kpi: any) {
506
+ const popup = await openPopup(html` <kpi-grade-editor .kpi=${kpi}></kpi-grade-editor> `, {
507
+ title: `${kpi.name} - 등급 설정`,
508
+ size: 'large'
509
+ })
510
+
511
+ popup.onclosed = () => {
512
+ this.grist.fetch()
513
+ }
514
+ }
515
+
516
+ async _editViz(kpi: any) {
478
517
  const popup = await openPopup(
479
518
  html`
480
- <kpi-grade-editor .kpi=${kpi} @grades-updated=${(e: any) => this._onGradesUpdated(e.detail)}></kpi-grade-editor>
519
+ <kpi-viz-editor
520
+ .kpi=${kpi}
521
+ .onSave=${(vizType: string, vizMeta: any) => this._onVizUpdated(kpi.id, vizType, vizMeta)}
522
+ .onCancel=${() => popup.close()}
523
+ ></kpi-viz-editor>
481
524
  `,
482
525
  {
483
- title: `${kpi.name} - 등급 설정`,
526
+ title: `${kpi.name} - 시각화 설정`,
484
527
  size: 'large'
485
528
  }
486
529
  )
487
530
  }
488
531
 
489
- async _onGradesUpdated(detail: any) {
490
- const { kpiId, grades } = detail
491
-
532
+ async _onVizUpdated(kpiId: string, vizType: string, vizMeta: any) {
492
533
  try {
493
534
  const response = await client.mutate({
494
535
  mutation: gql`
@@ -496,25 +537,26 @@ export class KpiListPage extends connect(store)(localize(i18next)(ScopedElements
496
537
  updateKpi(id: $id, patch: $patch) {
497
538
  id
498
539
  name
499
- grades
540
+ vizType
541
+ vizMeta
500
542
  }
501
543
  }
502
544
  `,
503
545
  variables: {
504
546
  id: kpiId,
505
- patch: { grades }
547
+ patch: { vizType, vizMeta }
506
548
  }
507
549
  })
508
550
 
509
551
  if (!response.errors) {
510
552
  this.grist.fetch()
511
553
  notify({
512
- message: '등급 설정이 성공적으로 업데이트되었습니다.'
554
+ message: '시각화 설정이 성공적으로 업데이트되었습니다.'
513
555
  })
514
556
  }
515
557
  } catch (error) {
516
558
  notify({
517
- message: '등급 설정 업데이트 중 오류가 발생했습니다.'
559
+ message: '시각화 설정 업데이트 중 오류가 발생했습니다.'
518
560
  })
519
561
  }
520
562
  }