@things-factory/ops-master 10.1.31 → 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/equipment/equipment-master-resolver.js +54 -49
- package/dist-server/service/equipment/equipment-master-resolver.js.map +1 -1
- 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/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/material-master-resolver.js +55 -51
- package/dist-server/service/material/material-master-resolver.js.map +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/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/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);
|