@things-factory/kpi 9.0.16 → 9.0.18

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.
@@ -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
  }
@@ -503,47 +503,21 @@ export class KpiListPage extends connect(store)(localize(i18next)(ScopedElements
503
503
  }
504
504
 
505
505
  async _editGrades(kpi: any) {
506
- const popup = await openPopup(
507
- html`
508
- <kpi-grade-editor .kpi=${kpi} @grades-updated=${(e: any) => this._onGradesUpdated(e.detail)}></kpi-grade-editor>
509
- `,
510
- {
511
- title: `${kpi.name} - 등급 설정`,
512
- size: 'large'
513
- }
514
- )
515
- }
506
+ if (!kpi.id) {
507
+ notify({
508
+ message: 'KPI를 먼저 저장한 후에 등급 설정을 할 수 있습니다.'
509
+ })
516
510
 
517
- async _onGradesUpdated(detail: any) {
518
- const { kpiId, grades } = detail
511
+ return
512
+ }
519
513
 
520
- try {
521
- const response = await client.mutate({
522
- mutation: gql`
523
- mutation ($id: String!, $patch: KpiPatch!) {
524
- updateKpi(id: $id, patch: $patch) {
525
- id
526
- name
527
- grades
528
- }
529
- }
530
- `,
531
- variables: {
532
- id: kpiId,
533
- patch: { grades }
534
- }
535
- })
514
+ const popup = await openPopup(html` <kpi-grade-editor .kpi=${kpi}></kpi-grade-editor> `, {
515
+ title: `${kpi.name} - 등급 설정`,
516
+ size: 'large'
517
+ })
536
518
 
537
- if (!response.errors) {
538
- this.grist.fetch()
539
- notify({
540
- message: '등급 설정이 성공적으로 업데이트되었습니다.'
541
- })
542
- }
543
- } catch (error) {
544
- notify({
545
- message: '등급 설정 업데이트 중 오류가 발생했습니다.'
546
- })
519
+ popup.onclosed = () => {
520
+ this.grist.fetch()
547
521
  }
548
522
  }
549
523