@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.
@@ -194,11 +194,13 @@ export class KpiVizEditor extends localize(i18next)(LitElement) {
194
194
  }
195
195
 
196
196
  _renderPreview() {
197
- const kpiValue = this.kpi?.value?.value || 75
198
- const targetValue = this.kpi?.targetValue || 100
199
- const unit = this.kpi?.unit || ''
197
+ const kpiValue = this.kpi?.value?.value ?? 75
198
+ const targetValue = this.kpi?.targetValue ?? 100
199
+ const unit = this.kpi?.unit ?? ''
200
200
  const color = this.vizMeta.color || '#2196f3'
201
201
  const icon = this.vizMeta.icon || 'trending_up'
202
+ const min = this.vizMeta.minValue ?? 0
203
+ const max = this.vizMeta.maxValue ?? 100
202
204
 
203
205
  switch (this.selectedVizType) {
204
206
  case 'CARD':
@@ -213,22 +215,161 @@ export class KpiVizEditor extends localize(i18next)(LitElement) {
213
215
  </div>
214
216
  </div>
215
217
  `
216
- case 'GAUGE':
217
- const percentage = Math.min((kpiValue / targetValue) * 100, 100)
218
+ case 'GAUGE': {
219
+ const value = Math.max(min, Math.min(kpiValue, max))
220
+ const percent = max - min > 0 ? (value - min) / (max - min) : 0
221
+ const r = 60
222
+ const cx = 90
223
+ const cy = 90
224
+ const startX = cx - r
225
+ const startY = cy
226
+ const endX = cx + r * Math.cos(Math.PI * (1 - percent))
227
+ const endY = cy - r * Math.sin(Math.PI * (1 - percent))
228
+ const needleAngle = Math.PI - Math.PI * percent
229
+ const needleX = cx + r * Math.cos(needleAngle)
230
+ const needleY = cy - r * Math.sin(needleAngle)
218
231
  return html`
219
232
  <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};"
233
+ <svg width="180" height="110" viewBox="0 0 180 110">
234
+ <!-- 배경 arc -->
235
+ <path
236
+ d="M${startX},${startY} A${r},${r} 0 0,1 ${cx + r},${cy}"
237
+ fill="none"
238
+ stroke="#e0e0e0"
239
+ stroke-width="16"
240
+ />
241
+ <!-- 값 arc -->
242
+ <path
243
+ d="M${startX},${startY} A${r},${r} 0 0,1 ${endX},${endY}"
244
+ fill="none"
245
+ stroke="${color}"
246
+ stroke-width="16"
247
+ />
248
+ <!-- 바늘 -->
249
+ <line x1="${cx}" y1="${cy}" x2="${needleX}" y2="${needleY}" stroke="#333" stroke-width="4" />
250
+ <!-- 중심 원 -->
251
+ <circle cx="${cx}" cy="${cy}" r="7" fill="#333" />
252
+ <!-- 중앙값 -->
253
+ <text x="${cx}" y="${cy - 25}" text-anchor="middle" font-size="22" fill="${color}" font-weight="bold">
254
+ ${value}${unit}
255
+ </text>
256
+ <!-- min/max -->
257
+ <text x="${cx - r}" y="${cy + 20}" text-anchor="middle" font-size="12" fill="#888">${min}</text>
258
+ <text x="${cx + r}" y="${cy + 20}" text-anchor="middle" font-size="12" fill="#888">${max}</text>
259
+ </svg>
260
+ </div>
261
+ `
262
+ }
263
+ case 'SPEEDOMETER': {
264
+ const value = Math.max(min, Math.min(kpiValue, max))
265
+ const percent = max - min > 0 ? (value - min) / (max - min) : 0
266
+ const r = 60
267
+ const cx = 90
268
+ const cy = 90
269
+ const startX = cx - r
270
+ const startY = cy
271
+ const endX = cx + r * Math.cos(Math.PI * (1 - percent))
272
+ const endY = cy - r * Math.sin(Math.PI * (1 - percent))
273
+ const needleAngle = Math.PI - Math.PI * percent
274
+ const needleX = cx + r * Math.cos(needleAngle)
275
+ const needleY = cy - r * Math.sin(needleAngle)
276
+ // 중간 눈금 (5개)
277
+ const ticks = Array.from({ length: 6 }, (_, i) => {
278
+ const tickAngle = Math.PI - (Math.PI * i) / 5
279
+ const tx1 = cx + (r - 8) * Math.cos(tickAngle)
280
+ const ty1 = cy - (r - 8) * Math.sin(tickAngle)
281
+ const tx2 = cx + (r + 8) * Math.cos(tickAngle)
282
+ const ty2 = cy - (r + 8) * Math.sin(tickAngle)
283
+ const label = Math.round(min + (max - min) * (i / 5))
284
+ const lx = cx + (r + 22) * Math.cos(tickAngle)
285
+ const ly = cy - (r + 22) * Math.sin(tickAngle) + 6
286
+ return { tx1, ty1, tx2, ty2, label, lx, ly }
287
+ })
288
+ return html`
289
+ <div style="text-align:center;padding:16px;">
290
+ <svg width="200" height="120" viewBox="0 0 200 120">
291
+ <!-- 배경 arc (더 두껍게) -->
292
+ <path
293
+ d="M${startX + 10},${startY} A${r},${r} 0 0,1 ${cx + r + 10},${cy}"
294
+ fill="none"
295
+ stroke="#e0e0e0"
296
+ stroke-width="28"
297
+ />
298
+ <!-- 값 arc -->
299
+ <path
300
+ d="M${startX + 10},${startY} A${r},${r} 0 0,1 ${endX + 10},${endY}"
301
+ fill="none"
302
+ stroke="${color}"
303
+ stroke-width="28"
304
+ />
305
+ <!-- 눈금 -->
306
+ ${ticks.map(
307
+ t =>
308
+ html`<line
309
+ x1="${t.tx1 + 10}"
310
+ y1="${t.ty1}"
311
+ x2="${t.tx2 + 10}"
312
+ y2="${t.ty2}"
313
+ stroke="#888"
314
+ stroke-width="2"
315
+ />`
316
+ )}
317
+ <!-- 눈금 숫자 -->
318
+ ${ticks.map(
319
+ t =>
320
+ html`<text
321
+ x="${t.lx + 10}"
322
+ y="${t.ly}"
323
+ text-anchor="middle"
324
+ font-size="14"
325
+ fill="#333"
326
+ font-weight="bold"
327
+ >${t.label}</text
328
+ >`
329
+ )}
330
+ <!-- 바늘 (빨간색) -->
331
+ <line x1="${cx + 10}" y1="${cy}" x2="${needleX + 10}" y2="${needleY}" stroke="#d32f2f" stroke-width="6" />
332
+ <!-- 중심 원 -->
333
+ <circle cx="${cx + 10}" cy="${cy}" r="13" fill="#333" />
334
+ <!-- 중앙값 -->
335
+ <text
336
+ x="${cx + 10}"
337
+ y="${cy - 32}"
338
+ text-anchor="middle"
339
+ font-size="26"
340
+ fill="${color}"
341
+ font-weight="bold"
226
342
  >
227
- ${kpiValue}${unit}
228
- </div>
229
- </div>
343
+ ${value}${unit}
344
+ </text>
345
+ <!-- min/max 포인트 -->
346
+ <circle cx="${startX + 10}" cy="${startY}" r="7" fill="#fff" stroke="#888" stroke-width="2" />
347
+ <circle cx="${cx + r + 10}" cy="${cy}" r="7" fill="#fff" stroke="#888" stroke-width="2" />
348
+ <!-- min/max 숫자 크게 -->
349
+ <text
350
+ x="${startX + 10}"
351
+ y="${startY + 32}"
352
+ text-anchor="middle"
353
+ font-size="16"
354
+ fill="#333"
355
+ font-weight="bold"
356
+ >
357
+ ${min}
358
+ </text>
359
+ <text
360
+ x="${cx + r + 10}"
361
+ y="${cy + 32}"
362
+ text-anchor="middle"
363
+ font-size="16"
364
+ fill="#333"
365
+ font-weight="bold"
366
+ >
367
+ ${max}
368
+ </text>
369
+ </svg>
230
370
  </div>
231
371
  `
372
+ }
232
373
  case 'PROGRESS':
233
374
  const progressPercentage = Math.min((kpiValue / targetValue) * 100, 100)
234
375
  return html`
@@ -241,6 +382,65 @@ export class KpiVizEditor extends localize(i18next)(LitElement) {
241
382
  </div>
242
383
  </div>
243
384
  `
385
+ case 'THERMOMETER': {
386
+ const value = Math.max(min, Math.min(kpiValue, max))
387
+ const percent = max - min > 0 ? (value - min) / (max - min) : 0
388
+ const barHeight = 120
389
+ const barWidth = 24
390
+ const x = 100
391
+ const yTop = 30
392
+ const yBottom = yTop + barHeight
393
+ const fillY = yBottom - percent * barHeight
394
+ return html`
395
+ <div style="text-align:center;padding:16px;">
396
+ <svg width="200" height="180" viewBox="0 0 200 180">
397
+ <!-- 바깥 테두리 -->
398
+ <rect
399
+ x="${x - barWidth / 2 - 4}"
400
+ y="${yTop - 4}"
401
+ width="${barWidth + 8}"
402
+ height="${barHeight + 8}"
403
+ rx="16"
404
+ fill="#f5f5f5"
405
+ stroke="#bbb"
406
+ stroke-width="2"
407
+ />
408
+ <!-- 빈 막대 -->
409
+ <rect
410
+ x="${x - barWidth / 2}"
411
+ y="${yTop}"
412
+ width="${barWidth}"
413
+ height="${barHeight}"
414
+ rx="12"
415
+ fill="#e0e0e0"
416
+ />
417
+ <!-- 채워진 부분 -->
418
+ <rect
419
+ x="${x - barWidth / 2}"
420
+ y="${fillY}"
421
+ width="${barWidth}"
422
+ height="${yBottom - fillY}"
423
+ rx="12"
424
+ fill="${color}"
425
+ />
426
+ <!-- 하단 구슬 -->
427
+ <circle cx="${x}" cy="${yBottom + 18}" r="22" fill="#e0e0e0" stroke="#bbb" stroke-width="2" />
428
+ <circle cx="${x}" cy="${yBottom + 18}" r="18" fill="${color}" />
429
+ <!-- 현재값 -->
430
+ <text x="${x}" y="${fillY - 12}" text-anchor="middle" font-size="22" fill="${color}" font-weight="bold">
431
+ ${value}${unit}
432
+ </text>
433
+ <!-- min/max -->
434
+ <text x="${x}" y="${yBottom + 52}" text-anchor="middle" font-size="16" fill="#333" font-weight="bold">
435
+ ${min}
436
+ </text>
437
+ <text x="${x}" y="${yTop - 12}" text-anchor="middle" font-size="16" fill="#333" font-weight="bold">
438
+ ${max}
439
+ </text>
440
+ </svg>
441
+ </div>
442
+ `
443
+ }
244
444
  case 'ICON':
245
445
  return html`
246
446
  <div style="text-align:center;padding:16px;">
@@ -1,11 +1,11 @@
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';
8
6
  import { LitElement } from 'lit';
7
+ import { DataGrist } from '@operato/data-grist/ox-grist.js';
8
+ import { FetchOption } from '@operato/data-grist';
9
9
  interface KpiGrade {
10
10
  name: string;
11
11
  minValue: number;
@@ -15,21 +15,21 @@ interface KpiGrade {
15
15
  description?: string;
16
16
  }
17
17
  type KpiGrades = KpiGrade[];
18
- declare const KpiGradeEditor_base: typeof LitElement & import("@open-wc/dedupe-mixin").Constructor<import("@open-wc/scoped-elements/types/src/types").ScopedElementsHost>;
18
+ declare const KpiGradeEditor_base: (new (...args: any[]) => LitElement) & typeof LitElement;
19
19
  export declare class KpiGradeEditor extends KpiGradeEditor_base {
20
20
  static styles: import("lit").CSSResult[];
21
21
  kpi: any;
22
22
  grades: KpiGrades;
23
- isDirty: boolean;
24
- connectedCallback(): void;
23
+ gristConfig: any;
24
+ grist: DataGrist;
25
+ firstUpdated(): Promise<void>;
25
26
  render(): import("lit-html").TemplateResult<1>;
26
- _renderGradeItem(grade: KpiGrade, index: number): import("lit-html").TemplateResult<1>;
27
- _updateGrade(index: number, field: keyof KpiGrade, value: any): void;
28
- _addGrade(): void;
29
- _removeGrade(index: number): void;
30
- _loadTemplate(template: string): void;
31
- _save(): Promise<void>;
27
+ fetchHandler({ page, limit, sorters }: FetchOption): Promise<{
28
+ total: number;
29
+ records: KpiGrades;
30
+ }>;
31
+ _updateGrades(): Promise<void>;
32
32
  _validateGrades(): boolean;
33
- _cancel(): void;
33
+ _deleteGrades(): Promise<void>;
34
34
  }
35
35
  export {};
@@ -2,261 +2,144 @@ import { __decorate, __metadata } from "tslib";
2
2
  import '@material/web/button/elevated-button.js';
3
3
  import '@material/web/button/filled-button.js';
4
4
  import '@material/web/button/text-button.js';
5
- import '@material/web/textfield/outlined-text-field.js';
6
- import '@material/web/select/outlined-select.js';
7
- import '@material/web/select/select-option.js';
8
5
  import '@material/web/icon/icon.js';
6
+ import '@operato/data-grist/ox-grist.js';
7
+ import deepEquals from 'lodash-es/isEqual';
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';
13
+ import { client } from '@operato/graphql';
12
14
  import { notify } from '@operato/layout';
13
- import { CommonHeaderStyles, ScrollbarStyles } from '@operato/styles';
14
- let KpiGradeEditor = class KpiGradeEditor extends ScopedElementsMixin(LitElement) {
15
+ import { CommonHeaderStyles } from '@operato/styles';
16
+ let KpiGradeEditor = class KpiGradeEditor extends localize(i18next)(LitElement) {
15
17
  constructor() {
16
18
  super(...arguments);
17
- this.grades = [];
18
- this.isDirty = false;
19
+ this.kpi = { grades: [] };
20
+ this.grades = this.kpi?.grades || [];
21
+ this.gristConfig = null;
19
22
  }
20
23
  static { this.styles = [
21
24
  CommonHeaderStyles,
22
- ScrollbarStyles,
23
25
  css `
24
26
  :host {
25
27
  display: flex;
26
28
  flex-direction: column;
27
- background-color: var(--md-sys-color-surface, #f4f6fa);
28
- }
29
-
30
- .grade-list {
31
- flex: 1;
32
- margin-bottom: 20px;
33
- overflow-y: auto;
34
- }
35
-
36
- .grade-item {
37
- display: flex;
38
- align-items: center;
39
- gap: 10px;
40
- padding: 10px;
41
- border: 1px solid #ddd;
42
- border-radius: 4px;
43
- margin-bottom: 10px;
44
- background: #f9f9f9;
45
- }
46
29
 
47
- .grade-item:hover {
48
- background: #f0f0f0;
30
+ background-color: var(--md-sys-color-surface);
49
31
  }
50
32
 
51
- .grade-inputs {
52
- display: flex;
53
- gap: 10px;
33
+ ox-grist {
54
34
  flex: 1;
55
35
  }
56
-
57
- .grade-inputs md-outlined-text-field {
58
- flex: 1;
59
- }
60
-
61
- .grade-actions {
62
- display: flex;
63
- gap: 5px;
64
- }
65
-
66
- .footer span {
67
- font-size: 0.8em;
68
- color: var(--md-sys-color-on-surface);
69
- line-height: 1.5;
70
- padding: 10px;
71
- }
72
36
  `
73
37
  ]; }
74
- connectedCallback() {
75
- super.connectedCallback();
38
+ async firstUpdated() {
76
39
  if (this.kpi?.grades) {
77
40
  this.grades = [...this.kpi.grades];
78
41
  }
42
+ this.gristConfig = {
43
+ list: { fields: ['name', 'minValue', 'maxValue', 'score', 'color', 'description'] },
44
+ columns: [
45
+ { type: 'gutter', gutterName: 'row-selector', multiple: true, fixed: true },
46
+ { type: 'gutter', gutterName: 'sequence', fixed: true },
47
+ { type: 'gutter', gutterName: 'button', fixed: true, icon: 'add', handlers: { click: 'record-copy' } },
48
+ { type: 'gutter', gutterName: 'button', fixed: true, icon: 'arrow_upward', handlers: { click: 'move-up' } },
49
+ { type: 'gutter', gutterName: 'button', fixed: true, icon: 'arrow_downward', handlers: { click: 'move-down' } },
50
+ { type: 'string', name: 'name', header: '등급명', record: { editable: true }, width: 100 },
51
+ { type: 'number', name: 'minValue', header: '최소값', record: { editable: true }, width: 100 },
52
+ { type: 'number', name: 'maxValue', header: '최대값', record: { editable: true }, width: 100 },
53
+ { type: 'number', name: 'score', header: '점수', record: { editable: true }, width: 80 },
54
+ { type: 'color', name: 'color', header: '색상', record: { editable: true }, width: 100 },
55
+ { type: 'string', name: 'description', header: '설명', record: { editable: true }, width: 200 }
56
+ ],
57
+ rows: { selectable: { multiple: true } },
58
+ pagination: { infinite: true }
59
+ };
79
60
  }
80
61
  render() {
81
62
  return html `
82
- <div class="grade-list">${this.grades.map((grade, index) => this._renderGradeItem(grade, index))}</div>
83
-
63
+ <ox-grist .mode=${'GRID'} .config=${this.gristConfig} .fetchHandler=${this.fetchHandler.bind(this)}></ox-grist>
84
64
  <div class="footer">
85
- <button type="button" @click=${this._addGrade}><md-icon>add</md-icon>등급 추가</button>
86
65
  <div filler></div>
87
- <button type="button" @click=${this._cancel}><md-icon>cancel</md-icon>취소</button>
88
- <button type="button" @click=${this._save} ?disabled=${!this.isDirty} done><md-icon>save</md-icon>저장</button>
89
- </div>
90
- `;
91
- }
92
- _renderGradeItem(grade, index) {
93
- return html `
94
- <div class="grade-item">
95
- <div class="grade-inputs">
96
- <md-outlined-text-field
97
- label="등급명"
98
- value=${grade.name}
99
- @input=${(e) => this._updateGrade(index, 'name', e.target.value)}
100
- ></md-outlined-text-field>
101
-
102
- <md-outlined-text-field
103
- label="최소값"
104
- type="number"
105
- value=${grade.minValue}
106
- @input=${(e) => this._updateGrade(index, 'minValue', parseFloat(e.target.value))}
107
- ></md-outlined-text-field>
108
-
109
- <md-outlined-text-field
110
- label="최대값"
111
- type="number"
112
- value=${grade.maxValue}
113
- @input=${(e) => this._updateGrade(index, 'maxValue', parseFloat(e.target.value))}
114
- ></md-outlined-text-field>
115
-
116
- <md-outlined-text-field
117
- label="점수"
118
- type="number"
119
- value=${grade.score || ''}
120
- @input=${(e) => this._updateGrade(index, 'score', parseFloat(e.target.value))}
121
- ></md-outlined-text-field>
122
-
123
- <md-outlined-text-field
124
- label="색상"
125
- value=${grade.color || ''}
126
- @input=${(e) => this._updateGrade(index, 'color', e.target.value)}
127
- ></md-outlined-text-field>
128
- </div>
129
-
130
- <div class="grade-actions">
131
- <md-icon-button @click=${() => this._removeGrade(index)}>
132
- <md-icon>delete</md-icon>
133
- </md-icon-button>
134
- </div>
66
+ <button danger @click=${this._deleteGrades.bind(this)}>
67
+ <md-icon>delete</md-icon>${i18next.t('button.delete')}
68
+ </button>
69
+ <button done type="button" @click=${this._updateGrades}><md-icon>save</md-icon>저장</button>
135
70
  </div>
136
71
  `;
137
72
  }
138
- _updateGrade(index, field, value) {
139
- this.grades[index] = { ...this.grades[index], [field]: value };
140
- this.isDirty = true;
141
- this.requestUpdate();
142
- }
143
- _addGrade() {
144
- const newGrade = {
145
- name: '',
146
- minValue: 0,
147
- maxValue: 0,
148
- score: 0,
149
- color: '#4caf50',
150
- description: ''
73
+ async fetchHandler({ page, limit, sorters = [] }) {
74
+ return {
75
+ total: this.grades.length,
76
+ records: this.grades
151
77
  };
152
- this.grades.push(newGrade);
153
- this.isDirty = true;
154
- this.requestUpdate();
155
- }
156
- _removeGrade(index) {
157
- this.grades.splice(index, 1);
158
- this.isDirty = true;
159
- this.requestUpdate();
160
78
  }
161
- _loadTemplate(template) {
162
- switch (template) {
163
- case '5grade':
164
- this.grades = [
165
- { name: 'A', minValue: 0, maxValue: 1.5, score: 95, color: '#4caf50', description: '우수' },
166
- { name: 'B', minValue: 1.5, maxValue: 2.5, score: 85, color: '#ff9800', description: '양호' },
167
- { name: 'C', minValue: 2.5, maxValue: 3.5, score: 75, color: '#ffc107', description: '보통' },
168
- { name: 'D', minValue: 3.5, maxValue: 4.5, score: 65, color: '#ff9800', description: '미흡' },
169
- { name: 'E', minValue: 4.5, maxValue: 999, score: 55, color: '#f44336', description: '불량' }
170
- ];
171
- break;
172
- case '3grade':
173
- this.grades = [
174
- { name: '우수', minValue: 0, maxValue: 1.5, score: 95, color: '#4caf50', description: '목표 달성' },
175
- { name: '양호', minValue: 1.5, maxValue: 2.5, score: 85, color: '#ff9800', description: '기준 달성' },
176
- { name: '미흡', minValue: 2.5, maxValue: 999, score: 75, color: '#f44336', description: '개선 필요' }
177
- ];
178
- break;
179
- case 'continuous':
180
- this.grades = [
181
- { name: '0.999999', minValue: 0, maxValue: 0.025, score: 0.999999, color: '#4caf50' },
182
- { name: '0.944189368', minValue: 0.025, maxValue: 0.05, score: 0.944189368, color: '#4caf50' },
183
- { name: '0.888379735', minValue: 0.05, maxValue: 0.075, score: 0.888379735, color: '#4caf50' }
184
- ];
185
- break;
186
- case 'clear':
187
- this.grades = [];
188
- break;
79
+ async _updateGrades() {
80
+ this.grades = this.grist.dirtyData.records
81
+ .map(patch => {
82
+ const { name, minValue, maxValue, score, color, description } = patch;
83
+ return { name, minValue, maxValue, score, color, description };
84
+ })
85
+ .sort((a, b) => a.minValue - b.minValue);
86
+ if (!this._validateGrades()) {
87
+ return;
189
88
  }
190
- this.isDirty = true;
191
- this.requestUpdate();
192
- }
193
- async _save() {
194
- try {
195
- // 등급 유효성 검사
196
- if (!this._validateGrades()) {
197
- return;
89
+ if (!deepEquals(this.kpi?.grades, this.grades)) {
90
+ try {
91
+ const response = await client.mutate({
92
+ mutation: gql `
93
+ mutation ($id: String!, $patch: KpiPatch!) {
94
+ updateKpi(id: $id, patch: $patch) {
95
+ id
96
+ name
97
+ grades
98
+ }
99
+ }
100
+ `,
101
+ variables: {
102
+ id: this.kpi.id,
103
+ patch: { grades: this.grades }
104
+ }
105
+ });
106
+ this.grades = response.data.updateKpi.grades;
107
+ this.grist.fetch();
108
+ }
109
+ catch (error) {
110
+ notify({ message: '등급 저장 중 오류가 발생했습니다.' });
198
111
  }
199
- // 정렬 (minValue 기준)
200
- this.grades.sort((a, b) => a.minValue - b.minValue);
201
- this.dispatchEvent(new CustomEvent('grades-updated', {
202
- detail: {
203
- kpiId: this.kpi.id,
204
- grades: this.grades
205
- }
206
- }));
207
- this.isDirty = false;
208
- }
209
- catch (error) {
210
- notify({
211
- message: '등급 저장 중 오류가 발생했습니다.'
212
- });
213
112
  }
214
113
  }
215
114
  _validateGrades() {
216
- // 최소 1개 등급 필요
217
115
  if (this.grades.length === 0) {
218
- notify({
219
- message: '최소 1개 이상의 등급을 설정해야 합니다.'
220
- });
116
+ notify({ message: '최소 1개 이상의 등급을 설정해야 합니다.' });
221
117
  return false;
222
118
  }
223
- // 등급명 중복 체크
224
119
  const names = this.grades.map(g => g.name);
225
120
  const uniqueNames = new Set(names);
226
121
  if (names.length !== uniqueNames.size) {
227
- notify({
228
- message: '등급명이 중복되었습니다.'
229
- });
122
+ notify({ message: '등급명이 중복되었습니다.' });
230
123
  return false;
231
124
  }
232
- // 값 범위 체크
233
125
  for (let i = 0; i < this.grades.length; i++) {
234
126
  const grade = this.grades[i];
235
127
  if (grade.minValue >= grade.maxValue) {
236
- notify({
237
- message: `등급 "${grade.name}"의 최소값이 최대값보다 크거나 같습니다.`
238
- });
128
+ notify({ message: `등급 "${grade.name}"의 최소값이 최대값보다 크거나 같습니다.` });
239
129
  return false;
240
130
  }
241
- // 연속성 체크
242
131
  if (i > 0) {
243
132
  const prevGrade = this.grades[i - 1];
244
133
  if (prevGrade.maxValue !== grade.minValue) {
245
- notify({
246
- message: `등급 "${prevGrade.name}"과 "${grade.name}" 사이에 간격이 있습니다.`
247
- });
134
+ notify({ message: `등급 "${prevGrade.name}"과 "${grade.name}" 사이에 간격이 있습니다.` });
248
135
  return false;
249
136
  }
250
137
  }
251
138
  }
252
139
  return true;
253
140
  }
254
- _cancel() {
255
- // 팝업 닫기
256
- const popup = this.closest('ox-popup');
257
- if (popup && popup.close) {
258
- popup.close();
259
- }
141
+ async _deleteGrades() {
142
+ this.grist.deleteSelectedRecords(true);
260
143
  }
261
144
  };
262
145
  __decorate([
@@ -270,7 +153,11 @@ __decorate([
270
153
  __decorate([
271
154
  state(),
272
155
  __metadata("design:type", Object)
273
- ], KpiGradeEditor.prototype, "isDirty", void 0);
156
+ ], KpiGradeEditor.prototype, "gristConfig", void 0);
157
+ __decorate([
158
+ query('ox-grist'),
159
+ __metadata("design:type", DataGrist)
160
+ ], KpiGradeEditor.prototype, "grist", void 0);
274
161
  KpiGradeEditor = __decorate([
275
162
  customElement('kpi-grade-editor')
276
163
  ], KpiGradeEditor);