@things-factory/kpi 10.0.0-zeta.9 → 10.0.1
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.
- package/RFC-production-readiness.md +161 -0
- package/client/pages/kpi-metric/kpi-metric-list-page.ts +27 -1
- package/client/viewparts/kpi-metric-input.ts +130 -0
- package/client/viewparts/kpi-metric-meta-editor.ts +277 -0
- package/client/viewparts/metric-input-hint.ts +41 -0
- package/client/viewparts/metric-input.test.ts +49 -0
- package/client/viewparts/metric-renderer-registry.ts +41 -0
- package/client/viewparts/ox-grist-editor-metric-input-hint.ts +83 -0
- package/dist-client/pages/kpi-metric/kpi-metric-list-page.js +28 -3
- package/dist-client/pages/kpi-metric/kpi-metric-list-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/viewparts/kpi-metric-input.d.ts +23 -0
- package/dist-client/viewparts/kpi-metric-input.js +138 -0
- package/dist-client/viewparts/kpi-metric-input.js.map +1 -0
- package/dist-client/viewparts/kpi-metric-meta-editor.d.ts +36 -0
- package/dist-client/viewparts/kpi-metric-meta-editor.js +319 -0
- package/dist-client/viewparts/kpi-metric-meta-editor.js.map +1 -0
- package/dist-client/viewparts/metric-input-hint.d.ts +26 -0
- package/dist-client/viewparts/metric-input-hint.js +19 -0
- package/dist-client/viewparts/metric-input-hint.js.map +1 -0
- package/dist-client/viewparts/metric-input.test.d.ts +1 -0
- package/dist-client/viewparts/metric-input.test.js +43 -0
- package/dist-client/viewparts/metric-input.test.js.map +1 -0
- package/dist-client/viewparts/metric-renderer-registry.d.ts +18 -0
- package/dist-client/viewparts/metric-renderer-registry.js +29 -0
- package/dist-client/viewparts/metric-renderer-registry.js.map +1 -0
- package/dist-client/viewparts/ox-grist-editor-metric-input-hint.d.ts +19 -0
- package/dist-client/viewparts/ox-grist-editor-metric-input-hint.js +76 -0
- package/dist-client/viewparts/ox-grist-editor-metric-input-hint.js.map +1 -0
- package/dist-server/calculator/calculator.test.d.ts +1 -0
- package/dist-server/calculator/calculator.test.js +126 -0
- package/dist-server/calculator/calculator.test.js.map +1 -0
- package/dist-server/calculator/index.d.ts +2 -0
- package/dist-server/calculator/index.js +2 -0
- package/dist-server/calculator/index.js.map +1 -1
- package/dist-server/calculator/parser.d.ts +13 -0
- package/dist-server/calculator/parser.js +55 -0
- package/dist-server/calculator/parser.js.map +1 -1
- package/dist-server/calculator/score.d.ts +21 -0
- package/dist-server/calculator/score.js +36 -0
- package/dist-server/calculator/score.js.map +1 -0
- package/dist-server/calculator/score.test.d.ts +1 -0
- package/dist-server/calculator/score.test.js +47 -0
- package/dist-server/calculator/score.test.js.map +1 -0
- package/dist-server/calculator/validate.d.ts +15 -0
- package/dist-server/calculator/validate.js +30 -0
- package/dist-server/calculator/validate.js.map +1 -0
- package/dist-server/calculator/validate.test.d.ts +1 -0
- package/dist-server/calculator/validate.test.js +45 -0
- package/dist-server/calculator/validate.test.js.map +1 -0
- package/dist-server/migrations/1752190849680-seed-kpi-metrics.js +1 -0
- package/dist-server/migrations/1752190849680-seed-kpi-metrics.js.map +1 -1
- package/dist-server/migrations/1752192090200-add-meta-to-kpi-metric.d.ts +10 -0
- package/dist-server/migrations/1752192090200-add-meta-to-kpi-metric.js +26 -0
- package/dist-server/migrations/1752192090200-add-meta-to-kpi-metric.js.map +1 -0
- package/dist-server/migrations/1752192090201-backfill-kpi-metric-meta.d.ts +11 -0
- package/dist-server/migrations/1752192090201-backfill-kpi-metric-meta.js +54 -0
- package/dist-server/migrations/1752192090201-backfill-kpi-metric-meta.js.map +1 -0
- package/dist-server/migrations/seed-data/kpi-metrics-seed.json +104 -41
- package/dist-server/service/kpi/aggregate-kpi.js +23 -7
- package/dist-server/service/kpi/aggregate-kpi.js.map +1 -1
- package/dist-server/service/kpi/kpi-formula.service.d.ts +18 -8
- package/dist-server/service/kpi/kpi-formula.service.js +27 -16
- package/dist-server/service/kpi/kpi-formula.service.js.map +1 -1
- package/dist-server/service/kpi/kpi-mutation.js +6 -0
- package/dist-server/service/kpi/kpi-mutation.js.map +1 -1
- package/dist-server/service/kpi-metric/kpi-metric-meta.d.ts +46 -0
- package/dist-server/service/kpi-metric/kpi-metric-meta.js +27 -0
- package/dist-server/service/kpi-metric/kpi-metric-meta.js.map +1 -0
- package/dist-server/service/kpi-metric/kpi-metric-meta.test.d.ts +1 -0
- package/dist-server/service/kpi-metric/kpi-metric-meta.test.js +43 -0
- package/dist-server/service/kpi-metric/kpi-metric-meta.test.js.map +1 -0
- package/dist-server/service/kpi-metric/kpi-metric-type.d.ts +2 -0
- package/dist-server/service/kpi-metric/kpi-metric-type.js +14 -0
- package/dist-server/service/kpi-metric/kpi-metric-type.js.map +1 -1
- package/dist-server/service/kpi-metric/kpi-metric.d.ts +2 -0
- package/dist-server/service/kpi-metric/kpi-metric.js +8 -0
- package/dist-server/service/kpi-metric/kpi-metric.js.map +1 -1
- package/dist-server/service/kpi-value/kpi-value-score.service.d.ts +5 -7
- package/dist-server/service/kpi-value/kpi-value-score.service.js +7 -39
- package/dist-server/service/kpi-value/kpi-value-score.service.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +18 -18
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# RFC: `@things-factory/kpi` 프로덕션 완성도 향상
|
|
2
|
+
|
|
3
|
+
- **상태**: Draft
|
|
4
|
+
- **대상 모듈**: `@things-factory/kpi` (v10.0.0-zeta.10)
|
|
5
|
+
- **목적**: 이 프레임워크 모듈 자체의 완성도·재사용성 향상. (특정 앱 — 예: `@dssp/dkpi` — 을 위한 것이 아님)
|
|
6
|
+
- **작성 근거**: 모듈 소스 코드 감사(2026-07-14). 인용된 `file:line`은 감사 시점 기준.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. 배경 & 문제의식
|
|
11
|
+
|
|
12
|
+
`@things-factory/kpi`는 **계층형 KPI + metric 집계 + 통계**를 갖춘 범용 KPI 프레임워크다. 골격(엔티티, AST 계산기, 통계 엔진)은 견고하다. 그러나 실전 적용(예: DSSP KPI POC)에서 **앱이 프레임워크 관심사를 대신 떠안는** 패턴이 반복 관찰됐다:
|
|
13
|
+
|
|
14
|
+
- metric ↔ 소스 바인딩(`collectType/dataSet/fieldName`)이 있으나 **쓰기 불편해 앱이 하드코딩 매핑으로 우회**.
|
|
15
|
+
- 값 입력 UI가 **metric 이름 추측·하드코딩 위젯** 기반 → admin이 metric을 추가/변경하면 UI가 자동 대응 못 함.
|
|
16
|
+
- `(domain, metric, valueDate, org)` upsert·`valueDate` 규칙이 **앱 코드로 샘**.
|
|
17
|
+
- "데이터 없음"과 "계산 실패"가 구분되지 않아 앱에서 시나리오가 중단(HALT)되는 사고.
|
|
18
|
+
|
|
19
|
+
프레임워크의 존재 이유는 **"코드 없이 런타임 설정으로 KPI를 구성"** 하는 것이다. 위 gap들은 그 전제를 프로덕션 규모에서 무너뜨린다. 본 RFC는 이를 메우기 위한 우선순위·설계를 제안한다.
|
|
20
|
+
|
|
21
|
+
## 2. 목표 / 비목표
|
|
22
|
+
|
|
23
|
+
**목표**
|
|
24
|
+
- 선언(정의)만으로 **수집·입력 UI·검증·계산**이 자동으로 따라오는 구조.
|
|
25
|
+
- 계산의 **정확성·안전성·테스트가능성** 확보.
|
|
26
|
+
- metric/kpi 정의 **진화(버전)** 를 데이터 정합성 손상 없이 수용.
|
|
27
|
+
|
|
28
|
+
**비목표**
|
|
29
|
+
- 건설 등 특정 도메인 기능 추가(범용성 유지).
|
|
30
|
+
- 기존 데이터 파괴적 마이그레이션(모두 하위호환 경로 제공).
|
|
31
|
+
- 대시보드/시각화 렌더링 자체(모듈은 정의·저장, 표현은 소비자 몫 — 단 vizMeta 스펙은 문서화).
|
|
32
|
+
|
|
33
|
+
## 3. 현재 상태 요약 (감사 기반)
|
|
34
|
+
|
|
35
|
+
| 영역 | 상태 | 완성도 | 핵심 gap |
|
|
36
|
+
|---|---|---|---|
|
|
37
|
+
| 엔티티 모델 | 있음 | 70% | metric 버전 추적 부재, 렌더힌트 스키마 부재, scope 필드 중복(`group` vs `kpiOrgScope`) |
|
|
38
|
+
| 계산 엔진 | 있음(AST 파서/평가기 분리) | 60% | **집계 경로가 `Function()` eval 사용**(안전한 AST 평가기 미사용), 논리연산 부재, 전량 재계산, 결측/실패 미구분 |
|
|
39
|
+
| Score 계산 | 있음 | 75% | scoreFormula 검증 부재, formula 변경 후 자동 재계산 트리거 없음, `grades`(deprecated) 혼재 |
|
|
40
|
+
| 수집 AUTO/MANUAL | 있음 | 70~80% | EXTERNAL 미구현, metric `schedule` 자동 호출 미연결, collectType별 UI 분기 없음 |
|
|
41
|
+
| 통계 | 있음 | 85% | N+1 쿼리 |
|
|
42
|
+
| Alert | GraphQL 타입만 | 10% | rule/trigger 엔진 전무 (`kpi-alert-type.ts`, DB 엔티티 아님) |
|
|
43
|
+
| 클라이언트 UI | 부분 | 40% | **metadata 기반 제네릭 렌더러 부재**, metric 정의 에디터 부재, 동적 metric 코드수정 없이 렌더 불가 |
|
|
44
|
+
| 버전관리 | 부분 | 50% | Kpi는 `@VersionColumn`+`KpiHistory` 있음, **KpiMetricValue는 metric 버전 미스탬프** |
|
|
45
|
+
| 테스트 | 없음 | 0% | calculator/formula/aggregate 전부 무테스트 |
|
|
46
|
+
| 보안 | 부분 | 50% | **formula `Function()` eval → injection 위험** (`aggregate-kpi.ts:53`) |
|
|
47
|
+
|
|
48
|
+
강점(유지): AST 파서/평가기 분리(`server/calculator/parser.ts`, `evaluator.ts`, `functions.ts`), 통계 엔진(`kpi-statistic-calculation.service.ts`), 계층형 KPI + KpiHistory 스냅샷, 다차원 scope(KpiScope level1~5).
|
|
49
|
+
|
|
50
|
+
## 4. 설계 원칙
|
|
51
|
+
|
|
52
|
+
1. **선언적, 비-마법적(declarative, not magical)**: 동작은 정의(메타데이터)에서 결정한다. 이름 추측·값 추론 금지.
|
|
53
|
+
2. **프레임워크가 관심사를 소유**: valueDate/upsert/집계 규칙은 모듈 API 뒤로 숨긴다. 앱은 "무엇을/언제"만 말한다.
|
|
54
|
+
3. **80/20 + 탈출구**: 제네릭이 80%를 자동 처리, 예외는 커스텀 레지스트리로.
|
|
55
|
+
4. **결측 ≠ 실패**: 데이터 없음은 정상 상태(1급), 실패는 별도.
|
|
56
|
+
5. **진화 가능**: 정의 변경이 과거 데이터를 깨지 않도록 버전 스탬프.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 5. 제안 (우선순위별)
|
|
61
|
+
|
|
62
|
+
### P0 — 정확성·안전성·신뢰 (선행 필수)
|
|
63
|
+
|
|
64
|
+
#### P0-1. 안전한 단일 계산 경로로 통일 (eval 제거)
|
|
65
|
+
- **문제**: 안전한 AST 파서/평가기(`server/calculator/*`)가 있음에도, 집계는 `new Function(...)`로 formula를 eval한다 → **코드 인젝션 위험 + 검증 불가 + silent fail**. 근거: `server/service/kpi/aggregate-kpi.ts:53`.
|
|
66
|
+
- **제안**: 모든 formula/scoreFormula 평가를 **AST 평가기 하나로 통일**하고 `Function()` 경로 삭제. 평가기에 논리/비교 연산(`&&`, `||`, `>`, `<`, `>=`, `<=`, `==`), 삼항 대체(`if()`는 유지) 추가.
|
|
67
|
+
- **효과**: 보안 + 정적 검증 가능 + 결정적 테스트 가능.
|
|
68
|
+
|
|
69
|
+
#### P0-2. Formula 검증기 완성
|
|
70
|
+
- **문제**: `validateFormula()`가 metric 존재·순환참조는 보나 **문법(괄호/토큰)·함수 존재 검증은 주석만**. 근거: `server/service/kpi/kpi-formula.service.ts:33-65`.
|
|
71
|
+
- **제안**: 파서를 재사용해 (a) 구문 파싱 성공, (b) 사용 함수가 `functions.ts` 화이트리스트에 존재, (c) 미정의 변수 없음 을 **정의 저장 시점에 검증·거부**. 저장 mutation에서 강제.
|
|
72
|
+
|
|
73
|
+
#### P0-3. 결측 vs 실패 시맨틱 + 상태/프로버넌스
|
|
74
|
+
- **문제**: metric 값 부재는 저장 안 함, 계산 실패도 `value=null` 후 skip → **구분 불가·추적 불가**. 근거: `aggregate-kpi.ts:60`, `kpi-value-score.service.ts`(null 반환).
|
|
75
|
+
- **제안**: `KpiValue`/`KpiMetricValue`에 **상태 개념** 도입 — `OK | NO_DATA | ERROR`(+ `meta.error`). 계산기는 예외를 삼키지 말고 `ERROR` 상태 + 사유 기록. 조회/집계는 `NO_DATA`를 정상 전파(null propagation 규칙 명문화).
|
|
76
|
+
- **효과**: 앱이 "없음"에 중단하지 않고 진행(HALT 방지), 운영 모니터링 가능.
|
|
77
|
+
|
|
78
|
+
#### P0-4. Metric 버전 스탬프
|
|
79
|
+
- **문제**: `Kpi`는 `@VersionColumn`+`KpiHistory` 스냅샷이 있으나(`kpi.ts`, `kpi-history.ts`), **`KpiMetricValue`는 metric 버전을 안 남김**(`kpi-metric-value.ts:1-93`). metric 정의(periodType/unit/dataSet) 변경 후 과거 값 해석 불가.
|
|
80
|
+
- **제안**: `KpiMetric`에 version, `KpiMetricValue`에 기록 시점 `metricVersion` 스탬프. 정의 변경 시 `KpiMetricHistory` 스냅샷(기존 KpiHistory 패턴 재사용).
|
|
81
|
+
|
|
82
|
+
#### P0-5. 테스트 스위트 구축
|
|
83
|
+
- **문제**: `*.test.ts`/`*.spec.ts` 전무, package.json test script 없음.
|
|
84
|
+
- **제안**: 최우선으로 **순수 로직**(parser, evaluator, functions, formula.service, score.service, aggregate)에 단위테스트. CI 게이트. (P0-1~3의 회귀 안전망)
|
|
85
|
+
|
|
86
|
+
### P1 — 선언적 자동화 (프레임워크의 본질)
|
|
87
|
+
|
|
88
|
+
#### P1-1. 선언적 정의 스키마 + 렌더 힌트 (핵심 레버)
|
|
89
|
+
- **문제**: 정의에 "입력/표현을 어떻게 할지" 메타가 없어, UI가 **이름 추측**(예: scoreType 추론 `kpi-list-page.ts:54-60`)과 하드코딩에 의존. `unit`도 자유 문자열.
|
|
90
|
+
- **제안**: metric/kpi 정의를 확장:
|
|
91
|
+
```ts
|
|
92
|
+
// KpiMetric 확장
|
|
93
|
+
kind: 'attribute' | 'performance' // 고정속성 vs 시계열성과
|
|
94
|
+
source: { type: 'entityField' | 'dataset' | 'external' | 'manual', ... }
|
|
95
|
+
input: { widget: 'number'|'rating'|'select'|'date'|'text',
|
|
96
|
+
min?, max?, step?, options?, required? } // 렌더/검증 힌트
|
|
97
|
+
aggregation?: 'sum'|'avg'|'last'|'min'|'max'
|
|
98
|
+
```
|
|
99
|
+
- `kind`는 **reference(고정 속성) vs performance(성과)** 를 모델에 각인 → 소비 앱의 "속성↔KPI 양방향 sync" 같은 안티패턴 원천 차단.
|
|
100
|
+
- `input`은 위젯·검증을 **정의가 소유**. (별점 0.5→1점 같은 요구가 코드가 아니라 `input.step`로 해결)
|
|
101
|
+
|
|
102
|
+
#### P1-2. Metadata-driven 제네릭 렌더러 + 커스텀 레지스트리
|
|
103
|
+
- **문제**: 값 입력 UI가 하드코딩, 동적 metric은 코드 수정 없이 못 뜬다(`kpi-metric-value-manual-entry-form.ts`, `kpi-value-editor-page.ts`).
|
|
104
|
+
- **제안**: `input` 힌트를 읽어 위젯을 렌더하는 **제네릭 렌더러 1개** + 예외용 **custom renderer registry**(80/20). periodType(단일/월별그리드/기간) 인지 레이아웃 포함.
|
|
105
|
+
|
|
106
|
+
#### P1-3. 값 인입 API (upsert/valueDate 캡슐화)
|
|
107
|
+
- **문제**: `(domain,metric,valueDate,org)` upsert·MONTH/ALLTIME valueDate 규칙이 앱으로 샘.
|
|
108
|
+
- **제안**: `kpi.record({ metric, value, org, period, at? })` 단일 진입점이 valueDate 정규화·upsert·버전스탬프·상태를 내부 처리. period/bucket을 문자열 관례 대신 타입으로.
|
|
109
|
+
|
|
110
|
+
#### P1-4. 수집(collector) 추상화
|
|
111
|
+
- **문제**: `collectType` enum은 있으나 EXTERNAL 미구현, metric `schedule` 자동 호출 미연결(스케줄은 KPI에만 `registerSchedule` — `kpi-mutation.ts:76-110`), 수집이 앱/시나리오에 의존.
|
|
112
|
+
- **제안**: metric별 **Collector 인터페이스**(`collect(): {found, value, provenance}`)와 스케줄러 연동을 모듈이 제공. AUTO(dataset)·MANUAL은 기본 제공, EXTERNAL/IMPORT는 소비자가 collector 등록. "found=false"는 정상.
|
|
113
|
+
|
|
114
|
+
### P2 — 정리·운영
|
|
115
|
+
|
|
116
|
+
- **P2-1. Deprecated 필드 정리**: `grades`→`scoreFormula`(`kpi-value-score.service.ts:51`), `KpiValue.group`(string)→`kpiOrgScope`(ref). 마이그레이션 + 이중지원 종료 계획.
|
|
117
|
+
- **P2-2. Scope 필드 통합**: `org`/`group`/`kpiOrgScope`/`project`를 **하나의 scope(dimension) 모델**로 수렴, 나머지 deprecate.
|
|
118
|
+
- **P2-3. 통계 N+1 제거**: `kpi-statistic-calculation.service.ts:154-210`, `aggregate-kpi-metric.ts:66` batch/조인.
|
|
119
|
+
- **P2-4. Alert rule 엔진**: 현 `kpi-alert-type.ts`는 타입만 → 조건·트리거·저장 엔티티 설계.
|
|
120
|
+
- **P2-5. 권한 세분화**: 현 `kpi:mutation|query` 2단계 → metric정의/값입력/조회 분리.
|
|
121
|
+
- **P2-6. 문서화**: formula/scoreFormula 문법·함수 목록, `vizMeta` 스펙(vizType별 필드), KpiScope(scope01~05) taxonomy, cron 가이드.
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## 6. 마이그레이션 & 하위호환
|
|
126
|
+
|
|
127
|
+
- 신규 필드(kind/source/input, metricVersion, status)는 **nullable + 기본값**으로 추가 → 기존 데이터 무손상.
|
|
128
|
+
- 기존 정의에 `kind`/`input` 미지정 시: 렌더러는 **안전한 폴백**(number 위젯) + 콘솔 경고. (점진 채택)
|
|
129
|
+
- eval→AST 전환은 기존 formula 문법을 **파서가 수용하는지 회귀테스트**(P0-5) 후 전환. 미수용 문법은 마이그레이션 스크립트로 정규화.
|
|
130
|
+
- `grades`/`group`은 **읽기 이중지원 유지**하며 한 마이너 버전 뒤 제거 공지.
|
|
131
|
+
|
|
132
|
+
## 7. 리스크
|
|
133
|
+
|
|
134
|
+
- **eval→AST 전환**이 기존 프로덕션 formula를 깨뜨릴 수 있음 → 회귀 코퍼스 + 파서 수용성 검증 선행(P0-5, P0-1 순서 고정).
|
|
135
|
+
- 선언 스키마 확장은 **소비 앱들이 서서히 채택**해야 효과 → 폴백을 보장해 강제 마이그레이션 회피.
|
|
136
|
+
- 범용성 훼손 위험 → 모든 신규는 **도메인 중립 메타**만(건설 특화 금지).
|
|
137
|
+
|
|
138
|
+
## 8. 단계 로드맵
|
|
139
|
+
|
|
140
|
+
- **Phase 1 (신뢰 기반)**: P0-5 테스트 → P0-1 eval 제거/통일 → P0-2 검증기 → P0-3 상태 시맨틱 → P0-4 버전 스탬프.
|
|
141
|
+
- **Phase 2 (선언 자동화)**: P1-1 스키마 → P1-3 record API → P1-2 제네릭 렌더러 → P1-4 collector.
|
|
142
|
+
- **Phase 3 (정리·운영)**: P2-*.
|
|
143
|
+
|
|
144
|
+
각 Phase는 독립 배포 가능하며 하위호환을 유지한다.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 부록 A. 핵심 근거 (file:line)
|
|
149
|
+
|
|
150
|
+
- 계산 eval: `server/service/kpi/aggregate-kpi.ts:53`, null skip `:60`
|
|
151
|
+
- AST 계산기(재사용 대상): `server/calculator/parser.ts`, `evaluator.ts`, `functions.ts:4-67`
|
|
152
|
+
- formula 검증 미흡: `server/service/kpi/kpi-formula.service.ts:33-65`
|
|
153
|
+
- score/ grades deprecated: `server/service/kpi-value/kpi-value-score.service.ts:51`
|
|
154
|
+
- metric 정의(버전/렌더힌트 부재): `server/service/kpi-metric/kpi-metric.ts:1-138`
|
|
155
|
+
- metric value(버전 미스탬프): `server/service/kpi-metric-value/kpi-metric-value.ts:1-93`
|
|
156
|
+
- scope 중복: `server/service/kpi-value/kpi-value.ts` (`group` vs `kpiOrgScope`)
|
|
157
|
+
- 하드코딩 UI: `client/pages/kpi-metric-value/kpi-metric-value-manual-entry-form.ts`, `client/pages/kpi-value/kpi-value-editor-page.ts`
|
|
158
|
+
- 이름 추측: `client/pages/kpi/kpi-list-page.ts:54-60`
|
|
159
|
+
- alert 타입만: `server/service/kpi-alert/kpi-alert-type.ts:1-20`
|
|
160
|
+
- 통계 N+1: `server/service/kpi-statistic/kpi-statistic-calculation.service.ts:154-210`
|
|
161
|
+
- 스케줄(KPI만): `server/service/kpi/kpi-mutation.ts:76-110`
|
|
@@ -9,7 +9,7 @@ import { PageView } from '@operato/shell'
|
|
|
9
9
|
import { css, html, PropertyValues } from 'lit'
|
|
10
10
|
import { customElement, property, query, state } from 'lit/decorators.js'
|
|
11
11
|
import { ScopedElementsMixin } from '@open-wc/scoped-elements'
|
|
12
|
-
import { ColumnConfig, DataGrist, FetchOption } from '@operato/data-grist'
|
|
12
|
+
import { ColumnConfig, DataGrist, FetchOption, registerEditor } from '@operato/data-grist'
|
|
13
13
|
import { client } from '@operato/graphql'
|
|
14
14
|
import { i18next, localize } from '@operato/i18n'
|
|
15
15
|
import { notify, openPopup } from '@operato/layout'
|
|
@@ -20,6 +20,10 @@ import { p13n } from '@operato/p13n'
|
|
|
20
20
|
import gql from 'graphql-tag'
|
|
21
21
|
|
|
22
22
|
import { KpiMetricImporter } from './kpi-metric-importer'
|
|
23
|
+
import { OxGristEditorMetricInputHint } from '../../viewparts/ox-grist-editor-metric-input-hint'
|
|
24
|
+
|
|
25
|
+
// KpiMetric.meta 입력힌트 편집용 커스텀 grist 에디터 등록 (셀 클릭 → 구조화 팝업).
|
|
26
|
+
registerEditor('metric-input-hint', OxGristEditorMetricInputHint)
|
|
23
27
|
|
|
24
28
|
@customElement('kpi-metric-list-page')
|
|
25
29
|
export class KpiMetricListPage extends p13n(localize(i18next)(ScopedElementsMixin(PageView))) { static styles = [
|
|
@@ -205,6 +209,27 @@ export class KpiMetricListPage extends p13n(localize(i18next)(ScopedElementsMixi
|
|
|
205
209
|
},
|
|
206
210
|
width: 80
|
|
207
211
|
},
|
|
212
|
+
{
|
|
213
|
+
type: 'metric-input-hint',
|
|
214
|
+
name: 'meta',
|
|
215
|
+
header: '입력힌트',
|
|
216
|
+
record: {
|
|
217
|
+
editable: true,
|
|
218
|
+
renderer: (v: any, c: any, r: any) => {
|
|
219
|
+
const input = r?.meta?.input
|
|
220
|
+
if (!input?.widget) return ''
|
|
221
|
+
const extra = [
|
|
222
|
+
input.max !== undefined ? `max ${input.max}` : '',
|
|
223
|
+
input.step !== undefined ? `step ${input.step}` : '',
|
|
224
|
+
input.readonly ? 'RO' : ''
|
|
225
|
+
]
|
|
226
|
+
.filter(Boolean)
|
|
227
|
+
.join('·')
|
|
228
|
+
return extra ? `${input.widget} (${extra})` : input.widget
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
width: 150
|
|
232
|
+
},
|
|
208
233
|
{ type: 'integer', name: 'rank', header: '순서', sortable: true, record: { editable: true, align: 'center' }, width: 70 },
|
|
209
234
|
{ type: 'checkbox', name: 'active', label: true, header: '활성', record: { editable: true }, width: 60 },
|
|
210
235
|
{ type: 'crontab', name: 'schedule', header: '스케줄', record: { editable: true }, width: 120 },
|
|
@@ -247,6 +272,7 @@ export class KpiMetricListPage extends p13n(localize(i18next)(ScopedElementsMixi
|
|
|
247
272
|
source
|
|
248
273
|
collectType
|
|
249
274
|
rank
|
|
275
|
+
meta
|
|
250
276
|
dataSet { id
|
|
251
277
|
name
|
|
252
278
|
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { html, css, LitElement, nothing } from 'lit'
|
|
2
|
+
import { customElement, property } from 'lit/decorators.js'
|
|
3
|
+
|
|
4
|
+
import { resolveInputHint, InputHint, KpiMetricLike } from './metric-input-hint'
|
|
5
|
+
import { MetricRendererRegistry } from './metric-renderer-registry'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* metric 정의(meta.input)에 따라 입력 위젯을 **선언적으로 자동 렌더**하는 제네릭 컴포넌트.
|
|
9
|
+
* 이름 추측(isRating 등) 없이 `resolveInputHint` 로 위젯을 결정한다.
|
|
10
|
+
*
|
|
11
|
+
* <kpi-metric-input .metric=${metric} .value=${v}
|
|
12
|
+
* @metric-input-change=${e => (v = e.detail.value)}></kpi-metric-input>
|
|
13
|
+
*
|
|
14
|
+
* 예외 UI 는 MetricRendererRegistry 로 등록(제네릭 우선순위보다 먼저 적용).
|
|
15
|
+
*/
|
|
16
|
+
@customElement('kpi-metric-input')
|
|
17
|
+
export class KpiMetricInput extends LitElement {
|
|
18
|
+
static styles = css`
|
|
19
|
+
:host {
|
|
20
|
+
display: inline-flex;
|
|
21
|
+
align-items: baseline;
|
|
22
|
+
gap: 2px;
|
|
23
|
+
font: 13px/18px var(--theme-font, sans-serif);
|
|
24
|
+
}
|
|
25
|
+
input,
|
|
26
|
+
select {
|
|
27
|
+
font: inherit;
|
|
28
|
+
padding: 2px 4px;
|
|
29
|
+
}
|
|
30
|
+
input[type='number'] {
|
|
31
|
+
width: 5em;
|
|
32
|
+
text-align: right;
|
|
33
|
+
}
|
|
34
|
+
.stars {
|
|
35
|
+
display: inline-flex;
|
|
36
|
+
cursor: pointer;
|
|
37
|
+
font-size: 18px;
|
|
38
|
+
line-height: 1;
|
|
39
|
+
color: var(--md-sys-color-primary, #2e79be);
|
|
40
|
+
}
|
|
41
|
+
.stars[readonly] {
|
|
42
|
+
cursor: default;
|
|
43
|
+
opacity: 0.85;
|
|
44
|
+
}
|
|
45
|
+
.star {
|
|
46
|
+
width: 1em;
|
|
47
|
+
}
|
|
48
|
+
.unit {
|
|
49
|
+
color: var(--md-sys-color-on-surface-variant, #888);
|
|
50
|
+
font-size: 11px;
|
|
51
|
+
}
|
|
52
|
+
`
|
|
53
|
+
|
|
54
|
+
@property({ type: Object }) metric: KpiMetricLike = {}
|
|
55
|
+
@property() value: any
|
|
56
|
+
/** 상위에서 강제 비활성화(권한 등). readonly 힌트와 별개. */
|
|
57
|
+
@property({ type: Boolean }) disabled = false
|
|
58
|
+
|
|
59
|
+
private _emit(value: any) {
|
|
60
|
+
this.value = value
|
|
61
|
+
this.dispatchEvent(new CustomEvent('metric-input-change', { detail: { value }, bubbles: true, composed: true }))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
render() {
|
|
65
|
+
const custom = MetricRendererRegistry.resolve(this.metric)
|
|
66
|
+
if (custom) return custom(this.metric, this.value, v => this._emit(v))
|
|
67
|
+
|
|
68
|
+
const hint = resolveInputHint(this.metric)
|
|
69
|
+
const ro = this.disabled || !!hint.readonly
|
|
70
|
+
const unit = (this.metric as any)?.unit
|
|
71
|
+
|
|
72
|
+
// 값(우측정렬) 바로 옆에 단위 — 값이 단위에 붙어 자연스럽게. (rating/toggle 등엔 unit 보통 없음)
|
|
73
|
+
return html`
|
|
74
|
+
${this._renderWidget(hint, ro)}${unit ? html`<span class="unit">${unit}</span>` : nothing}
|
|
75
|
+
`
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private _renderWidget(hint: InputHint, ro: boolean) {
|
|
79
|
+
switch (hint.widget) {
|
|
80
|
+
case 'rating':
|
|
81
|
+
return this._renderRating(hint, ro)
|
|
82
|
+
case 'select':
|
|
83
|
+
return this._renderSelect(hint, ro)
|
|
84
|
+
case 'date':
|
|
85
|
+
return html`<input type="date" .value=${this.value ?? ''} ?disabled=${ro}
|
|
86
|
+
@change=${(e: Event) => this._emit((e.target as HTMLInputElement).value)} />`
|
|
87
|
+
case 'toggle':
|
|
88
|
+
return html`<input type="checkbox" .checked=${!!this.value} ?disabled=${ro}
|
|
89
|
+
@change=${(e: Event) => this._emit((e.target as HTMLInputElement).checked)} />`
|
|
90
|
+
case 'text':
|
|
91
|
+
return html`<input type="text" .value=${this.value ?? ''} ?disabled=${ro}
|
|
92
|
+
placeholder=${hint.placeholder ?? ''} @input=${(e: Event) => this._emit((e.target as HTMLInputElement).value)} />`
|
|
93
|
+
case 'number':
|
|
94
|
+
default:
|
|
95
|
+
return html`<input type="number" .value=${this.value ?? ''} ?disabled=${ro}
|
|
96
|
+
min=${hint.min ?? nothing} max=${hint.max ?? nothing} step=${hint.step ?? nothing}
|
|
97
|
+
placeholder=${hint.placeholder ?? ''}
|
|
98
|
+
@input=${(e: Event) => {
|
|
99
|
+
const raw = (e.target as HTMLInputElement).value
|
|
100
|
+
this._emit(raw === '' ? null : Number(raw))
|
|
101
|
+
}} />`
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private _renderRating(hint: InputHint, ro: boolean) {
|
|
106
|
+
const max = hint.max ?? 5
|
|
107
|
+
const score = Number(this.value ?? 0)
|
|
108
|
+
const stars = Array.from({ length: max }, (_, idx) => {
|
|
109
|
+
const i = idx + 1
|
|
110
|
+
const filled = i <= Math.round(score)
|
|
111
|
+
return html`<span class="star" @click=${() => (ro ? null : this._emit(i))}>${filled ? '★' : '☆'}</span>`
|
|
112
|
+
})
|
|
113
|
+
return html`<span class="stars" ?readonly=${ro} title=${`${score}/${max}`}>${stars}</span>`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private _renderSelect(hint: InputHint, ro: boolean) {
|
|
117
|
+
const options = hint.options ?? []
|
|
118
|
+
return html`<select ?disabled=${ro} @change=${(e: Event) => {
|
|
119
|
+
const raw = (e.target as HTMLSelectElement).value
|
|
120
|
+
// 옵션의 원래 타입(숫자 등)을 보존해 방출 — 문자열 강제 방지.
|
|
121
|
+
const opt = options.find(o => String(o.value) === raw)
|
|
122
|
+
this._emit(raw === '' ? null : opt ? opt.value : raw)
|
|
123
|
+
}}>
|
|
124
|
+
<option value="" ?selected=${this.value == null || this.value === ''}>-</option>
|
|
125
|
+
${options.map(
|
|
126
|
+
o => html`<option value=${o.value} ?selected=${String(this.value) === String(o.value)}>${o.label}</option>`
|
|
127
|
+
)}
|
|
128
|
+
</select>`
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import '@material/web/icon/icon.js'
|
|
2
|
+
|
|
3
|
+
import { css, html, LitElement } from 'lit'
|
|
4
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
5
|
+
import { CommonHeaderStyles } from '@operato/styles'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* KpiMetric.meta.input(입력 위젯 힌트) 구조화 에디터.
|
|
9
|
+
*
|
|
10
|
+
* metric 관리 화면(kpi-metric-list-page)에서 팝업으로 열려 meta.input 을 편집한다.
|
|
11
|
+
* raw JSON 을 직접 만지지 않고 위젯/필드를 폼으로 구성 → 내부적으로 meta 객체 생성.
|
|
12
|
+
* meta 의 다른 키(kind/source…)는 보존하고 input 만 교체/삭제한다.
|
|
13
|
+
*
|
|
14
|
+
* - `metric-meta-save` detail:{ meta } — 저장 (widget 미선택 시 input 제거된 meta)
|
|
15
|
+
* - `metric-meta-cancel` — 취소
|
|
16
|
+
*/
|
|
17
|
+
@customElement('kpi-metric-meta-editor')
|
|
18
|
+
export class KpiMetricMetaEditor extends LitElement {
|
|
19
|
+
static styles = [
|
|
20
|
+
CommonHeaderStyles,
|
|
21
|
+
css`
|
|
22
|
+
:host {
|
|
23
|
+
display: flex;
|
|
24
|
+
flex-direction: column;
|
|
25
|
+
height: 100%;
|
|
26
|
+
box-sizing: border-box;
|
|
27
|
+
width: 100%;
|
|
28
|
+
min-width: 520px;
|
|
29
|
+
font-size: 14px;
|
|
30
|
+
color: #212529;
|
|
31
|
+
}
|
|
32
|
+
/* 스크롤되는 내용부 — 버튼은 아래에 고정 (kpi-metric-value-manual-entry-form 패턴) */
|
|
33
|
+
.content {
|
|
34
|
+
flex: 1;
|
|
35
|
+
overflow-y: auto;
|
|
36
|
+
padding: 20px 24px 8px 24px;
|
|
37
|
+
}
|
|
38
|
+
h3 {
|
|
39
|
+
margin: 0 0 4px 0;
|
|
40
|
+
font-size: 15px;
|
|
41
|
+
}
|
|
42
|
+
.sub {
|
|
43
|
+
color: #6b7684;
|
|
44
|
+
margin-bottom: 14px;
|
|
45
|
+
}
|
|
46
|
+
.field {
|
|
47
|
+
display: grid;
|
|
48
|
+
grid-template-columns: 96px 1fr;
|
|
49
|
+
align-items: center;
|
|
50
|
+
gap: 8px 10px;
|
|
51
|
+
margin-bottom: 10px;
|
|
52
|
+
}
|
|
53
|
+
label {
|
|
54
|
+
color: #35618e;
|
|
55
|
+
font-weight: 500;
|
|
56
|
+
}
|
|
57
|
+
input[type='text'],
|
|
58
|
+
input[type='number'],
|
|
59
|
+
select,
|
|
60
|
+
textarea {
|
|
61
|
+
width: 100%;
|
|
62
|
+
box-sizing: border-box;
|
|
63
|
+
padding: 6px 8px;
|
|
64
|
+
border: 1px solid #cdd5df;
|
|
65
|
+
border-radius: 5px;
|
|
66
|
+
font-size: 13px;
|
|
67
|
+
}
|
|
68
|
+
textarea {
|
|
69
|
+
min-height: 72px;
|
|
70
|
+
resize: vertical;
|
|
71
|
+
font-family: monospace;
|
|
72
|
+
}
|
|
73
|
+
.checks {
|
|
74
|
+
display: flex;
|
|
75
|
+
gap: 16px;
|
|
76
|
+
flex-wrap: wrap;
|
|
77
|
+
margin: 4px 0 12px 0;
|
|
78
|
+
}
|
|
79
|
+
.checks label {
|
|
80
|
+
display: inline-flex;
|
|
81
|
+
align-items: center;
|
|
82
|
+
gap: 5px;
|
|
83
|
+
color: #212529;
|
|
84
|
+
font-weight: 400;
|
|
85
|
+
}
|
|
86
|
+
.hint {
|
|
87
|
+
color: #8b95a1;
|
|
88
|
+
font-size: 11px;
|
|
89
|
+
grid-column: 2;
|
|
90
|
+
margin-top: -4px;
|
|
91
|
+
}
|
|
92
|
+
`
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
/** { name, meta, collectType } */
|
|
96
|
+
@property({ type: Object }) metric: any = {}
|
|
97
|
+
|
|
98
|
+
@state() widget = ''
|
|
99
|
+
@state() min = ''
|
|
100
|
+
@state() max = ''
|
|
101
|
+
@state() step = ''
|
|
102
|
+
@state() decimals = ''
|
|
103
|
+
@state() placeholder = ''
|
|
104
|
+
@state() optionsText = ''
|
|
105
|
+
@state() readonly = false
|
|
106
|
+
@state() required = false
|
|
107
|
+
@state() hasPlan = false
|
|
108
|
+
|
|
109
|
+
connectedCallback() {
|
|
110
|
+
super.connectedCallback()
|
|
111
|
+
const input = this.metric?.meta?.input || {}
|
|
112
|
+
this.widget = input.widget || ''
|
|
113
|
+
this.min = input.min !== undefined ? String(input.min) : ''
|
|
114
|
+
this.max = input.max !== undefined ? String(input.max) : ''
|
|
115
|
+
this.step = input.step !== undefined ? String(input.step) : ''
|
|
116
|
+
this.decimals = input.decimals !== undefined ? String(input.decimals) : ''
|
|
117
|
+
this.placeholder = input.placeholder || ''
|
|
118
|
+
this.optionsText = (input.options || []).map((o: any) => `${o.value}|${o.label ?? o.value}`).join('\n')
|
|
119
|
+
this.readonly = !!input.readonly
|
|
120
|
+
this.required = !!input.required
|
|
121
|
+
this.hasPlan = !!input.hasPlan
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
render() {
|
|
125
|
+
const w = this.widget
|
|
126
|
+
return html`
|
|
127
|
+
<div class="content">
|
|
128
|
+
<h3>입력 힌트 · ${this.metric?.name || ''}</h3>
|
|
129
|
+
<div class="sub">값 입력 화면(월별/완료평가 등)이 이 힌트로 위젯을 그립니다. 위젯을 비우면 힌트가 제거되어 숫자 입력으로 폴백합니다.</div>
|
|
130
|
+
|
|
131
|
+
<div class="field">
|
|
132
|
+
<label>위젯</label>
|
|
133
|
+
<select .value=${w} @change=${(e: any) => (this.widget = e.target.value)}>
|
|
134
|
+
<option value="">(자동/폴백 — 숫자)</option>
|
|
135
|
+
<option value="number" ?selected=${w === 'number'}>number — 숫자</option>
|
|
136
|
+
<option value="rating" ?selected=${w === 'rating'}>rating — 별점</option>
|
|
137
|
+
<option value="select" ?selected=${w === 'select'}>select — 선택목록</option>
|
|
138
|
+
<option value="toggle" ?selected=${w === 'toggle'}>toggle — 스위치</option>
|
|
139
|
+
<option value="text" ?selected=${w === 'text'}>text — 텍스트</option>
|
|
140
|
+
<option value="date" ?selected=${w === 'date'}>date — 날짜</option>
|
|
141
|
+
</select>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
${w === 'number' || w === 'rating'
|
|
145
|
+
? html`
|
|
146
|
+
${w === 'number'
|
|
147
|
+
? html`<div class="field">
|
|
148
|
+
<label>최소</label>
|
|
149
|
+
<input type="number" .value=${this.min} @input=${(e: any) => (this.min = e.target.value)} />
|
|
150
|
+
</div>`
|
|
151
|
+
: ''}
|
|
152
|
+
<div class="field">
|
|
153
|
+
<label>최대</label>
|
|
154
|
+
<input type="number" .value=${this.max} @input=${(e: any) => (this.max = e.target.value)} />
|
|
155
|
+
${w === 'rating' ? html`<span class="hint">별 개수 (기본 5)</span>` : ''}
|
|
156
|
+
</div>
|
|
157
|
+
<div class="field">
|
|
158
|
+
<label>스텝</label>
|
|
159
|
+
<input type="number" .value=${this.step} @input=${(e: any) => (this.step = e.target.value)} />
|
|
160
|
+
${w === 'rating' ? html`<span class="hint">1=정수, 0.5=반별 (기본 1)</span>` : ''}
|
|
161
|
+
</div>
|
|
162
|
+
${w === 'number'
|
|
163
|
+
? html`<div class="field">
|
|
164
|
+
<label>소수자리</label>
|
|
165
|
+
<input type="number" .value=${this.decimals} @input=${(e: any) => (this.decimals = e.target.value)} />
|
|
166
|
+
</div>`
|
|
167
|
+
: ''}
|
|
168
|
+
`
|
|
169
|
+
: ''}
|
|
170
|
+
${w === 'select'
|
|
171
|
+
? html`
|
|
172
|
+
<div class="field">
|
|
173
|
+
<label>옵션</label>
|
|
174
|
+
<textarea
|
|
175
|
+
.value=${this.optionsText}
|
|
176
|
+
placeholder="한 줄에 하나: 값|표시라벨 예) 1|양호 2|보통"
|
|
177
|
+
@input=${(e: any) => (this.optionsText = e.target.value)}
|
|
178
|
+
></textarea>
|
|
179
|
+
<span class="hint">한 줄당 <code>값|라벨</code> (라벨 생략 시 값 사용)</span>
|
|
180
|
+
</div>
|
|
181
|
+
`
|
|
182
|
+
: ''}
|
|
183
|
+
${w === 'text'
|
|
184
|
+
? html`<div class="field">
|
|
185
|
+
<label>placeholder</label>
|
|
186
|
+
<input type="text" .value=${this.placeholder} @input=${(e: any) => (this.placeholder = e.target.value)} />
|
|
187
|
+
</div>`
|
|
188
|
+
: ''}
|
|
189
|
+
|
|
190
|
+
${w
|
|
191
|
+
? html`<div class="checks">
|
|
192
|
+
<label><input type="checkbox" .checked=${this.readonly} @change=${(e: any) => (this.readonly = e.target.checked)} /> 읽기전용</label>
|
|
193
|
+
<label><input type="checkbox" .checked=${this.required} @change=${(e: any) => (this.required = e.target.checked)} /> 필수</label>
|
|
194
|
+
<label><input type="checkbox" .checked=${this.hasPlan} @change=${(e: any) => (this.hasPlan = e.target.checked)} /> 계획/실제 2열</label>
|
|
195
|
+
</div>`
|
|
196
|
+
: ''}
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
<div class="footer">
|
|
200
|
+
${this.metric?.meta?.input
|
|
201
|
+
? html`<button danger @click=${this._clear}><md-icon>delete</md-icon>힌트 삭제</button>`
|
|
202
|
+
: ''}
|
|
203
|
+
<div filler></div>
|
|
204
|
+
<button @click=${this._cancel}><md-icon>cancel</md-icon>취소</button>
|
|
205
|
+
<button done @click=${this._save}><md-icon>done</md-icon>저장</button>
|
|
206
|
+
</div>
|
|
207
|
+
`
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private _num(v: string): number | undefined {
|
|
211
|
+
if (v === '' || v === null || v === undefined) return undefined
|
|
212
|
+
const n = Number(v)
|
|
213
|
+
return Number.isNaN(n) ? undefined : n
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private _buildInput(): any {
|
|
217
|
+
if (!this.widget) return undefined
|
|
218
|
+
const input: any = { widget: this.widget }
|
|
219
|
+
|
|
220
|
+
if (this.widget === 'number' || this.widget === 'rating') {
|
|
221
|
+
const min = this._num(this.min)
|
|
222
|
+
const max = this._num(this.max)
|
|
223
|
+
const step = this._num(this.step)
|
|
224
|
+
const decimals = this._num(this.decimals)
|
|
225
|
+
if (this.widget === 'number' && min !== undefined) input.min = min
|
|
226
|
+
if (max !== undefined) input.max = max
|
|
227
|
+
if (step !== undefined) input.step = step
|
|
228
|
+
if (this.widget === 'number' && decimals !== undefined) input.decimals = decimals
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (this.widget === 'select') {
|
|
232
|
+
const options = this.optionsText
|
|
233
|
+
.split('\n')
|
|
234
|
+
.map(line => line.trim())
|
|
235
|
+
.filter(Boolean)
|
|
236
|
+
.map(line => {
|
|
237
|
+
const [value, label] = line.split('|').map(s => s.trim())
|
|
238
|
+
return { value, label: label || value }
|
|
239
|
+
})
|
|
240
|
+
if (options.length) input.options = options
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (this.widget === 'text' && this.placeholder) input.placeholder = this.placeholder
|
|
244
|
+
|
|
245
|
+
if (this.readonly) input.readonly = true
|
|
246
|
+
if (this.required) input.required = true
|
|
247
|
+
if (this.hasPlan) input.hasPlan = true
|
|
248
|
+
|
|
249
|
+
return input
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** 기존 meta 는 보존하고 input 만 교체(또는 삭제). */
|
|
253
|
+
private _buildMeta(): any {
|
|
254
|
+
const rest = { ...(this.metric?.meta || {}) }
|
|
255
|
+
delete rest.input
|
|
256
|
+
const input = this._buildInput()
|
|
257
|
+
const meta = input ? { ...rest, input } : rest
|
|
258
|
+
return Object.keys(meta).length ? meta : null
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private _save() {
|
|
262
|
+
this.dispatchEvent(
|
|
263
|
+
new CustomEvent('metric-meta-save', { detail: { meta: this._buildMeta() }, bubbles: true, composed: true })
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
private _clear() {
|
|
268
|
+
this.widget = ''
|
|
269
|
+
this.dispatchEvent(
|
|
270
|
+
new CustomEvent('metric-meta-save', { detail: { meta: this._buildMeta() }, bubbles: true, composed: true })
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private _cancel() {
|
|
275
|
+
this.dispatchEvent(new CustomEvent('metric-meta-cancel', { bubbles: true, composed: true }))
|
|
276
|
+
}
|
|
277
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// 클라이언트용 InputHint 타입 + resolveInputHint.
|
|
2
|
+
//
|
|
3
|
+
// ⚠️ 원본(진실의 원천): server/service/kpi-metric/kpi-metric-meta.ts
|
|
4
|
+
// client/server tsconfig 가 분리되어 server 를 import 할 수 없어 동형 복제한다.
|
|
5
|
+
// 변경 시 양쪽을 함께 수정할 것.
|
|
6
|
+
|
|
7
|
+
export type InputWidget = 'number' | 'rating' | 'select' | 'date' | 'text' | 'toggle'
|
|
8
|
+
|
|
9
|
+
export interface InputHint {
|
|
10
|
+
widget: InputWidget
|
|
11
|
+
min?: number
|
|
12
|
+
max?: number
|
|
13
|
+
step?: number
|
|
14
|
+
options?: { value: string | number; label: string }[]
|
|
15
|
+
required?: boolean
|
|
16
|
+
decimals?: number
|
|
17
|
+
readonly?: boolean
|
|
18
|
+
hasPlan?: boolean
|
|
19
|
+
placeholder?: string
|
|
20
|
+
default?: any
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface KpiMetricLike {
|
|
24
|
+
meta?: { input?: InputHint; [k: string]: any } | null
|
|
25
|
+
collectType?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** meta.input 우선, 없으면 number 폴백(이름추측 금지). readonly 는 명시값 우선, 미지정+AUTO 면 true. */
|
|
29
|
+
export function resolveInputHint(metric: KpiMetricLike | null | undefined): InputHint {
|
|
30
|
+
const isAuto = metric?.collectType === 'AUTO'
|
|
31
|
+
const hint = metric?.meta?.input
|
|
32
|
+
|
|
33
|
+
if (hint?.widget) {
|
|
34
|
+
const readonly = hint.readonly ?? isAuto
|
|
35
|
+
if (hint.widget === 'rating') {
|
|
36
|
+
return { ...hint, max: hint.max ?? 5, step: hint.step ?? 1, readonly }
|
|
37
|
+
}
|
|
38
|
+
return { ...hint, readonly }
|
|
39
|
+
}
|
|
40
|
+
return { widget: 'number', readonly: isAuto }
|
|
41
|
+
}
|