@things-factory/ops-master 10.1.33 → 10.1.34

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 (66) hide show
  1. package/dist-client/index.d.ts +1 -0
  2. package/dist-client/index.js +1 -0
  3. package/dist-client/index.js.map +1 -1
  4. package/dist-client/master-change-approval.d.ts +7 -0
  5. package/dist-client/master-change-approval.js +20 -0
  6. package/dist-client/master-change-approval.js.map +1 -0
  7. package/dist-client/pages/master/test-specification.d.ts +22 -85
  8. package/dist-client/pages/master/test-specification.js +190 -405
  9. package/dist-client/pages/master/test-specification.js.map +1 -1
  10. package/dist-client/tsconfig.tsbuildinfo +1 -1
  11. package/dist-server/service/asset/asset-master-resolver.js +38 -34
  12. package/dist-server/service/asset/asset-master-resolver.js.map +1 -1
  13. package/dist-server/service/asset/index.d.ts +2 -2
  14. package/dist-server/service/equipment/equipment-master-resolver.js +54 -49
  15. package/dist-server/service/equipment/equipment-master-resolver.js.map +1 -1
  16. package/dist-server/service/equipment/index.d.ts +2 -2
  17. package/dist-server/service/index.d.ts +8 -2
  18. package/dist-server/service/index.js +8 -0
  19. package/dist-server/service/index.js.map +1 -1
  20. package/dist-server/service/location/index.d.ts +1 -1
  21. package/dist-server/service/location/location-master-resolver.js +34 -31
  22. package/dist-server/service/location/location-master-resolver.js.map +1 -1
  23. package/dist-server/service/master-change-policy.d.ts +23 -0
  24. package/dist-server/service/master-change-policy.js +129 -0
  25. package/dist-server/service/master-change-policy.js.map +1 -0
  26. package/dist-server/service/master-crud.js +6 -0
  27. package/dist-server/service/master-crud.js.map +1 -1
  28. package/dist-server/service/material/index.d.ts +2 -2
  29. package/dist-server/service/material/material-master-resolver.js +55 -51
  30. package/dist-server/service/material/material-master-resolver.js.map +1 -1
  31. package/dist-server/service/operation/index.d.ts +1 -1
  32. package/dist-server/service/operation/operation-master-resolver.js +28 -25
  33. package/dist-server/service/operation/operation-master-resolver.js.map +1 -1
  34. package/dist-server/service/personnel/index.d.ts +2 -2
  35. package/dist-server/service/personnel/personnel-master-resolver.js +38 -34
  36. package/dist-server/service/personnel/personnel-master-resolver.js.map +1 -1
  37. package/dist-server/service/quality/nonconformance-cause-resolver.js +17 -14
  38. package/dist-server/service/quality/nonconformance-cause-resolver.js.map +1 -1
  39. package/dist-server/service/recipe/index.d.ts +2 -2
  40. package/dist-server/service/recipe/recipe-master-resolver.js +32 -28
  41. package/dist-server/service/recipe/recipe-master-resolver.js.map +1 -1
  42. package/dist-server/service/test-spec/index.d.ts +3 -2
  43. package/dist-server/service/test-spec/index.js +4 -2
  44. package/dist-server/service/test-spec/index.js.map +1 -1
  45. package/dist-server/service/test-spec/test-spec-effective-list.d.ts +32 -0
  46. package/dist-server/service/test-spec/test-spec-effective-list.js +40 -0
  47. package/dist-server/service/test-spec/test-spec-effective-list.js.map +1 -0
  48. package/dist-server/service/test-spec/test-spec-master-resolver.d.ts +4 -14
  49. package/dist-server/service/test-spec/test-spec-master-resolver.js +12 -83
  50. package/dist-server/service/test-spec/test-spec-master-resolver.js.map +1 -1
  51. package/dist-server/service/test-spec/test-spec-query.js +7 -1
  52. package/dist-server/service/test-spec/test-spec-query.js.map +1 -1
  53. package/dist-server/service/test-spec/test-spec-revision-resolver.d.ts +52 -0
  54. package/dist-server/service/test-spec/test-spec-revision-resolver.js +76 -0
  55. package/dist-server/service/test-spec/test-spec-revision-resolver.js.map +1 -0
  56. package/dist-server/service/test-spec/test-spec-revision-service.d.ts +117 -0
  57. package/dist-server/service/test-spec/test-spec-revision-service.js +519 -0
  58. package/dist-server/service/test-spec/test-spec-revision-service.js.map +1 -0
  59. package/dist-server/service/test-spec/test-spec-revision.d.ts +82 -0
  60. package/dist-server/service/test-spec/test-spec-revision.js +329 -0
  61. package/dist-server/service/test-spec/test-spec-revision.js.map +1 -0
  62. package/dist-server/service/test-spec/test-specification.d.ts +4 -0
  63. package/dist-server/service/test-spec/test-specification.js +11 -2
  64. package/dist-server/service/test-spec/test-specification.js.map +1 -1
  65. package/dist-server/tsconfig.tsbuildinfo +1 -1
  66. package/package.json +9 -6
@@ -2,446 +2,231 @@ import { __decorate, __metadata } from "tslib";
2
2
  import '@operato/data-grist/ox-grist.js';
3
3
  import gql from 'graphql-tag';
4
4
  import { css, html } from 'lit';
5
- import { customElement, query, state } from 'lit/decorators.js';
6
- import { DataGrist } from '@operato/data-grist';
5
+ import { customElement, state } from 'lit/decorators.js';
7
6
  import { client } from '@operato/graphql';
8
7
  import { i18next } from '@operato/i18n';
9
- import { CustomAlert } from '@operato/shell';
10
8
  import { notify } from '@operato/layout';
11
9
  import { EFFECTIVE, IDENTITY, MasterPageBase } from './master-page-base.js';
12
- /**
13
- * 무엇을 어떻게 검사하나 — 항목과 상하한과 방법.
14
- *
15
- * 인원 등급과 자산 등급이 이것을 요구 검사로 가리킨다.
16
- *
17
- * ── 왜 이 축만 자기 화면을 갖나 ────────────────────────────────────────────
18
- * 다른 축의 칸은 글자 · 숫자 · 날짜라서 공통 그리드 하나로 충분하다. 검사 명세는 다르다. **명세 하나가
19
- * 기준 여럿을 갖는다.** 기준 하나는 이름 · 설명 · 측정 속성 · 하한 · 상한 · 단위 · 표현식을 갖는다.
20
- *
21
- * 그것을 한 칸에 JSON 원문으로 보이면 품질팀장이 상한을 40.5 로 고치려고 중괄호를 세게 된다. 그것은
22
- * 기준 정보를 다루는 화면이 아니라 개발자가 JSON 을 손보는 화면이다. 실제로 한동안 그랬다.
23
- *
24
- * ── 화면은 JSON 을 모른다 ──────────────────────────────────────────────────
25
- * 서버가 기준을 **행으로** 낸다(`testSpecificationCriteria`) 그리고 행으로 받는다
26
- * (`updateTestSpecificationCriteria`). 옮김은 서버의 `criteria-rows.ts` 한 곳이 맡는다.
27
- *
28
- * 그래서 저장이 언젠가 표로 내려가도 **이 파일은 바뀌지 않는다.** 화면이 JSON 을 풀면 그날 이 코드가
29
- * 통째로 버려진다.
30
- *
31
- * ── 위아래로 나눈다 ────────────────────────────────────────────────────────
32
- * 위는 명세 목록, 아래는 **고른 명세의 기준**이다. 아무것도 안 고른 상태를 빈 표로 두지 않는다 —
33
- * 빈 표는 「기준이 없는 명세」와 같아 보이고, 그러면 사람이 기준을 다시 적는다.
34
- */
10
+ import { requestMasterChangeApproval } from '../../master-change-approval.js';
11
+ /** Identity list, approved schedule, and draft workspace are separate surfaces. */
35
12
  let OpsMasterTestSpecification = class OpsMasterTestSpecification extends MasterPageBase {
36
13
  constructor() {
37
14
  super(...arguments);
38
- /**
39
- * 지금 체크된 명세들.
40
- *
41
- * 아래는 **하나의 기준만** 보이는데 위에서 여럿을 체크할 수 있다(명세 여럿을 한 번에 종료하는 데
42
- * 쓴다). 그때 화면이 아무 말도 안 하면 보이는 기준이 **여럿을 합친 것**으로 읽힌다. 그래서 몇 개가
43
- * 체크돼 있는지 세어 말한다.
44
- */
45
- this.checked = new Set();
46
- /**
47
- * 아래 표에서 아직 저장하지 않은 기준이 몇 건인가.
48
- *
49
- * ── 왜 세어 보이나 (2026-09-05) ────────────────────────────────────────
50
- * 이 화면은 표가 둘인데 「저장」은 하나다. 아래를 고쳐 놓고도 그것이 저장 대상인지 화면 어디에도
51
- * 안 적혀 있었다. 「복잡한 편집인데 저장 버튼의 기능이 위험해 보인다」(사용자).
52
- *
53
- * 세어서 표 머리에 적으면 두 가지가 한꺼번에 풀린다. 저장을 누르기 전에 **무엇이 나가는지** 보이고,
54
- * 다른 명세로 넘어가려 할 때 **버릴 것이 있다**는 사실도 보인다.
55
- */
56
- this.dirtyCriteria = 0;
15
+ this.newName = '';
16
+ this.busy = false;
17
+ this.dirty = false;
18
+ this.loadSerial = 0;
57
19
  }
58
- static { this.styles = [
59
- ...MasterPageBase.styles,
60
- css `
61
- :host {
62
- display: flex;
63
- flex-direction: column;
64
- }
65
-
66
- /* 위가 목록, 아래가 그 명세의 기준. 기준 쪽을 조금 더 준다 — 줄이 여럿이다. */
67
- .split {
68
- display: flex;
69
- flex-direction: column;
70
- flex: 1;
71
- min-height: 0;
72
- }
73
-
74
- .split > ox-grist {
75
- flex: 4;
76
- min-height: 0;
77
- }
78
-
79
- .criteria {
80
- display: flex;
81
- flex-direction: column;
82
- flex: 5;
83
- min-height: 0;
84
- border-top: 1px solid var(--ui-line, #c9d4d9);
85
- }
86
-
87
- .criteria-head {
88
- display: flex;
89
- align-items: center;
90
- justify-content: space-between;
91
- gap: 8px;
92
- padding: 6px 12px;
93
- background-color: var(--ui-panel-2, #f3f6f7);
94
- }
95
-
96
- .criteria-head .what {
97
- font-size: 13px;
98
- font-weight: 600;
99
- color: var(--ui-ink, #17272f);
100
- }
101
-
102
- /* 아직 저장하지 않은 것이 있다 — 저장을 누르기 전에 무엇이 나가는지 여기서 읽는다. */
103
- .criteria-head .pending {
104
- margin-right: 8px;
105
- font-family: inherit;
106
- font-style: normal;
107
- font-weight: 600;
108
- color: var(--status-warning-color, #b26a00);
109
- }
110
-
111
- .criteria-head .among {
112
- margin-left: 6px;
113
- font-style: normal;
114
- color: var(--status-warning-color, #b26a00);
115
- }
116
-
117
- .criteria-head .who {
118
- font-family: var(--theme-font-mono, monospace);
119
- font-size: 12px;
120
- color: var(--ui-muted, #57676f);
121
- }
122
-
123
- .criteria ox-grist {
124
- flex: 1;
125
- min-height: 0;
126
- }
127
-
128
- /* 아직 아무 명세도 안 골랐다. 빈 표를 보이지 않는다. */
129
- .nothing-picked {
130
- flex: 1;
131
- display: flex;
132
- align-items: center;
133
- justify-content: center;
134
- padding: 24px;
135
- text-align: center;
136
- font-size: 13px;
137
- line-height: 1.6;
138
- color: var(--ui-muted, #57676f);
139
- }
140
- `
141
- ]; }
20
+ static { this.styles = [...MasterPageBase.styles, css `
21
+ :host { display:flex; flex-direction:column; min-height:0 }
22
+ ox-grist { flex:2; min-height:160px }
23
+ section { flex:3; overflow:auto; padding:16px; border-top:1px solid var(--ui-line,#ccd5da) }
24
+ .toolbar,.fields { display:flex; flex-wrap:wrap; gap:12px; align-items:end; margin-bottom:12px }
25
+ label { display:flex; flex-direction:column; gap:4px; font-size:13px }
26
+ input,button { font:inherit; padding:7px; border:1px solid var(--ui-line,#ccd5da); border-radius:3px }
27
+ input { min-width:100px; max-width:220px }
28
+ button { cursor:pointer; background:var(--ui-panel-2,#f3f6f7) }
29
+ button:disabled { cursor:default; opacity:.5 }
30
+ table { width:100%; border-collapse:collapse; margin:10px 0 18px; font-size:13px }
31
+ th,td { padding:7px; text-align:left; border-bottom:1px solid var(--ui-line,#ccd5da); vertical-align:top }
32
+ td input { width:100%; min-width:70px; box-sizing:border-box }
33
+ .notice { padding:10px; background:var(--ui-panel-2,#f3f6f7) }
34
+ .changed { background:var(--status-warning-background,#fff2d0) }
35
+ h3 { margin:14px 0 8px; font-size:15px }
36
+ `]; }
37
+ t(key, fallback) { return i18next.t(`test-spec-revision.${key}`, { defaultValue: fallback }); }
142
38
  get spec() {
143
- return {
144
- key: 'testSpecification',
145
- title: 'test-specification',
146
- help: 'ops-master/test-specification',
147
- query: 'testSpecifications',
148
- mutation: 'updateMultipleTestSpecification',
149
- columns: [...IDENTITY, ...EFFECTIVE]
150
- };
39
+ return { key: 'testSpecification', title: 'test-specification', help: 'ops-master/test-specification',
40
+ query: 'testSpecifications', mutation: 'updateMultipleTestSpecification',
41
+ columns: [...IDENTITY, { name: 'version', label: 'version', type: 'string' }, ...EFFECTIVE].map(column => ({ ...column, readonly: true })) };
151
42
  }
152
- countDirtyCriteria() {
153
- this.dirtyCriteria = (this.criteriaGrist?.dirtyRecords ?? []).length;
43
+ get context() {
44
+ return { ...super.context, importable: undefined, actions: [{
45
+ title: this.t('new', '새 검사 명세'), icon: 'add', emphasis: { raised: true }, action: () => this.startNew()
46
+ }] };
154
47
  }
155
- /**
156
- * 기준 표의 구성.
157
- *
158
- * **단위 칸을 좁히지 않는다.** `MMT`(mm) · `CEL`(℃) 같은 UN/CEFACT 코드가 들어가고, 잘리면
159
- * 「무엇으로 잰 값인지」가 화면에서 사라진다.
160
- */
161
- get criteriaConfig() {
162
- return {
163
- /*
164
- * 명세는 골랐는데 그 안에 판단 기준이 하나도 없는 자리다. 왼쪽의 빈 목록과 뜻이 다르다 —
165
- * 「검사 명세가 없다」가 아니라 「이 명세가 아직 무엇으로 합격을 가르는지 말하지 않는다」이다.
166
- *
167
- * 고른 것이 없을 때는 아래 render 가 표 자체를 보이지 않는다(그 자리 주석 참조).
168
- */
169
- emptyNote: {
170
- title: i18next.t('text.criteria-empty'),
171
- description: i18next.t('text.criteria-empty-hint')
172
- },
173
- list: { fields: ['id', 'description'], details: [] },
174
- columns: [
175
- { type: 'gutter', gutterName: 'sequence', fixed: true },
176
- { type: 'gutter', gutterName: 'row-selector', multiple: true, fixed: true },
177
- {
178
- type: 'string',
179
- name: 'id',
180
- header: i18next.t('field.criterion-id'),
181
- record: { editable: true },
182
- width: 160,
183
- fixed: true
184
- },
185
- {
186
- type: 'string',
187
- name: 'description',
188
- header: i18next.t('field.description'),
189
- record: { editable: true },
190
- width: 180
191
- },
192
- {
193
- type: 'string',
194
- name: 'evaluatedPropertyId',
195
- header: i18next.t('field.evaluated-property'),
196
- record: { editable: true },
197
- width: 150
198
- },
199
- { type: 'float', name: 'minimum', header: i18next.t('field.minimum'), record: { editable: true }, width: 100 },
200
- { type: 'float', name: 'maximum', header: i18next.t('field.maximum'), record: { editable: true }, width: 100 },
201
- { type: 'string', name: 'uom', header: i18next.t('field.uom'), record: { editable: true }, width: 90 },
202
- {
203
- type: 'string',
204
- name: 'expression',
205
- header: i18next.t('field.expression'),
206
- record: { editable: true },
207
- width: 200
208
- }
209
- ],
210
- rows: { appendable: true, selectable: { multiple: true } }
211
- };
48
+ configOf() {
49
+ const config = super.configOf();
50
+ return { ...config, rows: { ...config.rows, appendable: false, selectable: { multiple: false } } };
212
51
  }
213
- render() {
214
- return html `
215
- <div class="split">
216
- <ox-grist
217
- .mode=${this.mode}
218
- .config=${this.gristConfig}
219
- .fetchHandler=${this.fetchHandler.bind(this)}
220
- .personalConfigProvider=${this.getPagePreferenceProvider(`ox-grist-${this.spec.key}`)}
221
- @select-record-change=${(e) => this.pick(e)}
222
- >
223
- <div slot="headroom" class="header">${this.renderScope()}</div>
224
- <ox-grist-personalizer slot="setting"></ox-grist-personalizer>
225
- </ox-grist>
226
-
227
- <div class="criteria">
228
- <div class="criteria-head">
229
- <span class="what">${i18next.t('field.criteria')}</span>
230
- <span class="who">
231
- ${this.dirtyCriteria
232
- ? html `<em class="pending">${i18next.t('text.n-unsaved-criteria', { count: this.dirtyCriteria })}</em>`
233
- : ''}
234
- ${this.picked ?? ''}
235
- <!--
236
- 여럿 체크했는데 하나만 보이는 상태를 **말한다.** 아무 말도 안 하면 보이는 기준이
237
- 여럿을 합친 것으로 읽힌다.
238
- -->
239
- ${this.checked.size > 1
240
- ? html `<em class="among">${i18next.t('text.of-n-selected', { count: this.checked.size })}</em>`
241
- : ''}
242
- </span>
243
- </div>
244
- ${this.picked
245
- ? html `
246
- <ox-grist
247
- id="criteria-grist"
248
- mode="GRID"
249
- .config=${this.criteriaConfig}
250
- .fetchHandler=${this.fetchCriteria.bind(this)}
251
- @field-change=${() => this.countDirtyCriteria()}
252
- @record-change=${() => this.countDirtyCriteria()}
253
- ></ox-grist>
254
- `
255
- : html `
256
- <!--
257
- 빈 표를 보이지 않는다. 빈 표는 「기준이 없는 명세」와 같아 보이고, 그러면 사람이
258
- 기준을 다시 적는다.
259
- -->
260
- <div class="nothing-picked">${i18next.t('text.pick-a-specification-to-see-criteria')}</div>
261
- `}
262
- </div>
263
- </div>
264
- `;
52
+ canLeave() { return !this.dirty || window.confirm(this.t('discard', '저장하지 않은 초안을 버리시겠습니까?')); }
53
+ startNew() {
54
+ if (this.busy || !this.canLeave())
55
+ return;
56
+ ++this.loadSerial;
57
+ this.overview = undefined;
58
+ this.newName = '';
59
+ this.draft = { version: '1', effectiveFrom: new Date().toISOString(), reason: '', criteria: [] };
60
+ this.dirty = false;
265
61
  }
266
- /**
267
- * 위에서 고른 줄이 아래를 정한다.
268
- *
269
- * ── detail 모양이 거터 설정에 따라 다르다 ─────────────────────────────────
270
- * `gutter-row-selector.js` 를 보면 두 모양을 낸다.
271
- *
272
- * ```
273
- * multiple: true { added: [record], removed: [] } ← 이 화면이 쓰는 것
274
- * 그 밖 { records: [record] }
275
- * ```
276
- *
277
- * 한쪽만 읽으면 **오류보고없이 아무것도 안 골라진다.** 오류가 나지 않고 아래 표만 영원히 안내 문구로
278
- * 남는다. 처음에 `records` 만 읽어서 실제로 그랬다. 그리드가 세우는 `__selected__` 를 읽는 방법도
279
- * 있는데, 그 표시는 이 화면에 오지 않았다. 그래서 **둘 다 읽는다.**
280
- *
281
- * 여럿 고르면 마지막에 고른 것을 본다. 아래는 명세 하나의 기준을 보이는 자리라 둘을 함께 보일 수
282
- * 없고, 아무 것이나 고르면 사람은 그것이 무엇의 기준인지 모른다.
283
- */
284
- async pick(e) {
285
- const detail = (e.detail ?? {});
286
- const nameOf = (record) => String(record?.name ?? '');
287
- const added = detail.added ?? detail.records ?? [];
288
- const removed = detail.removed ?? [];
289
- /* `records` 는 「지금 고른 것 전부」다. `added`·`removed` 는 「이번에 바뀐 것」이다. */
290
- const next = detail.records ? new Set() : new Set(this.checked);
291
- for (const record of removed)
292
- next.delete(nameOf(record));
293
- for (const record of added)
294
- next.add(nameOf(record));
295
- /* 아래에 무엇이 보일지 먼저 정하고, 바꾸기 전에 고치던 것이 있는지 본다. */
296
- let picked = this.picked;
297
- if (added.length) {
298
- picked = nameOf(added[added.length - 1]) || undefined;
299
- }
300
- else if (removed.some(record => nameOf(record) === this.picked)) {
301
- /* 지금 보고 있던 것을 풀었다. 남은 것이 하나면 그것을 보인다. */
302
- picked = next.size ? [...next][next.size - 1] : undefined;
303
- }
304
- else if (detail.records && !detail.records.length) {
305
- picked = undefined;
306
- }
307
- this.checked = next;
308
- if (picked === this.picked) {
62
+ async pick(event) {
63
+ const record = event.detail?.records?.[0] ?? event.detail?.added?.[0];
64
+ if (!record?.id || this.busy || !this.canLeave())
309
65
  return;
310
- }
311
- /*
312
- * ── 고치던 기준을 **말없이 버리지 않는다** (2026-09-05) ────────────────
313
- *
314
- * 아래 표는 「지금 고른 명세」에 걸려 있다. 명세를 바꾸면 아래는 새 명세의 기준을 다시 읽어
315
- * 오고, 고치던 값은 그때 사라진다. [확인함] 2026-09-05 · CP-CAM-LEAK 의 하한을 고쳐 두고
316
- * CP-CAM-OQC 를 고른 뒤 돌아오니 원래 값이었다. 화면은 아무 말도 하지 않았다.
317
- *
318
- * 두 갈래로만 묻는다 — 어느 쪽을 골라도 넘어간다. 「머무르기」를 두면 위의 체크는 이미 옮겨
319
- * 갔는데 아래만 옛 명세를 보이는, 화면과 사실이 어긋난 상태가 남는다.
320
- */
321
- if (this.dirtyCriteria > 0) {
322
- const answer = await CustomAlert({
323
- type: 'question',
324
- title: i18next.t('text.n-unsaved-criteria', { count: this.dirtyCriteria }),
325
- text: i18next.t('text.leaving-discards-unsaved-criteria'),
326
- confirmButton: { text: i18next.t('button.save-and-leave') },
327
- cancelButton: { text: i18next.t('button.discard-and-leave') }
328
- });
329
- if (answer?.isConfirmed) {
330
- await this.save();
331
- }
332
- }
333
- this.picked = picked;
334
- this.dirtyCriteria = 0;
66
+ await this.run(async () => { await this.load(record.id); this.draft = undefined; this.dirty = false; });
335
67
  }
336
- /**
337
- * 고른 명세의 기준을 읽는다.
338
- *
339
- * 못 읽으면 **빈 목록으로 바꾸지 않는다.** 빈 목록은 「기준이 없다」와 같아 보이고, 그러면 사람이
340
- * 기준을 다시 적어 넣는다. 무엇이 잘못됐는지 말하고 비운다.
341
- */
342
- async fetchCriteria() {
343
- if (!this.picked) {
344
- return { total: 0, records: [] };
345
- }
68
+ async load(specId) {
69
+ const serial = ++this.loadSerial;
70
+ const response = await client.query({ query: gql `query($specId:String!){testSpecificationRevisions(specId:$specId)}`,
71
+ variables: { specId }, fetchPolicy: 'no-cache' });
72
+ this.assertResponse(response);
73
+ if (serial === this.loadSerial)
74
+ this.overview = response.data.testSpecificationRevisions;
75
+ }
76
+ assertResponse(response) {
77
+ if (response?.errors?.length)
78
+ throw new Error(response.errors.map((error) => error.message).join('\n'));
79
+ if (!response?.data)
80
+ throw new Error(this.t('no-response', '서버 응답을 확인할 수 없습니다. 다시 조회하십시오.'));
81
+ }
82
+ async run(work) {
83
+ if (this.busy)
84
+ return;
85
+ this.busy = true;
346
86
  try {
347
- const response = await client.query({
348
- query: gql `
349
- query ($name: String!) {
350
- testSpecificationCriteria(name: $name) {
351
- id
352
- description
353
- evaluatedPropertyId
354
- minimum
355
- maximum
356
- uom
357
- expression
358
- keptFields
359
- }
360
- }
361
- `,
362
- variables: { name: this.picked },
363
- fetchPolicy: 'no-cache'
364
- });
365
- const rows = (response.data?.testSpecificationCriteria ?? []);
366
- /* 어느 줄인지 서버가 알아야 고칠 수 있다 — 이름은 고칠 수 있으므로 이름으로 찾을 수 없다. */
367
- return { total: rows.length, records: rows.map((row, index) => ({ ...row, rowIndex: index })) };
87
+ await work();
368
88
  }
369
89
  catch (error) {
370
90
  notify({ message: error.message, level: 'error' });
371
- return { total: 0, records: [] };
91
+ }
92
+ finally {
93
+ this.busy = false;
372
94
  }
373
95
  }
374
- /**
375
- * 저장 — **위와 아래를 따로 낸다.**
376
- *
377
- * 명세 자체(이름 · 버전 · 유효 기간)는 공통 규칙이 저장하고, 기준은 이 축의 뮤테이션이 저장한다.
378
- * 기준이 막히면 **명세 쪽도 되돌린다.** 절반만 저장되면 화면과 저장된 것이 달라진다.
379
- */
380
- async save() {
381
- const criteriaPatches = this.picked ? (this.criteriaGrist?.dirtyRecords ?? []) : [];
382
- if (criteriaPatches.length) {
383
- let response;
384
- try {
385
- response = await client.mutate({
386
- mutation: gql `
387
- mutation ($name: String!, $patches: [Object!]!) {
388
- updateTestSpecificationCriteria(name: $name, patches: $patches) {
389
- id
390
- }
391
- }
392
- `,
393
- variables: { name: this.picked, patches: criteriaPatches }
394
- });
395
- }
396
- catch (error) {
397
- /* 무엇이 왜 막혔는지 그대로 보인다 — 「저장할 수 없습니다」로는 고칠 곳을 모른다. */
398
- notify({ message: error.message, level: 'error' });
96
+ openRevision(revision, fork = false) {
97
+ if (this.busy || !this.canLeave())
98
+ return;
99
+ this.draft = JSON.parse(JSON.stringify(revision));
100
+ if (fork)
101
+ this.draft = { ...this.draft, id: undefined, frozenAt: undefined, activityInstanceId: undefined,
102
+ editGeneration: undefined, effectiveFrom: new Date().toISOString(), reason: '' };
103
+ this.dirty = false;
104
+ }
105
+ change(field, value) { this.draft = { ...this.draft, [field]: value }; this.dirty = true; }
106
+ criterion(index, field, value) {
107
+ const criteria = this.draft.criteria.map(row => ({ ...row, ...(row.limit ? { limit: { ...row.limit } } : {}) }));
108
+ if (['minimum', 'maximum', 'uom'].includes(field)) {
109
+ criteria[index].limit ??= {};
110
+ if (!value)
111
+ delete criteria[index].limit[field];
112
+ else
113
+ criteria[index].limit[field] = field === 'uom' ? value : Number(value);
114
+ if (!Object.keys(criteria[index].limit).length)
115
+ delete criteria[index].limit;
116
+ }
117
+ else
118
+ criteria[index][field] = value;
119
+ this.change('criteria', criteria);
120
+ }
121
+ async save() { await this.run(() => this.persist()); }
122
+ async persist() {
123
+ if (!this.draft || this.draft.frozenAt)
124
+ return;
125
+ const { version, description, effectiveFrom, reason, criteria } = this.draft;
126
+ const content = { version, description, effectiveFrom, reason, criteria };
127
+ const response = this.draft.id
128
+ ? await client.mutate({ mutation: gql `mutation($revisionId:String!,$editGeneration:Int!,$content:Object!){editTestSpecificationDraft(revisionId:$revisionId,editGeneration:$editGeneration,content:$content)}`,
129
+ variables: { revisionId: this.draft.id, editGeneration: this.draft.editGeneration, content } })
130
+ : await client.mutate({ mutation: gql `mutation($specId:String,$name:String,$content:Object!){createTestSpecificationDraft(specId:$specId,name:$name,content:$content)}`,
131
+ variables: { specId: this.overview?.specId, name: this.overview ? undefined : this.newName, content } });
132
+ this.assertResponse(response);
133
+ this.draft = response.data.editTestSpecificationDraft ?? response.data.createTestSpecificationDraft;
134
+ this.dirty = false;
135
+ await this.load(this.draft.specId);
136
+ this.grist?.fetch();
137
+ }
138
+ async submit() {
139
+ await this.run(async () => {
140
+ await this.persist();
141
+ if (!this.draft?.id || this.draft.frozenAt)
399
142
  return;
143
+ const policy = await client.query({ query: gql `query { masterChangePolicy(axis:"testSpecification") }`, fetchPolicy: 'no-cache' });
144
+ this.assertResponse(policy);
145
+ if (policy.data.masterChangePolicy.approvalRequired) {
146
+ await requestMasterChangeApproval(this, { axis: 'testSpecification', revisionId: this.draft.id, editGeneration: this.draft.editGeneration });
400
147
  }
401
- /*
402
- * **`errors` 를 직접 본다.** 이 앱의 GraphQL 클라이언트는 서버가 거절해도 던지지 않고
403
- * `response.errors` 에 담아 돌려준다. `try/catch` 만 두면 거절이 오류 없이 지나가고, 화면은
404
- * 아무 일 없었다는 듯 다음으로 넘어간다 — **고친 것이 버려졌는데 사람은 저장된 줄 안다.**
405
- * 2026-09-02에 실제로 그랬다(단위를 지운 기준이 서버에서 막혔는데 화면은 「바뀐 것이 없습니다」).
406
- */
407
- if (response?.errors?.length) {
408
- notify({ message: response.errors.map((error) => error.message).join('\n'), level: 'error' });
409
- return;
148
+ else {
149
+ const response = await client.mutate({ mutation: gql `mutation($revisionId:String!,$editGeneration:Int!){publishTestSpecificationDraft(revisionId:$revisionId,editGeneration:$editGeneration)}`,
150
+ variables: { revisionId: this.draft.id, editGeneration: this.draft.editGeneration } });
151
+ this.assertResponse(response);
410
152
  }
411
- }
412
- await super.save();
413
- if (criteriaPatches.length) {
414
- this.criteriaGrist?.fetch();
415
- }
153
+ await this.load(this.draft.specId);
154
+ this.draft = this.overview.revisions.find(row => row.id === this.draft.id);
155
+ notify({ message: policy.data.masterChangePolicy.approvalRequired ? this.t('submitted', '승인을 요청했습니다. 승인 전에는 검사에 적용되지 않습니다.') : this.t('published', '정책에 따라 발행했습니다. 지정한 시각부터 적용됩니다.'), level: 'info' });
156
+ });
157
+ }
158
+ criteriaTable(revision, editable = false, before) {
159
+ const fields = ['id', 'description', 'evaluatedPropertyId', 'minimum', 'maximum', 'uom', 'expression'];
160
+ const value = (row, field) => ['minimum', 'maximum', 'uom'].includes(field) ? row.limit?.[field] : row[field];
161
+ return html `<table><thead><tr>${fields.map(field => html `<th>${field}</th>`)}${editable ? html `<th></th>` : ''}</tr></thead>
162
+ <tbody>${revision.criteria.map((row, index) => html `<tr>${fields.map(field => {
163
+ const prior = before?.criteria.find(item => item.id === row.id);
164
+ const changed = before && value(prior ?? {}, field) !== value(row, field);
165
+ return html `<td class=${changed ? 'changed' : ''}>${editable
166
+ ? html `<input aria-label=${`${row.id || index + 1} ${field}`} .value=${String(value(row, field) ?? '')}
167
+ type=${['minimum', 'maximum'].includes(field) ? 'number' : 'text'} step="any"
168
+ @input=${(e) => this.criterion(index, field, e.target.value)}>`
169
+ : value(row, field) ?? '—'}${changed ? html `<div><small>← ${value(prior ?? {}, field) ?? '—'}</small></div>` : ''}</td>`;
170
+ })}${editable ? html `<td><button @click=${() => this.change('criteria', revision.criteria.filter((_, i) => i !== index))}>${this.t('remove', '삭제')}</button></td>` : ''}</tr>`)}
171
+ </tbody></table>`;
172
+ }
173
+ render() {
174
+ const current = this.overview?.schedule.find(row => row.state === 'current');
175
+ const currentRevision = this.overview?.revisions.find(row => row.id === current?.revisionId);
176
+ const readonly = !!this.draft?.frozenAt || this.busy;
177
+ return html `
178
+ <ox-grist .mode=${this.mode} .config=${this.gristConfig} .fetchHandler=${this.fetchHandler.bind(this)} @select-record-change=${this.pick.bind(this)}></ox-grist>
179
+ <section aria-busy=${this.busy}>
180
+ ${this.overview ? html `<div class="toolbar"><h3>${this.overview.name}</h3>
181
+ <button ?disabled=${this.busy} @click=${() => this.run(() => this.load(this.overview.specId))}>${this.t('refresh', '새로고침')}</button>
182
+ <button ?disabled=${this.busy} @click=${() => this.openRevision(currentRevision ?? { version: '1', effectiveFrom: new Date().toISOString(), reason: '', criteria: [] }, true)}>${this.t('fork', '변경 초안 만들기')}</button></div>
183
+ <h3>${this.t('schedule', '승인된 적용 시간표')}</h3>
184
+ <table><thead><tr><th>${this.t('state', '상태')}</th><th>${this.t('version', '버전')}</th><th>${this.t('from', '이 시각부터')}</th><th>${this.t('to', '이 시각 직전까지')}</th></tr></thead>
185
+ <tbody>${this.overview.schedule.map(row => html `<tr><td>${this.t(row.state, { current: '현재 적용', scheduled: '적용 예정', expired: '적용 종료' }[row.state] ?? row.state)}</td>
186
+ <td><button @click=${() => this.openRevision(this.overview.revisions.find(item => item.id === row.revisionId))}>${this.overview.revisions.find(item => item.id === row.revisionId)?.version}</button></td>
187
+ <td>${row.validFrom}</td><td>${row.validTo ?? '∞'}</td></tr>`)}</tbody></table>
188
+ ${!current ? html `<p class="notice">${this.t('no-current', '현재 적용 가능한 승인본이 없습니다.')}</p>` : ''}
189
+ <h3>${this.t('drafts', '초안 · 상신본')}</h3><div class="toolbar">${this.overview.revisions.filter(row => !this.overview.schedule.some(segment => segment.revisionId === row.id)).map(row => html `
190
+ <button @click=${() => this.openRevision(row)}>${row.version} · ${row.frozenAt ? this.t('frozen', '상신본 — 결재 상태는 업무함에서 확인') : this.t('draft', '편집 초안')}</button>`)}</div>
191
+ ` : !this.draft ? html `<p>${this.t('pick', '검사 명세를 선택하거나 새 명세를 만드십시오.')}</p>` : ''}
192
+ ${this.draft ? html `<h3>${this.draft.frozenAt ? this.t('immutable', '동결된 내용 — 수정하려면 새 초안을 만드십시오') : this.t('editing', '편집 초안 — 아직 적용되지 않음')}</h3>
193
+ <div class="fields">
194
+ ${!this.overview ? html `<label>${this.t('name', '명세 이름')}<input ?disabled=${readonly} .value=${this.newName} @input=${(e) => { this.newName = e.target.value; this.dirty = true; }}></label>` : ''}
195
+ ${['version', 'description', 'effectiveFrom', 'reason'].map(field => html `<label>${this.t(field, { version: '버전', description: '설명', effectiveFrom: '적용 시작 (시간대 포함 ISO 시각)', reason: '변경 사유' }[field])}
196
+ <input ?disabled=${readonly} .value=${this.draft[field] ?? ''} @input=${(e) => this.change(field, e.target.value)}></label>`)}
197
+ </div>
198
+ ${this.criteriaTable(this.draft, !readonly, currentRevision)}
199
+ ${currentRevision ? html `<details><summary>${this.t('before', '현재 적용 중인 기준 전체 보기 (삭제된 항목 포함)')}</summary>${this.criteriaTable(currentRevision)}</details>` : ''}
200
+ <div class="toolbar">
201
+ <button ?disabled=${readonly} @click=${() => this.change('criteria', [...this.draft.criteria, { id: '' }])}>${this.t('add', '기준 추가')}</button>
202
+ <button ?disabled=${readonly} @click=${() => this.save()}>${this.t('save', '초안 저장')}</button>
203
+ <button ?disabled=${readonly} @click=${() => this.submit()}>${this.t('submit', '저장 후 발행 / 승인 요청')}</button>
204
+ ${this.draft.frozenAt ? html `<button ?disabled=${this.busy} @click=${() => this.openRevision(this.draft, true)}>${this.t('fork', '변경 초안 만들기')}</button>` : ''}
205
+ ${this.dirty ? html `<span>${this.t('unsaved', '저장하지 않은 변경이 있습니다.')}</span>` : ''}
206
+ </div>` : ''}
207
+ </section>`;
416
208
  }
417
209
  };
418
210
  __decorate([
419
211
  state(),
420
- __metadata("design:type", String)
421
- ], OpsMasterTestSpecification.prototype, "picked", void 0);
212
+ __metadata("design:type", Object)
213
+ ], OpsMasterTestSpecification.prototype, "overview", void 0);
422
214
  __decorate([
423
215
  state(),
424
216
  __metadata("design:type", Object)
425
- ], OpsMasterTestSpecification.prototype, "checked", void 0);
217
+ ], OpsMasterTestSpecification.prototype, "draft", void 0);
426
218
  __decorate([
427
- query('#criteria-grist'),
428
- __metadata("design:type", DataGrist
429
- /**
430
- * 아래 표에서 아직 저장하지 않은 기준이 몇 건인가.
431
- *
432
- * ── 왜 세어 보이나 (2026-09-05) ────────────────────────────────────────
433
- * 이 화면은 표가 둘인데 「저장」은 하나다. 아래를 고쳐 놓고도 그것이 저장 대상인지 화면 어디에도
434
- * 안 적혀 있었다. 「복잡한 편집인데 저장 버튼의 기능이 위험해 보인다」(사용자).
435
- *
436
- * 세어서 표 머리에 적으면 두 가지가 한꺼번에 풀린다. 저장을 누르기 전에 **무엇이 나가는지** 보이고,
437
- * 다른 명세로 넘어가려 할 때 **버릴 것이 있다**는 사실도 보인다.
438
- */
439
- )
440
- ], OpsMasterTestSpecification.prototype, "criteriaGrist", void 0);
219
+ state(),
220
+ __metadata("design:type", Object)
221
+ ], OpsMasterTestSpecification.prototype, "newName", void 0);
222
+ __decorate([
223
+ state(),
224
+ __metadata("design:type", Object)
225
+ ], OpsMasterTestSpecification.prototype, "busy", void 0);
441
226
  __decorate([
442
227
  state(),
443
228
  __metadata("design:type", Object)
444
- ], OpsMasterTestSpecification.prototype, "dirtyCriteria", void 0);
229
+ ], OpsMasterTestSpecification.prototype, "dirty", void 0);
445
230
  OpsMasterTestSpecification = __decorate([
446
231
  customElement('ops-master-test-specification')
447
232
  ], OpsMasterTestSpecification);