@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.
- package/dist-client/index.d.ts +1 -0
- package/dist-client/index.js +1 -0
- package/dist-client/index.js.map +1 -1
- package/dist-client/master-change-approval.d.ts +7 -0
- package/dist-client/master-change-approval.js +20 -0
- package/dist-client/master-change-approval.js.map +1 -0
- package/dist-client/pages/master/test-specification.d.ts +22 -85
- package/dist-client/pages/master/test-specification.js +190 -405
- package/dist-client/pages/master/test-specification.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/asset/asset-master-resolver.js +38 -34
- package/dist-server/service/asset/asset-master-resolver.js.map +1 -1
- package/dist-server/service/asset/index.d.ts +2 -2
- package/dist-server/service/equipment/equipment-master-resolver.js +54 -49
- package/dist-server/service/equipment/equipment-master-resolver.js.map +1 -1
- package/dist-server/service/equipment/index.d.ts +2 -2
- package/dist-server/service/index.d.ts +8 -2
- package/dist-server/service/index.js +8 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/location/index.d.ts +1 -1
- package/dist-server/service/location/location-master-resolver.js +34 -31
- package/dist-server/service/location/location-master-resolver.js.map +1 -1
- package/dist-server/service/master-change-policy.d.ts +23 -0
- package/dist-server/service/master-change-policy.js +129 -0
- package/dist-server/service/master-change-policy.js.map +1 -0
- package/dist-server/service/master-crud.js +6 -0
- package/dist-server/service/master-crud.js.map +1 -1
- package/dist-server/service/material/index.d.ts +2 -2
- package/dist-server/service/material/material-master-resolver.js +55 -51
- package/dist-server/service/material/material-master-resolver.js.map +1 -1
- package/dist-server/service/operation/index.d.ts +1 -1
- package/dist-server/service/operation/operation-master-resolver.js +28 -25
- package/dist-server/service/operation/operation-master-resolver.js.map +1 -1
- package/dist-server/service/personnel/index.d.ts +2 -2
- package/dist-server/service/personnel/personnel-master-resolver.js +38 -34
- package/dist-server/service/personnel/personnel-master-resolver.js.map +1 -1
- package/dist-server/service/quality/nonconformance-cause-resolver.js +17 -14
- package/dist-server/service/quality/nonconformance-cause-resolver.js.map +1 -1
- package/dist-server/service/recipe/index.d.ts +2 -2
- package/dist-server/service/recipe/recipe-master-resolver.js +32 -28
- package/dist-server/service/recipe/recipe-master-resolver.js.map +1 -1
- package/dist-server/service/test-spec/index.d.ts +3 -2
- package/dist-server/service/test-spec/index.js +4 -2
- package/dist-server/service/test-spec/index.js.map +1 -1
- package/dist-server/service/test-spec/test-spec-effective-list.d.ts +32 -0
- package/dist-server/service/test-spec/test-spec-effective-list.js +40 -0
- package/dist-server/service/test-spec/test-spec-effective-list.js.map +1 -0
- package/dist-server/service/test-spec/test-spec-master-resolver.d.ts +4 -14
- package/dist-server/service/test-spec/test-spec-master-resolver.js +12 -83
- package/dist-server/service/test-spec/test-spec-master-resolver.js.map +1 -1
- package/dist-server/service/test-spec/test-spec-query.js +7 -1
- package/dist-server/service/test-spec/test-spec-query.js.map +1 -1
- package/dist-server/service/test-spec/test-spec-revision-resolver.d.ts +52 -0
- package/dist-server/service/test-spec/test-spec-revision-resolver.js +76 -0
- package/dist-server/service/test-spec/test-spec-revision-resolver.js.map +1 -0
- package/dist-server/service/test-spec/test-spec-revision-service.d.ts +117 -0
- package/dist-server/service/test-spec/test-spec-revision-service.js +519 -0
- package/dist-server/service/test-spec/test-spec-revision-service.js.map +1 -0
- package/dist-server/service/test-spec/test-spec-revision.d.ts +82 -0
- package/dist-server/service/test-spec/test-spec-revision.js +329 -0
- package/dist-server/service/test-spec/test-spec-revision.js.map +1 -0
- package/dist-server/service/test-spec/test-specification.d.ts +4 -0
- package/dist-server/service/test-spec/test-specification.js +11 -2
- package/dist-server/service/test-spec/test-specification.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- 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,
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
145
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
this.busy = false;
|
|
372
94
|
}
|
|
373
95
|
}
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
403
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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",
|
|
421
|
-
], OpsMasterTestSpecification.prototype, "
|
|
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, "
|
|
217
|
+
], OpsMasterTestSpecification.prototype, "draft", void 0);
|
|
426
218
|
__decorate([
|
|
427
|
-
|
|
428
|
-
__metadata("design:type",
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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, "
|
|
229
|
+
], OpsMasterTestSpecification.prototype, "dirty", void 0);
|
|
445
230
|
OpsMasterTestSpecification = __decorate([
|
|
446
231
|
customElement('ops-master-test-specification')
|
|
447
232
|
], OpsMasterTestSpecification);
|