@dssp/dkpi 1.0.27 → 1.0.29

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 (56) hide show
  1. package/dist-client/pages/kpi-dashboard/cards/kpi-level1-card.d.ts +2 -0
  2. package/dist-client/pages/kpi-dashboard/cards/kpi-level1-card.js +19 -4
  3. package/dist-client/pages/kpi-dashboard/cards/kpi-level1-card.js.map +1 -1
  4. package/dist-client/pages/kpi-dashboard/cards/kpi-level2-comparison.d.ts +2 -0
  5. package/dist-client/pages/kpi-dashboard/cards/kpi-level2-comparison.js +19 -4
  6. package/dist-client/pages/kpi-dashboard/cards/kpi-level2-comparison.js.map +1 -1
  7. package/dist-client/pages/kpi-dashboard/cards/kpi-level3-comparison.d.ts +2 -0
  8. package/dist-client/pages/kpi-dashboard/cards/kpi-level3-comparison.js +19 -4
  9. package/dist-client/pages/kpi-dashboard/cards/kpi-level3-comparison.js.map +1 -1
  10. package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.d.ts +2 -0
  11. package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js +78 -4
  12. package/dist-client/pages/kpi-dashboard/components/kpi-left-panel.js.map +1 -1
  13. package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.d.ts +2 -0
  14. package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js +35 -4
  15. package/dist-client/pages/kpi-dashboard/components/kpi-region-popup.js.map +1 -1
  16. package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.d.ts +2 -0
  17. package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js +29 -1
  18. package/dist-client/pages/kpi-dashboard/kpi-dashboard-map.js.map +1 -1
  19. package/dist-client/pages/kpi-dashboard/kpi-dashboard.d.ts +11 -0
  20. package/dist-client/pages/kpi-dashboard/kpi-dashboard.js +65 -12
  21. package/dist-client/pages/kpi-dashboard/kpi-dashboard.js.map +1 -1
  22. package/dist-client/pages/project-complete-tabs/pc-tab1-plan.d.ts +18 -0
  23. package/dist-client/pages/project-complete-tabs/pc-tab1-plan.js +138 -64
  24. package/dist-client/pages/project-complete-tabs/pc-tab1-plan.js.map +1 -1
  25. package/dist-client/pages/project-complete-tabs/pc-tab3-upload.js +80 -8
  26. package/dist-client/pages/project-complete-tabs/pc-tab3-upload.js.map +1 -1
  27. package/dist-client/pages/sv-project-detail.d.ts +33 -0
  28. package/dist-client/pages/sv-project-detail.js +130 -8
  29. package/dist-client/pages/sv-project-detail.js.map +1 -1
  30. package/dist-client/pages/sv-project-update.js +98 -31
  31. package/dist-client/pages/sv-project-update.js.map +1 -1
  32. package/dist-client/shared/complete-api.d.ts +7 -0
  33. package/dist-client/shared/complete-api.js +27 -0
  34. package/dist-client/shared/complete-api.js.map +1 -1
  35. package/dist-client/shared/project-axis.d.ts +23 -0
  36. package/dist-client/shared/project-axis.js +42 -0
  37. package/dist-client/shared/project-axis.js.map +1 -0
  38. package/dist-client/shared/sentiment-index.d.ts +47 -0
  39. package/dist-client/shared/sentiment-index.js +119 -0
  40. package/dist-client/shared/sentiment-index.js.map +1 -0
  41. package/dist-client/tsconfig.tsbuildinfo +1 -1
  42. package/dist-server/index.js +14 -0
  43. package/dist-server/index.js.map +1 -1
  44. package/dist-server/scripts/load-grade-data-migration.js +17 -3
  45. package/dist-server/scripts/load-grade-data-migration.js.map +1 -1
  46. package/dist-server/scripts/propagate-parent-kpi-values.js +13 -2
  47. package/dist-server/scripts/propagate-parent-kpi-values.js.map +1 -1
  48. package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.d.ts +22 -0
  49. package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js +93 -1
  50. package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js.map +1 -1
  51. package/dist-server/service/kpi-stat/kpi-stat-query.d.ts +9 -9
  52. package/dist-server/service/kpi-stat/kpi-stat-query.js +71 -84
  53. package/dist-server/service/kpi-stat/kpi-stat-query.js.map +1 -1
  54. package/dist-server/tsconfig.tsbuildinfo +1 -1
  55. package/package.json +3 -3
  56. package/schema.graphql +2011 -67
@@ -0,0 +1,119 @@
1
+ import { gql } from '@apollo/client';
2
+ import { client } from '@operato/graphql';
3
+ import { tenantHeaders } from '@dssp/project/dist-client/shared/domain-context';
4
+ /**
5
+ * 감리 종합의견서 AI 분석 결과 — "체감지수 결과" 표시 규칙.
6
+ *
7
+ * 지수값 하나(1.09)는 읽는 사람에게 아무것도 말해 주지 않는다. 2026-08-07 고려대·건산연·
8
+ * 하티오랩 회의에서 정해진 것은 그 지수를 5단계(매우 부정 ~ 매우 긍정)로 환산해 함께 보여
9
+ * 주는 것이고, 환산 구간은 안전과 품질이 서로 다르다:
10
+ *
11
+ * 안전 <0.7 / 0.7~1.0 / 1.0~1.1 / 1.1~1.2 / 1.2~
12
+ * 품질 <0.6 / 0.6~1.0 / 1.0~1.15 / 1.15~1.25 / 1.25~
13
+ *
14
+ * ── 그 경계를 여기 다시 적지 않는 이유 ──────────────────────────────────────
15
+ * 같은 숫자가 이미 X43/X34 의 grade table 에 있고(server/scripts/grade-data), KPI 점수는
16
+ * 그것으로 매겨진다. 화면이 제 사본을 들면 둘은 언젠가 갈라지고, 갈라지는 날 화면은 KPI 점수와
17
+ * 다른 등급을 아무 오류 없이 보여 준다. 그래서 등급도 같은 표에서 읽는다 — 표가 곧 정의다.
18
+ */
19
+ /** grade table 의 score(1~5) 가 곧 5단계 결과. 순서가 의미다. */
20
+ const SENTIMENT_RESULTS = ['매우 부정', '부정', '중립', '긍정', '매우 긍정'];
21
+ /**
22
+ * 감정 분석이 값을 써 넣는 metric 과, 그 값을 채점하는 KPI.
23
+ *
24
+ * key 는 서버가 저장할 때 쓰는 metric 이름 그대로다
25
+ * (kpi-metric-value-mutation.ts 의 updateKpiMetricValuesSentiment).
26
+ * 화면 이름만 회의 결과대로 바꾼다 — metric 이름은 값을 찾는 열쇠라서, 바꾸면 이미 쌓인
27
+ * 값이 주인을 잃는다.
28
+ */
29
+ export const SENTIMENT_METRICS = {
30
+ 'SL-PA 안전 평가': { kpiCode: 'X43', label: '안전성과 체감지수 결과' },
31
+ 'SL-PA 품질 평가': { kpiCode: 'X34', label: '품질성과 체감지수 결과' }
32
+ };
33
+ /** "X43. 안전 SL-PA 결과값" → "X43" — sv-project-detail 과 같은 규칙. */
34
+ const codeOf = (name) => (name || '').trim().split(/[.\s]/)[0];
35
+ /**
36
+ * 체감지수 metric 을 채점하는 KPI 들의 grade table 을 코드별로 가져온다.
37
+ *
38
+ * KPI 는 스무 개 남짓이라 전부 받아 코드로 고른다. 이름으로 필터를 걸면 "X43. 안전 SL-PA
39
+ * 결과값" 이라는 전체 문자열에 의존하게 되는데, 그 이름은 관리 화면에서 바뀔 수 있다.
40
+ */
41
+ export async function fetchSentimentGrades(projectCode) {
42
+ var _a, _b;
43
+ const wanted = new Set(Object.values(SENTIMENT_METRICS).map(m => m.kpiCode));
44
+ const response = await client.query({
45
+ query: gql `
46
+ query SentimentKpiGrades {
47
+ kpis {
48
+ items {
49
+ id
50
+ name
51
+ grades
52
+ }
53
+ }
54
+ }
55
+ `,
56
+ context: { headers: tenantHeaders(projectCode) }
57
+ });
58
+ if (response.errors) {
59
+ return {};
60
+ }
61
+ const grades = {};
62
+ for (const kpi of ((_b = (_a = response.data) === null || _a === void 0 ? void 0 : _a.kpis) === null || _b === void 0 ? void 0 : _b.items) || []) {
63
+ const code = codeOf(kpi.name);
64
+ if (wanted.has(code) && Array.isArray(kpi.grades)) {
65
+ grades[code] = kpi.grades;
66
+ }
67
+ }
68
+ return grades;
69
+ }
70
+ /** 0.7 → "0.7", 1 → "1.0", 1.15 → "1.15". 회의 자료의 표기를 따른다. */
71
+ function formatBoundary(value) {
72
+ return Number.isInteger(value) ? value.toFixed(1) : String(value);
73
+ }
74
+ /**
75
+ * 구간표를 화면에 그대로 보여 줄 수 있는 형태로 — 회의 자료의 "지수 범위 / 결과" 표다.
76
+ *
77
+ * 양 끝은 열린 구간으로 적는다. 첫 구간의 minValue(0)와 마지막 구간의 maxValue(99999)는
78
+ * 표를 채우기 위한 수치이지 사람에게 보여 줄 경계가 아니다 — "0~0.7" 이나 "1.2~99999" 로
79
+ * 적으면 읽는 사람이 없는 하한과 상한을 믿게 된다.
80
+ */
81
+ export function sentimentBandsOf(grades) {
82
+ if (!Array.isArray(grades) || grades.length === 0) {
83
+ return [];
84
+ }
85
+ const sorted = [...grades].sort((a, b) => a.minValue - b.minValue);
86
+ return sorted.map((grade, index) => {
87
+ var _a;
88
+ return ({
89
+ score: grade.score,
90
+ range: index === 0
91
+ ? `<${formatBoundary(grade.maxValue)}`
92
+ : index === sorted.length - 1
93
+ ? `${formatBoundary(grade.minValue)}~`
94
+ : `${formatBoundary(grade.minValue)}~${formatBoundary(grade.maxValue)}`,
95
+ result: (_a = SENTIMENT_RESULTS[grade.score - 1]) !== null && _a !== void 0 ? _a : ''
96
+ });
97
+ });
98
+ }
99
+ /**
100
+ * 지수값이 떨어지는 구간을 찾는다.
101
+ *
102
+ * 판정 규칙은 kpi-step-lookup-chart 와 같다: 아래는 포함, 위는 미포함, 마지막 구간의
103
+ * maxValue(99999)는 열린 끝으로 본다. 두 곳이 다르게 판정하면 같은 값에 대해 차트와 글자가
104
+ * 엇갈린다.
105
+ *
106
+ * 표가 없거나 어느 구간에도 안 걸리면 null — 모르는 것을 "중립" 으로 메우지 않는다.
107
+ */
108
+ export function matchSentimentBand(value, grades) {
109
+ var _a;
110
+ if (value == null || !Array.isArray(grades) || grades.length === 0) {
111
+ return null;
112
+ }
113
+ const matched = grades.find(grade => value >= grade.minValue && (grade.maxValue >= 9999 ? true : value < grade.maxValue));
114
+ if (!matched) {
115
+ return null;
116
+ }
117
+ return (_a = sentimentBandsOf(grades).find(band => band.score === matched.score)) !== null && _a !== void 0 ? _a : null;
118
+ }
119
+ //# sourceMappingURL=sentiment-index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sentiment-index.js","sourceRoot":"","sources":["../../client/shared/sentiment-index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,gBAAgB,CAAA;AACpC,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAEzC,OAAO,EAAE,aAAa,EAAE,MAAM,iDAAiD,CAAA;AAE/E;;;;;;;;;;;;;;GAcG;AAEH,oDAAoD;AACpD,MAAM,iBAAiB,GAAG,CAAC,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,OAAO,CAAC,CAAA;AAE9D;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,iBAAiB,GAAuD;IACnF,aAAa,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,cAAc,EAAE;IACxD,aAAa,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,cAAc,EAAE;CACzD,CAAA;AAID,+DAA+D;AAC/D,MAAM,MAAM,GAAG,CAAC,IAAY,EAAE,EAAE,CAAC,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAA;AAEtE;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,WAAoB;;IAC7D,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,iBAAiB,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAA;IAE5E,MAAM,QAAQ,GAAG,MAAM,MAAM,CAAC,KAAK,CAAC;QAClC,KAAK,EAAE,GAAG,CAAA;;;;;;;;;;KAUT;QACD,OAAO,EAAE,EAAE,OAAO,EAAE,aAAa,CAAC,WAAW,CAAC,EAAE;KACjD,CAAC,CAAA;IAEF,IAAI,QAAQ,CAAC,MAAM,EAAE,CAAC;QACpB,OAAO,EAAE,CAAA;IACX,CAAC;IAED,MAAM,MAAM,GAAqC,EAAE,CAAA;IAEnD,KAAK,MAAM,GAAG,IAAI,CAAA,MAAA,MAAA,QAAQ,CAAC,IAAI,0CAAE,IAAI,0CAAE,KAAK,KAAI,EAAE,EAAE,CAAC;QACnD,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAA;QAC7B,IAAI,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC;YAClD,MAAM,CAAC,IAAI,CAAC,GAAG,GAAG,CAAC,MAAM,CAAA;QAC3B,CAAC;IACH,CAAC;IAED,OAAO,MAAM,CAAA;AACf,CAAC;AAID,6DAA6D;AAC7D,SAAS,cAAc,CAAC,KAAa;IACnC,OAAO,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AACnE,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAyB;IACxD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAClD,OAAO,EAAE,CAAA;IACX,CAAC;IAED,MAAM,MAAM,GAAG,CAAC,GAAG,MAAM,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,GAAG,CAAC,CAAC,QAAQ,CAAC,CAAA;IAElE,OAAO,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,KAAK,EAAE,EAAE;;QAAC,OAAA,CAAC;YACnC,KAAK,EAAE,KAAK,CAAC,KAAK;YAClB,KAAK,EACH,KAAK,KAAK,CAAC;gBACT,CAAC,CAAC,IAAI,cAAc,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE;gBACtC,CAAC,CAAC,KAAK,KAAK,MAAM,CAAC,MAAM,GAAG,CAAC;oBAC3B,CAAC,CAAC,GAAG,cAAc,CAAC,KAAK,CAAC,QAAQ,CAAC,GAAG;oBACtC,CAAC,CAAC,GAAG,cAAc,CAAC,KAAK,CAAC,QAAQ,CAAC,IAAI,cAAc,CAAC,KAAK,CAAC,QAAQ,CAAC,EAAE;YAC7E,MAAM,EAAE,MAAA,iBAAiB,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,mCAAI,EAAE;SACjD,CAAC,CAAA;KAAA,CAAC,CAAA;AACL,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,kBAAkB,CAChC,KAAgC,EAChC,MAAyB;;IAEzB,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACnE,OAAO,IAAI,CAAA;IACb,CAAC;IAED,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CACzB,KAAK,CAAC,EAAE,CAAC,KAAK,IAAI,KAAK,CAAC,QAAQ,IAAI,CAAC,KAAK,CAAC,QAAQ,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,GAAG,KAAK,CAAC,QAAQ,CAAC,CAC7F,CAAA;IAED,IAAI,CAAC,OAAO,EAAE,CAAC;QACb,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,MAAA,gBAAgB,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,KAAK,KAAK,OAAO,CAAC,KAAK,CAAC,mCAAI,IAAI,CAAA;AACpF,CAAC","sourcesContent":["import { gql } from '@apollo/client'\nimport { client } from '@operato/graphql'\n\nimport { tenantHeaders } from '@dssp/project/dist-client/shared/domain-context'\n\n/**\n * 감리 종합의견서 AI 분석 결과 — \"체감지수 결과\" 표시 규칙.\n *\n * 지수값 하나(1.09)는 읽는 사람에게 아무것도 말해 주지 않는다. 2026-08-07 고려대·건산연·\n * 하티오랩 회의에서 정해진 것은 그 지수를 5단계(매우 부정 ~ 매우 긍정)로 환산해 함께 보여\n * 주는 것이고, 환산 구간은 안전과 품질이 서로 다르다:\n *\n * 안전 <0.7 / 0.7~1.0 / 1.0~1.1 / 1.1~1.2 / 1.2~\n * 품질 <0.6 / 0.6~1.0 / 1.0~1.15 / 1.15~1.25 / 1.25~\n *\n * ── 그 경계를 여기 다시 적지 않는 이유 ──────────────────────────────────────\n * 같은 숫자가 이미 X43/X34 의 grade table 에 있고(server/scripts/grade-data), KPI 점수는\n * 그것으로 매겨진다. 화면이 제 사본을 들면 둘은 언젠가 갈라지고, 갈라지는 날 화면은 KPI 점수와\n * 다른 등급을 아무 오류 없이 보여 준다. 그래서 등급도 같은 표에서 읽는다 — 표가 곧 정의다.\n */\n\n/** grade table 의 score(1~5) 가 곧 5단계 결과. 순서가 의미다. */\nconst SENTIMENT_RESULTS = ['매우 부정', '부정', '중립', '긍정', '매우 긍정']\n\n/**\n * 감정 분석이 값을 써 넣는 metric 과, 그 값을 채점하는 KPI.\n *\n * key 는 서버가 저장할 때 쓰는 metric 이름 그대로다\n * (kpi-metric-value-mutation.ts 의 updateKpiMetricValuesSentiment).\n * 화면 이름만 회의 결과대로 바꾼다 — metric 이름은 값을 찾는 열쇠라서, 바꾸면 이미 쌓인\n * 값이 주인을 잃는다.\n */\nexport const SENTIMENT_METRICS: Record<string, { kpiCode: string; label: string }> = {\n 'SL-PA 안전 평가': { kpiCode: 'X43', label: '안전성과 체감지수 결과' },\n 'SL-PA 품질 평가': { kpiCode: 'X34', label: '품질성과 체감지수 결과' }\n}\n\nexport type SentimentGrade = { minValue: number; maxValue: number; score: number }\n\n/** \"X43. 안전 SL-PA 결과값\" → \"X43\" — sv-project-detail 과 같은 규칙. */\nconst codeOf = (name: string) => (name || '').trim().split(/[.\\s]/)[0]\n\n/**\n * 체감지수 metric 을 채점하는 KPI 들의 grade table 을 코드별로 가져온다.\n *\n * KPI 는 스무 개 남짓이라 전부 받아 코드로 고른다. 이름으로 필터를 걸면 \"X43. 안전 SL-PA\n * 결과값\" 이라는 전체 문자열에 의존하게 되는데, 그 이름은 관리 화면에서 바뀔 수 있다.\n */\nexport async function fetchSentimentGrades(projectCode?: string): Promise<Record<string, SentimentGrade[]>> {\n const wanted = new Set(Object.values(SENTIMENT_METRICS).map(m => m.kpiCode))\n\n const response = await client.query({\n query: gql`\n query SentimentKpiGrades {\n kpis {\n items {\n id\n name\n grades\n }\n }\n }\n `,\n context: { headers: tenantHeaders(projectCode) }\n })\n\n if (response.errors) {\n return {}\n }\n\n const grades: Record<string, SentimentGrade[]> = {}\n\n for (const kpi of response.data?.kpis?.items || []) {\n const code = codeOf(kpi.name)\n if (wanted.has(code) && Array.isArray(kpi.grades)) {\n grades[code] = kpi.grades\n }\n }\n\n return grades\n}\n\nexport type SentimentBand = { score: number; range: string; result: string }\n\n/** 0.7 → \"0.7\", 1 → \"1.0\", 1.15 → \"1.15\". 회의 자료의 표기를 따른다. */\nfunction formatBoundary(value: number): string {\n return Number.isInteger(value) ? value.toFixed(1) : String(value)\n}\n\n/**\n * 구간표를 화면에 그대로 보여 줄 수 있는 형태로 — 회의 자료의 \"지수 범위 / 결과\" 표다.\n *\n * 양 끝은 열린 구간으로 적는다. 첫 구간의 minValue(0)와 마지막 구간의 maxValue(99999)는\n * 표를 채우기 위한 수치이지 사람에게 보여 줄 경계가 아니다 — \"0~0.7\" 이나 \"1.2~99999\" 로\n * 적으면 읽는 사람이 없는 하한과 상한을 믿게 된다.\n */\nexport function sentimentBandsOf(grades?: SentimentGrade[]): SentimentBand[] {\n if (!Array.isArray(grades) || grades.length === 0) {\n return []\n }\n\n const sorted = [...grades].sort((a, b) => a.minValue - b.minValue)\n\n return sorted.map((grade, index) => ({\n score: grade.score,\n range:\n index === 0\n ? `<${formatBoundary(grade.maxValue)}`\n : index === sorted.length - 1\n ? `${formatBoundary(grade.minValue)}~`\n : `${formatBoundary(grade.minValue)}~${formatBoundary(grade.maxValue)}`,\n result: SENTIMENT_RESULTS[grade.score - 1] ?? ''\n }))\n}\n\n/**\n * 지수값이 떨어지는 구간을 찾는다.\n *\n * 판정 규칙은 kpi-step-lookup-chart 와 같다: 아래는 포함, 위는 미포함, 마지막 구간의\n * maxValue(99999)는 열린 끝으로 본다. 두 곳이 다르게 판정하면 같은 값에 대해 차트와 글자가\n * 엇갈린다.\n *\n * 표가 없거나 어느 구간에도 안 걸리면 null — 모르는 것을 \"중립\" 으로 메우지 않는다.\n */\nexport function matchSentimentBand(\n value: number | null | undefined,\n grades?: SentimentGrade[]\n): SentimentBand | null {\n if (value == null || !Array.isArray(grades) || grades.length === 0) {\n return null\n }\n\n const matched = grades.find(\n grade => value >= grade.minValue && (grade.maxValue >= 9999 ? true : value < grade.maxValue)\n )\n\n if (!matched) {\n return null\n }\n\n return sentimentBandsOf(grades).find(band => band.score === matched.score) ?? null\n}\n"]}