@dssp/dkpi 1.0.0-alpha.80 → 1.0.0-alpha.82

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 (68) hide show
  1. package/dist-client/bootstrap.d.ts +1 -0
  2. package/dist-client/bootstrap.js +11 -0
  3. package/dist-client/bootstrap.js.map +1 -1
  4. package/dist-client/components/kpi-single-boxplot-chart.d.ts +3 -2
  5. package/dist-client/components/kpi-single-boxplot-chart.js +30 -23
  6. package/dist-client/components/kpi-single-boxplot-chart.js.map +1 -1
  7. package/dist-client/pages/component/project-update-header.d.ts +1 -0
  8. package/dist-client/pages/component/project-update-header.js +127 -0
  9. package/dist-client/pages/component/project-update-header.js.map +1 -0
  10. package/dist-client/pages/kpi-admin/kpi-system-guide.d.ts +1 -1
  11. package/dist-client/pages/kpi-admin/kpi-system-guide.js +29 -21
  12. package/dist-client/pages/kpi-admin/kpi-system-guide.js.map +1 -1
  13. package/dist-client/pages/kpi-metric-value/kpi-metric-value-list-page.d.ts +1 -1
  14. package/dist-client/pages/kpi-metric-value/kpi-metric-value-list-page.js +1 -1
  15. package/dist-client/pages/kpi-metric-value/kpi-metric-value-list-page.js.map +1 -1
  16. package/dist-client/pages/kpi-value/kpi-value-list-page.js +1 -1
  17. package/dist-client/pages/kpi-value/kpi-value-list-page.js.map +1 -1
  18. package/dist-client/pages/project-complete-tabs/pc-tab1-plan.d.ts +21 -2
  19. package/dist-client/pages/project-complete-tabs/pc-tab1-plan.js +166 -134
  20. package/dist-client/pages/project-complete-tabs/pc-tab1-plan.js.map +1 -1
  21. package/dist-client/pages/project-complete-tabs/pc-tab2-rating.d.ts +4 -2
  22. package/dist-client/pages/project-complete-tabs/pc-tab2-rating.js +109 -44
  23. package/dist-client/pages/project-complete-tabs/pc-tab2-rating.js.map +1 -1
  24. package/dist-client/pages/project-complete-tabs/pc-tab3-upload.d.ts +3 -0
  25. package/dist-client/pages/project-complete-tabs/pc-tab3-upload.js +32 -4
  26. package/dist-client/pages/project-complete-tabs/pc-tab3-upload.js.map +1 -1
  27. package/dist-client/pages/project-complete-tabs/pc-tab4-monthly.d.ts +24 -0
  28. package/dist-client/pages/project-complete-tabs/pc-tab4-monthly.js +365 -157
  29. package/dist-client/pages/project-complete-tabs/pc-tab4-monthly.js.map +1 -1
  30. package/dist-client/pages/sv-project-complete.d.ts +4 -1
  31. package/dist-client/pages/sv-project-complete.js +43 -12
  32. package/dist-client/pages/sv-project-complete.js.map +1 -1
  33. package/dist-client/pages/sv-project-completed-list.js +3 -3
  34. package/dist-client/pages/sv-project-completed-list.js.map +1 -1
  35. package/dist-client/pages/sv-project-detail.d.ts +11 -0
  36. package/dist-client/pages/sv-project-detail.js +188 -46
  37. package/dist-client/pages/sv-project-detail.js.map +1 -1
  38. package/dist-client/pages/sv-project-list.d.ts +10 -0
  39. package/dist-client/pages/sv-project-list.js +96 -6
  40. package/dist-client/pages/sv-project-list.js.map +1 -1
  41. package/dist-client/pages/sv-project-update.d.ts +86 -0
  42. package/dist-client/pages/sv-project-update.js +1121 -0
  43. package/dist-client/pages/sv-project-update.js.map +1 -0
  44. package/dist-client/route.d.ts +1 -1
  45. package/dist-client/route.js +3 -0
  46. package/dist-client/route.js.map +1 -1
  47. package/dist-client/shared/complete-api.d.ts +10 -9
  48. package/dist-client/shared/complete-api.js +47 -19
  49. package/dist-client/shared/complete-api.js.map +1 -1
  50. package/dist-client/tsconfig.tsbuildinfo +1 -1
  51. package/dist-client/viewparts/menu-tools.js +47 -54
  52. package/dist-client/viewparts/menu-tools.js.map +1 -1
  53. package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.d.ts +23 -0
  54. package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js +72 -28
  55. package/dist-server/service/kpi-metric-value/kpi-metric-value-mutation.js.map +1 -1
  56. package/dist-server/service/kpi-metric-value/kpi-metric-value-query.js +9 -2
  57. package/dist-server/service/kpi-metric-value/kpi-metric-value-query.js.map +1 -1
  58. package/dist-server/service/kpi-stat/kpi-stat-query.js +19 -18
  59. package/dist-server/service/kpi-stat/kpi-stat-query.js.map +1 -1
  60. package/dist-server/service/kpi-value/kpi-value-query.js +2 -2
  61. package/dist-server/service/kpi-value/kpi-value-query.js.map +1 -1
  62. package/dist-server/tsconfig.tsbuildinfo +1 -1
  63. package/package.json +3 -3
  64. package/schema.graphql +13 -1
  65. package/things-factory.config.js +1 -0
  66. package/dist-client/shared/domain-context.d.ts +0 -7
  67. package/dist-client/shared/domain-context.js +0 -13
  68. package/dist-client/shared/domain-context.js.map +0 -1
@@ -4,28 +4,48 @@ import { customElement, property, state } from 'lit/decorators.js';
4
4
  import { client } from '@operato/graphql';
5
5
  import { gql } from '@apollo/client';
6
6
  import { notify } from '@operato/layout';
7
+ import { OxPrompt } from '@operato/popup';
8
+ import { hasPrivilege } from '@things-factory/auth-base/dist-client';
7
9
  import moment from 'moment-timezone';
8
- const DATASET_ID = 'fd4092f5-11d0-488a-bbe8-21d2793e1e79';
9
- // 월별 수집 대상 항목 (Dataset의 dataItems tag 기준)
10
- const MONTHLY_ITEMS = [
11
- { tag: 'planned_progress', name: '계획공정율', unit: '%', type: 'number' },
12
- { tag: 'actual_progress', name: '실적공정율', unit: '%', type: 'number' },
13
- { tag: 'schedule_deviation', name: '일정 이탈 수준', unit: '', type: 'rating' },
14
- { tag: 'schedule_assessment', name: '일정성과 수준 평가', unit: '', type: 'rating' },
15
- { tag: 'cost_assessment', name: '비용성과 수준 평가', unit: '', type: 'rating' },
16
- { tag: 'quality_assessment', name: '품질성과 수준 평가', unit: '', type: 'rating' },
17
- { tag: 'safety_assessment', name: '안전성과 수준 평가', unit: '', type: 'rating' },
18
- { tag: 'environment_assessment', name: '환경성과 수준 평가', unit: '', type: 'rating' },
19
- { tag: 'productivity_assessment', name: '생산성성과 수준 평가', unit: '', type: 'rating' }
20
- ];
10
+ import { tenantHeaders } from '@dssp/project/dist-client/shared/domain-context';
11
+ /**
12
+ * 프로젝트 완공 처리 탭 4 — 월별 KPI 데이터 그리드.
13
+ *
14
+ * 데이터 source = `KpiMetric` (periodType=MONTH) + `KpiMetricValue` (그 metric 의
15
+ * 월별 값들). 이전 버전 (DataSet/DataSample) 폐기. KpiMetricValue unique
16
+ * 인덱스 (domain, metric, valueDate, org) (도메인, 프로젝트, 메트릭, 월) 단일 row
17
+ * 정책을 보장.
18
+ *
19
+ * 단방향 흐름:
20
+ * - 탭1/탭2 metric 저장 (valueDate=YYYY-MM-01) row 가 여기 자동 노출
21
+ * - 여기서 cell 수정 metric × row 만 update (탭1/탭2 의 "현재값" 영향 없음 —
22
+ * 탭1/탭2 는 KpiMetricValueProvider 가 latest 또는 valueDate-based lookup 으로 동작)
23
+ */
24
+ /** rating 셀렉트로 표시할 metric 인지 판단 — 이름에 '평가' 또는 '이탈' 포함되면 1~5 rating. */
25
+ function isRating(metricName) {
26
+ return /평가|이탈/.test(metricName || '');
27
+ }
21
28
  let SvProjectCompleteTab4Monthly = class SvProjectCompleteTab4Monthly extends LitElement {
22
29
  constructor() {
23
30
  super(...arguments);
24
31
  this.project = {};
25
- // monthRows: { workDate: 'YYYY-MM', data: {tag: value}, sampleId?: string, dirty: boolean }[]
32
+ /** 월별 metric 목록 (periodType=MONTH). KpiMetric admin 등록된 기준. */
33
+ this.monthlyMetrics = [];
34
+ /** monthRows: { workDate:'YYYY-MM', values:{[metricId]:value}, originalValues, dirty }[] */
26
35
  this.monthRows = [];
27
36
  this.addYear = new Date().getFullYear();
28
37
  this.addMonth = new Date().getMonth() + 1;
38
+ /** kpi:input — Step4 월별 데이터 저장 권한 (cumulative 와 같은 권한) */
39
+ this.canSave = false;
40
+ }
41
+ async connectedCallback() {
42
+ super.connectedCallback();
43
+ this.canSave = await hasPrivilege({
44
+ category: 'kpi',
45
+ privilege: 'input',
46
+ domainOwnerGranted: true,
47
+ superUserGranted: true
48
+ });
29
49
  }
30
50
  render() {
31
51
  const years = this._getYearRange();
@@ -36,7 +56,10 @@ let SvProjectCompleteTab4Monthly = class SvProjectCompleteTab4Monthly extends Li
36
56
  </div>
37
57
 
38
58
  <div class="toolbar">
39
- <select .value=${String(this.addYear)} @change=${(e) => (this.addYear = Number(e.target.value))}>
59
+ <select
60
+ .value=${String(this.addYear)}
61
+ @change=${(e) => (this.addYear = Number(e.target.value))}
62
+ >
40
63
  ${years.map(y => html `<option value=${y} ?selected=${y === this.addYear}>${y}년</option>`)}
41
64
  </select>
42
65
  <select
@@ -48,68 +71,90 @@ let SvProjectCompleteTab4Monthly = class SvProjectCompleteTab4Monthly extends Li
48
71
  <div class="add-btn" @click=${this._addMonth}>+ 월 추가</div>
49
72
  </div>
50
73
 
51
- ${this.monthRows.length === 0
52
- ? html `<div class="empty-msg">등록된 월별 데이터가 없습니다. 위에서 월을 추가해주세요.</div>`
53
- : html `
54
- <div class="grid-wrapper">
55
- <table>
56
- <thead>
57
- <tr>
58
- <th>월</th>
59
- ${MONTHLY_ITEMS.map(item => html `<th>${item.name}${item.unit ? ` (${item.unit})` : ''}</th>`)}
60
- <th>상태</th>
61
- <th></th>
62
- </tr>
63
- </thead>
64
- <tbody>
65
- ${this.monthRows.map((row, rowIdx) => html `
66
- <tr>
67
- <td class="month-cell ${this._isCurrentMonth(row.workDate) ? 'current' : ''}">${row.workDate}</td>
68
- ${MONTHLY_ITEMS.map(item => {
69
- var _a, _b;
70
- return item.type === 'rating'
71
- ? html `
72
- <td>
73
- <select
74
- class="rating-select"
75
- .value=${String((_a = row.data[item.tag]) !== null && _a !== void 0 ? _a : '')}
76
- @change=${(e) => this._onCellChange(rowIdx, item.tag, e.target.value)}
77
- >
78
- <option value="">-</option>
79
- ${[1, 2, 3, 4, 5].map(v => html `<option value=${v} ?selected=${Number(row.data[item.tag]) === v}>${v}</option>`)}
80
- </select>
81
- </td>
82
- `
83
- : html `
84
- <td>
85
- <input
86
- type="number"
87
- .value=${(_b = row.data[item.tag]) !== null && _b !== void 0 ? _b : ''}
88
- @input=${(e) => this._onCellChange(rowIdx, item.tag, e.target.value)}
89
- />
90
- </td>
91
- `;
92
- })}
93
- <td>
94
- ${row.sampleId
95
- ? row.dirty
74
+ ${this.monthlyMetrics.length === 0
75
+ ? html `<div class="empty-msg">
76
+ 월별 metric (periodType=MONTH) 이 등록되어 있지 않습니다. KPI 관리자에서 먼저 등록해주세요.
77
+ </div>`
78
+ : this.monthRows.length === 0
79
+ ? html `<div class="empty-msg">등록된 월별 데이터가 없습니다. 위에서 월을 추가해주세요.</div>`
80
+ : html `
81
+ <div class="grid-wrapper">
82
+ <table>
83
+ <thead>
84
+ <tr>
85
+ <th>월</th>
86
+ ${this.monthlyMetrics.map(m => html `
87
+ <th class=${this._isMetricNeverEntered(m.id) ? 'pending' : ''}
88
+ title=${this._isMetricNeverEntered(m.id) ? '한 번도 입력된 적 없는 metric' : ''}>
89
+ ${m.name}${m.unit ? ` (${m.unit})` : ''}
90
+ </th>
91
+ `)}
92
+ <th>상태</th>
93
+ <th></th>
94
+ </tr>
95
+ </thead>
96
+ <tbody>
97
+ ${this.monthRows.map((row, rowIdx) => html `
98
+ <tr>
99
+ <td class="month-cell ${this._isCurrentMonth(row.workDate) ? 'current' : ''}">
100
+ ${row.workDate}
101
+ </td>
102
+ ${this.monthlyMetrics.map(m => {
103
+ var _a, _b;
104
+ const pending = this._isCellPending(row, m.id);
105
+ return isRating(m.name)
106
+ ? html `
107
+ <td class=${pending ? 'pending' : ''}>
108
+ <select
109
+ class="rating-select"
110
+ .value=${String((_a = row.values[m.id]) !== null && _a !== void 0 ? _a : '')}
111
+ @change=${(e) => this._onCellChange(rowIdx, m.id, e.target.value)}
112
+ >
113
+ <option value="">-</option>
114
+ ${[1, 2, 3, 4, 5].map(v => html `<option value=${v} ?selected=${Number(row.values[m.id]) === v}>
115
+ ${v}
116
+ </option>`)}
117
+ </select>
118
+ </td>
119
+ `
120
+ : html `
121
+ <td class=${pending ? 'pending' : ''}>
122
+ <input
123
+ type="number"
124
+ .value=${(_b = row.values[m.id]) !== null && _b !== void 0 ? _b : ''}
125
+ @input=${(e) => this._onCellChange(rowIdx, m.id, e.target.value)}
126
+ />
127
+ </td>
128
+ `;
129
+ })}
130
+ <td>
131
+ ${row.dirty
96
132
  ? html `<span class="status-unsaved">수정됨</span>`
97
- : html `<span class="status-saved">저장됨</span>`
98
- : html `<span class="status-new">신규</span>`}
99
- </td>
100
- <td>
101
- <span class="delete-btn" title="삭제" @click=${() => this._removeMonth(rowIdx)}>✕</span>
102
- </td>
103
- </tr>
104
- `)}
105
- </tbody>
106
- </table>
107
- </div>
108
- `}
133
+ : row.isAuto
134
+ ? html `<span class="status-pending">미입력</span>`
135
+ : row.isNew
136
+ ? html `<span class="status-new">신규</span>`
137
+ : html `<span class="status-saved">저장됨</span>`}
138
+ </td>
139
+ <td>
140
+ <span class="delete-btn" title="삭제" @click=${() => this._removeMonth(rowIdx)}>✕</span>
141
+ </td>
142
+ </tr>
143
+ `)}
144
+ </tbody>
145
+ </table>
146
+ </div>
147
+ `}
109
148
 
110
149
  <div class="button-line">
111
150
  <div class="ghost-btn" @click=${this._reset}>초기화</div>
112
- <div class="ghost-btn secondary" @click=${this._save}>저장</div>
151
+ <div
152
+ class="ghost-btn secondary ${this.canSave ? '' : 'disabled'}"
153
+ title=${this.canSave ? '' : 'kpi:input 권한 필요'}
154
+ @click=${() => this.canSave && this._save()}
155
+ >
156
+ 저장
157
+ </div>
113
158
  </div>
114
159
  `;
115
160
  }
@@ -122,7 +167,9 @@ let SvProjectCompleteTab4Monthly = class SvProjectCompleteTab4Monthly extends Li
122
167
  }
123
168
  _getYearRange() {
124
169
  var _a, _b;
125
- const startYear = ((_a = this.project) === null || _a === void 0 ? void 0 : _a.startDate) ? new Date(this.project.startDate).getFullYear() : new Date().getFullYear() - 2;
170
+ const startYear = ((_a = this.project) === null || _a === void 0 ? void 0 : _a.startDate)
171
+ ? new Date(this.project.startDate).getFullYear()
172
+ : new Date().getFullYear() - 2;
126
173
  const endYear = ((_b = this.project) === null || _b === void 0 ? void 0 : _b.endDate)
127
174
  ? new Date(this.project.endDate).getFullYear() + 1
128
175
  : new Date().getFullYear() + 1;
@@ -134,130 +181,262 @@ let SvProjectCompleteTab4Monthly = class SvProjectCompleteTab4Monthly extends Li
134
181
  _isCurrentMonth(workDate) {
135
182
  return workDate === moment().tz('Asia/Seoul').format('YYYY-MM');
136
183
  }
184
+ /**
185
+ * 월별 metric 정의 + 그 프로젝트의 월별 KpiMetricValue 들을 조회해 그리드 row 구성.
186
+ *
187
+ * 1) KpiMetric where periodType=MONTH → monthlyMetrics
188
+ * 2) KpiMetricValue where org=projectId → 월별로 그룹핑하여 monthRows
189
+ */
137
190
  async _loadData() {
138
- var _a, _b;
191
+ var _a, _b, _c, _d, _e, _f;
139
192
  try {
140
- const response = await client.query({
193
+ // 1) 월별 metric 목록
194
+ const metricsResp = await client.query({
141
195
  query: gql `
142
- query DataSamplesByDataSet($dataSetId: String!, $filters: [Filter!], $sortings: [Sorting!], $pagination: Pagination) {
143
- dataSamplesByDataSet(dataSetId: $dataSetId, filters: $filters, sortings: $sortings, pagination: $pagination) {
196
+ query KpiMetrics {
197
+ kpiMetrics {
144
198
  items {
145
199
  id
146
- data
147
- workDate
148
- key01
200
+ name
201
+ unit
202
+ periodType
149
203
  }
150
- total
151
204
  }
152
205
  }
153
206
  `,
154
- variables: {
155
- dataSetId: DATASET_ID,
156
- filters: [{ name: 'key01', operator: 'eq', value: this.project.id }],
157
- sortings: [{ name: 'workDate', desc: false }],
158
- pagination: { page: 1, limit: 120 }
159
- }
207
+ context: { headers: tenantHeaders((_a = this.project) === null || _a === void 0 ? void 0 : _a.code) }
160
208
  });
161
- const items = ((_b = (_a = response.data) === null || _a === void 0 ? void 0 : _a.dataSamplesByDataSet) === null || _b === void 0 ? void 0 : _b.items) || [];
162
- this.monthRows = items.map((sample) => {
163
- var _a;
164
- return ({
165
- workDate: ((_a = sample.workDate) === null || _a === void 0 ? void 0 : _a.substring(0, 7)) || '', // YYYY-MM
166
- data: sample.data || {},
167
- sampleId: sample.id,
168
- dirty: false
169
- });
209
+ const all = ((_c = (_b = metricsResp.data) === null || _b === void 0 ? void 0 : _b.kpiMetrics) === null || _c === void 0 ? void 0 : _c.items) || [];
210
+ this.monthlyMetrics = all.filter((m) => m.periodType === 'MONTH');
211
+ if (this.monthlyMetrics.length === 0) {
212
+ this.monthRows = [];
213
+ return;
214
+ }
215
+ // 2) 프로젝트의 KpiMetricValue 들 (org=projectId). 월별 metric 만 client 측에서 필터.
216
+ const monthlyMetricIds = new Set(this.monthlyMetrics.map((m) => m.id));
217
+ const valuesResp = await client.query({
218
+ query: gql `
219
+ query KpiMetricValues($filters: [Filter!]) {
220
+ kpiMetricValues(filters: $filters) {
221
+ items {
222
+ id
223
+ value
224
+ unit
225
+ periodType
226
+ valueDate
227
+ metricId
228
+ }
229
+ }
230
+ }
231
+ `,
232
+ variables: { filters: [{ name: 'org', operator: 'eq', value: this.project.id }] },
233
+ context: { headers: tenantHeaders((_d = this.project) === null || _d === void 0 ? void 0 : _d.code) }
170
234
  });
235
+ const allValues = ((_f = (_e = valuesResp.data) === null || _e === void 0 ? void 0 : _e.kpiMetricValues) === null || _f === void 0 ? void 0 : _f.items) || [];
236
+ // periodType='MONTH' 만 그리드에 — 비월별 row 가 valueDate prefix 로 잘못 묶이지 않도록.
237
+ const monthlyValues = allValues.filter((v) => monthlyMetricIds.has(v.metricId) && v.periodType === 'MONTH');
238
+ // 월별 그룹핑 — valueDate YYYY-MM-DD 의 앞 7자리. id 도 보존 (삭제 시 사용).
239
+ // 현재월(=this month) row 는 운영 원칙상 표시 대상 아님 → 아예 bucket 제외.
240
+ const currentYm = moment().tz('Asia/Seoul').format('YYYY-MM');
241
+ const byMonth = new Map();
242
+ for (const v of monthlyValues) {
243
+ const ym = (v.valueDate || '').slice(0, 7);
244
+ if (!ym)
245
+ continue;
246
+ if (ym === currentYm)
247
+ continue; // 현재월 record 무시 (DB 에 우연히 있더라도)
248
+ if (!byMonth.has(ym))
249
+ byMonth.set(ym, { workDate: ym, values: {}, originalValues: {}, valueIds: {}, dirty: false });
250
+ const row = byMonth.get(ym);
251
+ row.values[v.metricId] = v.value;
252
+ row.originalValues[v.metricId] = v.value;
253
+ row.valueIds[v.metricId] = v.id;
254
+ }
255
+ // project.startDate ~ 현재월 사이 누락된 월 placeholder row 자동 추가.
256
+ // 사용자가 값을 입력하지 않으면 dirty 가 안 되므로 저장 시 무시되어 spurious patch 없음.
257
+ for (const ym of this._generateExpectedMonths()) {
258
+ if (!byMonth.has(ym)) {
259
+ byMonth.set(ym, {
260
+ workDate: ym,
261
+ values: {},
262
+ originalValues: {},
263
+ valueIds: {},
264
+ dirty: false,
265
+ isAuto: true
266
+ });
267
+ }
268
+ }
269
+ this.monthRows = Array.from(byMonth.values()).sort((a, b) => b.workDate.localeCompare(a.workDate));
171
270
  }
172
271
  catch (e) {
173
272
  console.error('Failed to load monthly data:', e);
174
273
  this.monthRows = [];
175
274
  }
176
275
  }
276
+ /** project.startDate (YYYY-MM) ~ **전월** (YYYY-MM) 까지 매월 문자열 배열.
277
+ * 현재월은 의도적으로 제외 — 운영 원칙상 "이번달 데이터는 아직 입력 대상이 아님". */
278
+ _generateExpectedMonths() {
279
+ var _a;
280
+ const startStr = (((_a = this.project) === null || _a === void 0 ? void 0 : _a.startDate) || '').slice(0, 7);
281
+ if (!startStr || !/^\d{4}-\d{2}$/.test(startStr))
282
+ return [];
283
+ const tz = 'Asia/Seoul';
284
+ let cur = moment.tz(startStr, 'YYYY-MM', tz);
285
+ const end = moment.tz(moment().tz(tz).subtract(1, 'month').format('YYYY-MM'), 'YYYY-MM', tz);
286
+ if (!cur.isValid() || cur.isAfter(end))
287
+ return [];
288
+ const months = [];
289
+ while (cur.isSameOrBefore(end)) {
290
+ months.push(cur.format('YYYY-MM'));
291
+ cur = cur.add(1, 'month');
292
+ }
293
+ return months;
294
+ }
295
+ /** 셀 미입력 여부 — 값 없음 AND 그 월이 **전월** (직전 한 달) 인 경우만 pending.
296
+ * 과월 전체가 아니라 전월 한 달에 한해서만 강조 — 사용자 입력 흐름(이번 달에 지난달 데이터)과 일치. */
297
+ _isCellPending(row, metricId) {
298
+ var _a;
299
+ const v = (_a = row.values) === null || _a === void 0 ? void 0 : _a[metricId];
300
+ if (v !== undefined && v !== null && v !== '')
301
+ return false;
302
+ const lastMonth = moment().tz('Asia/Seoul').subtract(1, 'month').format('YYYY-MM');
303
+ return row.workDate === lastMonth;
304
+ }
305
+ /** metric 컬럼이 전 row 통틀어 한 번도 값이 없으면 헤더 강조. */
306
+ _isMetricNeverEntered(metricId) {
307
+ return !this.monthRows.some(r => {
308
+ var _a;
309
+ const v = (_a = r.values) === null || _a === void 0 ? void 0 : _a[metricId];
310
+ return v !== undefined && v !== null && v !== '';
311
+ });
312
+ }
177
313
  _addMonth() {
178
314
  const workDate = `${this.addYear}-${String(this.addMonth).padStart(2, '0')}`;
179
- // 중복 체크
315
+ // 현재월 row 는 추가 금지 — 운영 원칙상 이번달 데이터는 입력 대상 아님.
316
+ const currentYm = moment().tz('Asia/Seoul').format('YYYY-MM');
317
+ if (workDate === currentYm) {
318
+ notify({ message: '현재월(이번 달) 데이터는 입력 대상이 아닙니다.' });
319
+ return;
320
+ }
321
+ // 미래월도 차단
322
+ if (workDate > currentYm) {
323
+ notify({ message: '미래월은 입력 대상이 아닙니다.' });
324
+ return;
325
+ }
180
326
  if (this.monthRows.some(r => r.workDate === workDate)) {
181
327
  notify({ message: `${workDate}은 이미 존재합니다.` });
182
328
  return;
183
329
  }
184
- const newRow = {
185
- workDate,
186
- data: {},
187
- sampleId: null,
188
- dirty: true
189
- };
190
- // 정렬된 위치에 삽입
330
+ const newRow = { workDate, values: {}, originalValues: {}, valueIds: {}, dirty: true, isNew: true };
191
331
  const rows = [...this.monthRows, newRow];
192
- rows.sort((a, b) => a.workDate.localeCompare(b.workDate));
332
+ rows.sort((a, b) => b.workDate.localeCompare(a.workDate));
193
333
  this.monthRows = rows;
194
334
  }
195
- _removeMonth(rowIdx) {
196
- this.monthRows = this.monthRows.filter((_, i) => i !== rowIdx);
335
+ async _removeMonth(rowIdx) {
336
+ var _a;
337
+ const row = this.monthRows[rowIdx];
338
+ // 신규 row (DB 미반영) — 메모리에서만 제거
339
+ if (row.isNew) {
340
+ this.monthRows = this.monthRows.filter((_, i) => i !== rowIdx);
341
+ return;
342
+ }
343
+ // 저장된 row — 그 월의 모든 KpiMetricValue id 들을 한 번에 삭제.
344
+ // 권한: things-factory 의 deleteKpiMetricValues 는 category="kpi", privilege="mutation"
345
+ // (superUser/도메인 owner 자동 통과, 일반 사용자는 별도 grant 필요).
346
+ const ids = Object.values(row.valueIds || {}).filter(Boolean);
347
+ if (ids.length === 0) {
348
+ this.monthRows = this.monthRows.filter((_, i) => i !== rowIdx);
349
+ return;
350
+ }
351
+ const ok = await OxPrompt.open({
352
+ title: `${row.workDate} 의 월별 데이터를 삭제하시겠습니까?`,
353
+ confirmButton: { text: '삭제' },
354
+ cancelButton: { text: '취소' }
355
+ });
356
+ if (!ok)
357
+ return;
358
+ try {
359
+ const response = await client.mutate({
360
+ mutation: gql `
361
+ mutation DeleteKpiMetricValues($ids: [String!]!) {
362
+ deleteKpiMetricValues(ids: $ids)
363
+ }
364
+ `,
365
+ variables: { ids },
366
+ context: { headers: tenantHeaders((_a = this.project) === null || _a === void 0 ? void 0 : _a.code) }
367
+ });
368
+ if (response.errors) {
369
+ throw new Error(response.errors.map((e) => e.message).join('\n'));
370
+ }
371
+ notify({ message: `${row.workDate} 월별 데이터 ${ids.length}건 삭제되었습니다.` });
372
+ await this._loadData();
373
+ }
374
+ catch (e) {
375
+ console.error('Failed to delete monthly values:', e);
376
+ notify({ message: '삭제 중 오류가 발생했습니다. 권한을 확인해주세요.' });
377
+ }
197
378
  }
198
- _onCellChange(rowIdx, tag, rawValue) {
379
+ _onCellChange(rowIdx, metricId, rawValue) {
199
380
  const value = rawValue === '' ? null : Number(rawValue);
200
381
  this.monthRows = this.monthRows.map((row, i) => {
201
382
  if (i !== rowIdx)
202
383
  return row;
203
- return Object.assign(Object.assign({}, row), { data: Object.assign(Object.assign({}, row.data), { [tag]: value }), dirty: true });
384
+ return Object.assign(Object.assign({}, row), { values: Object.assign(Object.assign({}, row.values), { [metricId]: value }), dirty: true });
204
385
  });
205
386
  }
387
+ /**
388
+ * 저장 — dirty row 들 안의 변경된 cell 들을 KpiMetricValuePatch 로 모아
389
+ * updateKpiMetricValuesCumulative 한 번 호출 (backend upsert 가 unique 조합 처리).
390
+ */
206
391
  async _save() {
207
- const dirtyRows = this.monthRows.filter(r => r.dirty);
208
- if (dirtyRows.length === 0) {
392
+ var _a;
393
+ const patches = [];
394
+ for (const row of this.monthRows) {
395
+ if (!row.dirty)
396
+ continue;
397
+ for (const m of this.monthlyMetrics) {
398
+ const value = row.values[m.id];
399
+ const original = row.originalValues[m.id];
400
+ if (value === undefined || value === null)
401
+ continue; // 빈 값은 skip (기존 유지)
402
+ if (value === original)
403
+ continue; // 변화 없으면 skip
404
+ patches.push({
405
+ metricId: m.id,
406
+ value,
407
+ unit: m.unit || '',
408
+ org: this.project.id,
409
+ periodType: 'MONTH',
410
+ valueDate: `${row.workDate}-01`
411
+ });
412
+ }
413
+ }
414
+ if (patches.length === 0) {
209
415
  notify({ message: '변경된 데이터가 없습니다.' });
210
416
  return;
211
417
  }
212
- let savedCount = 0;
213
- let errorCount = 0;
214
- for (const row of dirtyRows) {
215
- try {
216
- // data에 project_id 포함 (DataKeySet이 key01에 매핑)
217
- const data = Object.assign(Object.assign({}, row.data), { project_id: this.project.id });
218
- // null 값 제거
219
- for (const key of Object.keys(data)) {
220
- if (data[key] === null || data[key] === undefined)
221
- delete data[key];
222
- }
223
- // collectedAt을 월 시작일 고정으로 설정 → 동일 key01+collectedAt이면 기존 레코드 업데이트
224
- const collectedAt = new Date(`${row.workDate}-01T00:00:00Z`);
225
- await client.mutate({
226
- mutation: gql `
227
- mutation CreateDataSample($dataSample: NewDataSample!) {
228
- createDataSample(dataSample: $dataSample) {
229
- id
230
- data
231
- workDate
232
- key01
233
- }
234
- }
235
- `,
236
- variables: {
237
- dataSample: {
238
- dataSet: { id: DATASET_ID },
239
- data,
240
- workDate: `${row.workDate}-01`,
241
- collectedAt: collectedAt.toISOString(),
242
- source: 'project-complete'
243
- }
244
- }
245
- });
246
- savedCount++;
418
+ try {
419
+ const response = await client.mutate({
420
+ mutation: gql `
421
+ mutation UpdateKpiMetricValuesCumulative($patches: [KpiMetricValuePatch!]!) {
422
+ updateKpiMetricValuesCumulative(patches: $patches) {
423
+ id
247
424
  }
248
- catch (e) {
249
- console.error(`Failed to save ${row.workDate}:`, e);
250
- errorCount++;
425
+ }
426
+ `,
427
+ variables: { patches },
428
+ context: { headers: tenantHeaders((_a = this.project) === null || _a === void 0 ? void 0 : _a.code) }
429
+ });
430
+ if (response.errors) {
431
+ throw new Error(response.errors.map((e) => e.message).join('\n'));
251
432
  }
433
+ notify({ message: `${patches.length}건 저장되었습니다.` });
434
+ await this._loadData();
252
435
  }
253
- if (errorCount > 0) {
254
- notify({ message: `${savedCount}건 저장, ${errorCount}건 오류 발생` });
255
- }
256
- else {
257
- notify({ message: `${savedCount}건 저장되었습니다.` });
436
+ catch (e) {
437
+ console.error('Failed to save monthly values:', e);
438
+ notify({ message: '저장 중 오류가 발생했습니다.' });
258
439
  }
259
- // 저장 후 다시 로드하여 sampleId 갱신
260
- await this._loadData();
261
440
  }
262
441
  _reset() {
263
442
  this._loadData();
@@ -372,6 +551,23 @@ SvProjectCompleteTab4Monthly.styles = [
372
551
  color: #3498db;
373
552
  font-size: 12px;
374
553
  }
554
+ .status-pending {
555
+ color: #e74c3c;
556
+ font-size: 12px;
557
+ font-weight: 600;
558
+ }
559
+
560
+ /* 미입력 셀 — 과월/현재월 row 에서 값이 비어있을 때 강조 */
561
+ td.pending input[type='number'],
562
+ td.pending .rating-select {
563
+ border-color: #e74c3c;
564
+ background-color: #fff5f5;
565
+ }
566
+ /* 한 번도 입력된 적 없는 metric 컬럼 — 헤더 강조 */
567
+ th.pending {
568
+ background: #fdecec;
569
+ color: #c0392b;
570
+ }
375
571
 
376
572
  .button-line {
377
573
  display: flex;
@@ -392,6 +588,10 @@ SvProjectCompleteTab4Monthly.styles = [
392
588
  .ghost-btn.secondary {
393
589
  background: #24be7b;
394
590
  }
591
+ .ghost-btn.disabled {
592
+ opacity: 0.45;
593
+ cursor: not-allowed;
594
+ }
395
595
 
396
596
  .delete-btn {
397
597
  cursor: pointer;
@@ -414,6 +614,10 @@ __decorate([
414
614
  property({ type: Object }),
415
615
  __metadata("design:type", Object)
416
616
  ], SvProjectCompleteTab4Monthly.prototype, "project", void 0);
617
+ __decorate([
618
+ state(),
619
+ __metadata("design:type", Array)
620
+ ], SvProjectCompleteTab4Monthly.prototype, "monthlyMetrics", void 0);
417
621
  __decorate([
418
622
  state(),
419
623
  __metadata("design:type", Array)
@@ -426,6 +630,10 @@ __decorate([
426
630
  state(),
427
631
  __metadata("design:type", Number)
428
632
  ], SvProjectCompleteTab4Monthly.prototype, "addMonth", void 0);
633
+ __decorate([
634
+ state(),
635
+ __metadata("design:type", Object)
636
+ ], SvProjectCompleteTab4Monthly.prototype, "canSave", void 0);
429
637
  SvProjectCompleteTab4Monthly = __decorate([
430
638
  customElement('sv-pc-tab4-monthly')
431
639
  ], SvProjectCompleteTab4Monthly);