@things-factory/work-shift 10.1.34 → 10.1.36
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/pages/work-shift.d.ts +18 -102
- package/dist-client/pages/work-shift.js +165 -781
- package/dist-client/pages/work-shift.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/controllers/work-shift-range.d.ts +25 -9
- package/dist-server/controllers/work-shift-range.js +49 -196
- package/dist-server/controllers/work-shift-range.js.map +1 -1
- package/dist-server/controllers/work-shift-schedule.d.ts +2 -0
- package/dist-server/controllers/work-shift-schedule.js +9 -18
- package/dist-server/controllers/work-shift-schedule.js.map +1 -1
- package/dist-server/service/index.d.ts +5 -2
- package/dist-server/service/index.js +3 -0
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/work-shift/index.d.ts +4 -4
- package/dist-server/service/work-shift/index.js +4 -3
- package/dist-server/service/work-shift/index.js.map +1 -1
- package/dist-server/service/work-shift/shift-occurrence.d.ts +19 -0
- package/dist-server/service/work-shift/shift-occurrence.js +34 -0
- package/dist-server/service/work-shift/shift-occurrence.js.map +1 -0
- package/dist-server/service/work-shift/work-shift-arrangement.d.ts +10 -0
- package/dist-server/service/work-shift/work-shift-arrangement.js +40 -0
- package/dist-server/service/work-shift/work-shift-arrangement.js.map +1 -0
- package/dist-server/service/work-shift/work-shift-content.d.ts +15 -0
- package/dist-server/service/work-shift/work-shift-content.js +55 -0
- package/dist-server/service/work-shift/work-shift-content.js.map +1 -0
- package/dist-server/service/work-shift/work-shift-lifecycle-resolver.d.ts +60 -0
- package/dist-server/service/work-shift/work-shift-lifecycle-resolver.js +77 -0
- package/dist-server/service/work-shift/work-shift-lifecycle-resolver.js.map +1 -0
- package/dist-server/service/work-shift/work-shift-lifecycle.d.ts +130 -0
- package/dist-server/service/work-shift/work-shift-lifecycle.js +521 -0
- package/dist-server/service/work-shift/work-shift-lifecycle.js.map +1 -0
- package/dist-server/service/work-shift/work-shift-mutation.js +9 -42
- package/dist-server/service/work-shift/work-shift-mutation.js.map +1 -1
- package/dist-server/service/work-shift/work-shift-query.js +30 -15
- package/dist-server/service/work-shift/work-shift-query.js.map +1 -1
- package/dist-server/service/work-shift/work-shift-revision-service.d.ts +29 -65
- package/dist-server/service/work-shift/work-shift-revision-service.js +67 -167
- package/dist-server/service/work-shift/work-shift-revision-service.js.map +1 -1
- package/dist-server/service/work-shift/work-shift-revision.d.ts +78 -62
- package/dist-server/service/work-shift/work-shift-revision.js +303 -82
- package/dist-server/service/work-shift/work-shift-revision.js.map +1 -1
- package/dist-server/service/work-shift/work-shift-type.d.ts +0 -5
- package/dist-server/service/work-shift/work-shift-type.js +1 -16
- package/dist-server/service/work-shift/work-shift-type.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/helps/page/work-shift.ko.md +19 -3
- package/helps/page/work-shift.md +17 -9
- package/package.json +6 -3
- package/dist-server/service/work-shift/work-shift-revision-query.d.ts +0 -20
- package/dist-server/service/work-shift/work-shift-revision-query.js +0 -74
- package/dist-server/service/work-shift/work-shift-revision-query.js.map +0 -1
|
@@ -1,824 +1,208 @@
|
|
|
1
1
|
import { __decorate, __metadata } from "tslib";
|
|
2
|
-
import '@operato/input/ox-input-work-shift.js';
|
|
3
2
|
import gql from 'graphql-tag';
|
|
4
3
|
import { css, html } from 'lit';
|
|
5
4
|
import { customElement, state } from 'lit/decorators.js';
|
|
6
5
|
import { client } from '@operato/graphql';
|
|
7
6
|
import { i18next } from '@operato/i18n';
|
|
8
7
|
import { PageView } from '@operato/shell';
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
const DAY_MINUTES = 24 * HOUR_MINUTES;
|
|
8
|
+
import { DocumentPageStyles, ScrollbarStyles } from '@operato/styles';
|
|
9
|
+
import { revisionText as t, revisionDate, revisionContents, revisionStyles } from '@things-factory/ops-master/dist-client/pages/master/revision-comparison.js';
|
|
10
|
+
import { localDateTime, isoFromLocal } from '@things-factory/ops-master/dist-client/pages/master/revision-view.js';
|
|
13
11
|
let WorkShift = class WorkShift extends PageView {
|
|
14
12
|
constructor() {
|
|
15
13
|
super(...arguments);
|
|
16
|
-
this.
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
overflow-y: auto;
|
|
48
|
-
font-size: var(--ui-t-body);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/*
|
|
52
|
-
* 하루를 가로로 펴서 어디가 비었는지 눈에 보이게 한다.
|
|
53
|
-
*
|
|
54
|
-
* 틈은 **칠하지 않고 사선 무늬**로 둔다. 아무것도 없는 것과 「여기가 틈이다」는 눈에 다르게
|
|
55
|
-
* 보여야 하고, 색으로만 가르면 색을 못 가르는 사람에게 전해지지 않는다.
|
|
56
|
-
*/
|
|
57
|
-
/* 이 화면의 답이 여기 있다. 다른 것보다 크고 조용하게 둔다. */
|
|
58
|
-
.band {
|
|
59
|
-
position: relative;
|
|
60
|
-
height: 52px;
|
|
61
|
-
border-radius: var(--ui-r-small);
|
|
62
|
-
border: 1px solid var(--ui-line);
|
|
63
|
-
background-color: var(--ui-panel);
|
|
64
|
-
background-image: repeating-linear-gradient(
|
|
65
|
-
45deg,
|
|
66
|
-
transparent 0 5px,
|
|
67
|
-
var(--ui-line) 5px 6px
|
|
68
|
-
);
|
|
69
|
-
overflow: hidden;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/*
|
|
73
|
-
* 조각 안의 글자는 **폭을 재서 가르지 않는다.** overflow: hidden 에 맡기면 넓을 때는 이름과
|
|
74
|
-
* 시각이, 좁을 때는 이름만, 아주 좁을 때는 아무것도 안 보인다 — 저절로 세 단계가 된다.
|
|
75
|
-
*
|
|
76
|
-
* 그래서 조각마다 title 속성을 붙인다. 좁아서 안 보일 때 그것이 유일한 길이다.
|
|
77
|
-
*/
|
|
78
|
-
.band .piece {
|
|
79
|
-
position: absolute;
|
|
80
|
-
top: 0;
|
|
81
|
-
bottom: 0;
|
|
82
|
-
background-color: var(--ui-accent);
|
|
83
|
-
color: #fff;
|
|
84
|
-
font-size: var(--ui-t-small);
|
|
85
|
-
line-height: 52px;
|
|
86
|
-
text-align: center;
|
|
87
|
-
white-space: nowrap;
|
|
88
|
-
overflow: hidden;
|
|
89
|
-
/*
|
|
90
|
-
* 조각끼리 붙어 있으면 한 덩어리로 보인다. 경계를 **조각 안쪽에** 긋는다 — 사이를 벌리면
|
|
91
|
-
* 그 틈이 「덮이지 않은 시간」으로 읽힌다. 그 둘은 반대되는 사실이다.
|
|
92
|
-
*/
|
|
93
|
-
box-shadow: inset -2px 0 0 var(--ui-panel), inset 2px 0 0 var(--ui-panel);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/* 겹친 조각은 색과 **무늬**로 함께 표시한다 — 색만으로는 못 가르는 사람이 있다. */
|
|
97
|
-
.band .piece[data-overlap] {
|
|
98
|
-
background-color: var(--ui-warn);
|
|
99
|
-
background-image: repeating-linear-gradient(
|
|
100
|
-
-45deg,
|
|
101
|
-
transparent 0 4px,
|
|
102
|
-
rgba(255, 255, 255, 0.35) 4px 6px
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/*
|
|
107
|
-
* 두께가 중요도다. 앞서 표 안에 넣느라 22px 로 눌러 두었더니 **교대 이름이 잘려 읽히지 않았다** —
|
|
108
|
-
* 이 띠들이 있는 이유가 「그때 편성이 어땠나」를 한눈에 보는 것인데, 읽을 수 없으면 있으나 마나다.
|
|
109
|
-
*/
|
|
110
|
-
.band[data-variant='scheduled'] {
|
|
111
|
-
height: 42px;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
.band[data-variant='past'] {
|
|
115
|
-
height: 34px;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
.band[data-variant='scheduled'] .piece {
|
|
119
|
-
line-height: 42px;
|
|
120
|
-
font-size: var(--ui-t-small);
|
|
121
|
-
/* 같은 색을 옅게 — 같은 것인데 아직 안 왔다는 뜻이다. */
|
|
122
|
-
background-color: color-mix(in srgb, var(--ui-accent) 42%, var(--ui-panel));
|
|
123
|
-
color: var(--ui-ink);
|
|
124
|
-
border: 1px dashed color-mix(in srgb, var(--ui-accent) 70%, transparent);
|
|
125
|
-
box-sizing: border-box;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
.band[data-variant='past'] .piece {
|
|
129
|
-
line-height: 34px;
|
|
130
|
-
font-size: var(--ui-t-micro);
|
|
131
|
-
/* 지나간 것은 색을 뺀다. 지금 쓰는 것과 다투지 않게. */
|
|
132
|
-
background-color: color-mix(in srgb, var(--ui-muted) 45%, var(--ui-panel));
|
|
133
|
-
color: var(--ui-ink);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
.ticks {
|
|
137
|
-
display: flex;
|
|
138
|
-
justify-content: space-between;
|
|
139
|
-
font-size: var(--ui-t-micro);
|
|
140
|
-
color: var(--ui-muted);
|
|
141
|
-
font-variant-numeric: tabular-nums;
|
|
142
|
-
margin-top: 2px;
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
/* 아무 말이 없으면 「확인했는데 문제가 없다」와 「아직 안 봤다」가 같아 보인다. */
|
|
146
|
-
.settled {
|
|
147
|
-
border-left: 3px solid var(--ui-good);
|
|
148
|
-
padding: var(--spacing-small) var(--spacing-medium);
|
|
149
|
-
font-size: var(--ui-t-body);
|
|
150
|
-
color: var(--ui-muted);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
.finding {
|
|
154
|
-
border-left: 3px solid var(--ui-warn);
|
|
155
|
-
background-color: var(--ui-panel);
|
|
156
|
-
padding: var(--spacing-small) var(--spacing-medium);
|
|
157
|
-
font-size: var(--ui-t-body);
|
|
158
|
-
line-height: 1.7;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
table {
|
|
162
|
-
width: 100%;
|
|
163
|
-
border-collapse: collapse;
|
|
164
|
-
font-size: var(--ui-t-body);
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
th {
|
|
168
|
-
text-align: left;
|
|
169
|
-
font-weight: 500;
|
|
170
|
-
font-size: var(--ui-t-micro);
|
|
171
|
-
color: var(--ui-muted);
|
|
172
|
-
padding: 4px 8px;
|
|
173
|
-
border-bottom: 1px solid var(--ui-line);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
td {
|
|
177
|
-
padding: 5px 8px;
|
|
178
|
-
border-bottom: 1px solid var(--ui-panel);
|
|
179
|
-
vertical-align: top;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
.quiet {
|
|
183
|
-
color: var(--ui-muted);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/* 한 편성이 한 덩어리다 — 시각과 띠가 붙어 있어야 어느 띠가 언제 것인지 갈린다. */
|
|
187
|
-
.entry {
|
|
188
|
-
display: flex;
|
|
189
|
-
flex-direction: column;
|
|
190
|
-
gap: 4px;
|
|
191
|
-
padding: var(--spacing-small) 0;
|
|
192
|
-
border-bottom: 1px solid var(--ui-line-soft);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
.entry:last-of-type {
|
|
196
|
-
border-bottom: none;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
.entry-head {
|
|
200
|
-
display: flex;
|
|
201
|
-
align-items: center;
|
|
202
|
-
gap: var(--spacing-medium);
|
|
203
|
-
font-size: var(--ui-t-body);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
.entry-head .when {
|
|
207
|
-
font-variant-numeric: tabular-nums;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
.entry-head button {
|
|
212
|
-
margin-left: auto;
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/* 편집기 바로 아래 — 무엇을 내보내는지가 바로 위에 있다. */
|
|
216
|
-
/*
|
|
217
|
-
* 윗쪽은 **고치는 자리**, 아랫쪽은 **보는 자리**다. 그 경계를 선 하나로 그으면 어느 쪽에 속하는지
|
|
218
|
-
* 애매한 것이 생긴다 — 실제로 적용 줄 위에 선을 두었더니 그 줄이 정보 영역으로 밀려 보였다.
|
|
219
|
-
* 적용 줄은 위 편집기의 내용을 내보내는 자리이므로 편집에 속한다.
|
|
220
|
-
*
|
|
221
|
-
* 선 대신 **면**으로 가른다. 고치는 것은 한 판 위에 함께 얹히고, 보는 것은 바탕 위에 놓인다.
|
|
222
|
-
*/
|
|
223
|
-
.editing {
|
|
224
|
-
display: flex;
|
|
225
|
-
flex-direction: column;
|
|
226
|
-
gap: var(--spacing-small);
|
|
227
|
-
padding: var(--spacing-medium);
|
|
228
|
-
/*
|
|
229
|
-
* --ui-panel 은 바탕과 거의 같은 색이라 면을 깔아도 경계가 안 보였다. 강조색을 아주 옅게
|
|
230
|
-
* 섞어 **손대는 자리**임을 드러낸다 — 아래 정보 영역은 바탕 그대로 둔다.
|
|
231
|
-
*/
|
|
232
|
-
background-color: color-mix(in srgb, var(--ui-accent) 7%, var(--ui-bg));
|
|
233
|
-
border: 1px solid color-mix(in srgb, var(--ui-accent) 35%, transparent);
|
|
234
|
-
border-radius: var(--ui-r);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/* 두 영역 사이는 선이 아니라 **빈 자리**가 가른다. */
|
|
238
|
-
.info {
|
|
239
|
-
display: flex;
|
|
240
|
-
flex-direction: column;
|
|
241
|
-
gap: var(--spacing-large);
|
|
242
|
-
margin-top: var(--spacing-medium);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
.howto {
|
|
246
|
-
margin: 0;
|
|
247
|
-
padding-left: 1.1em;
|
|
248
|
-
display: flex;
|
|
249
|
-
flex-direction: column;
|
|
250
|
-
gap: 2px;
|
|
251
|
-
font-size: var(--ui-t-small);
|
|
252
|
-
color: var(--ui-muted);
|
|
253
|
-
line-height: 1.6;
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
/* 같은 판 안의 두 부분이라 선을 흐리게 — 가르는 것이 아니라 나누는 것이다. */
|
|
257
|
-
.apply {
|
|
258
|
-
display: flex;
|
|
259
|
-
align-items: center;
|
|
260
|
-
gap: var(--spacing-medium);
|
|
261
|
-
flex-wrap: wrap;
|
|
262
|
-
padding: var(--spacing-small) 0 0 0;
|
|
263
|
-
border-top: 1px solid var(--ui-line-soft);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
.choice {
|
|
267
|
-
display: flex;
|
|
268
|
-
align-items: center;
|
|
269
|
-
gap: 4px;
|
|
270
|
-
font-size: var(--ui-t-body);
|
|
271
|
-
cursor: pointer;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
.apply-button {
|
|
275
|
-
margin-left: auto;
|
|
276
|
-
background-color: var(--ui-accent) !important;
|
|
277
|
-
color: #fff !important;
|
|
278
|
-
border-color: transparent !important;
|
|
279
|
-
min-height: 36px;
|
|
280
|
-
padding: 0 var(--spacing-large) !important;
|
|
281
|
-
font-weight: 600;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
.apply input[type='datetime-local'],
|
|
285
|
-
.apply button,
|
|
286
|
-
table button {
|
|
287
|
-
font: inherit;
|
|
288
|
-
padding: 5px 10px;
|
|
289
|
-
border-radius: var(--ui-r-small);
|
|
290
|
-
border: 1px solid var(--ui-line);
|
|
291
|
-
background-color: var(--ui-panel);
|
|
292
|
-
color: var(--ui-ink);
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
.announce button:not(:disabled),
|
|
296
|
-
table button {
|
|
297
|
-
cursor: pointer;
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
.announce button:disabled {
|
|
301
|
-
opacity: 0.45;
|
|
302
|
-
}
|
|
303
|
-
`
|
|
304
|
-
]; }
|
|
305
|
-
/**
|
|
306
|
-
* 툴바에 동작을 두지 않는다.
|
|
307
|
-
*
|
|
308
|
-
* 이 화면의 동작은 **언제부터 쓸지와 함께 정해진다.** 툴바 버튼은 그 선택에서 멀리 떨어져 있어
|
|
309
|
-
* 이름이 선택을 따라갈 수 없다 — 「수정」이라 적힌 단추가 예고를 하게 된다. 그래서 편집기 바로 아래,
|
|
310
|
-
* 시점을 고르는 자리 옆에 하나만 둔다.
|
|
311
|
-
*/
|
|
312
|
-
get context() {
|
|
313
|
-
return {
|
|
314
|
-
/*
|
|
315
|
-
* 이 화면이 어떤 틀인가 — 동작 줄이 이것을 읽고 자기 오른쪽 끝을 내용에 맞춘다
|
|
316
|
-
* (ADR-0060 결정 9 보탬). 안 적으면 창 폭이고, 그것은 작업면의 모양이다.
|
|
317
|
-
*/
|
|
318
|
-
frame: 'document',
|
|
319
|
-
title: i18next.t('title.work-shift'),
|
|
320
|
-
help: 'page/work-shift'
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
render() {
|
|
324
|
-
return html `
|
|
325
|
-
<div class="page">
|
|
326
|
-
<!--
|
|
327
|
-
제목과 동작 바를 여기 두지 않는다 — 셸이 이미 그린다.
|
|
328
|
-
|
|
329
|
-
제목은 context.title 이 페이지 제목 줄(또는 상단 앱 바)에 그리고, 동작 바는 contextToolbar
|
|
330
|
-
자리에 그린다. 여기 또 두면 제목이 위아래로 둘이고 같은 버튼이 두 벌이 된다.
|
|
331
|
-
-->
|
|
332
|
-
|
|
333
|
-
<div class="editing">
|
|
334
|
-
<header class="page-section-header"><h3>${i18next.t('title.shift-editing')}</h3></header>
|
|
335
|
-
|
|
336
|
-
<ox-input-work-shift
|
|
337
|
-
.value=${this.workShifts}
|
|
338
|
-
@change=${(event) => (this.draft = [...(event.target?.value ?? [])])}
|
|
339
|
-
></ox-input-work-shift>
|
|
340
|
-
|
|
341
|
-
${this.howTo()}
|
|
342
|
-
${this.applyBar()}
|
|
343
|
-
</div>
|
|
344
|
-
|
|
345
|
-
${this.problem ? html `<div class="finding">${this.problem}</div>` : ''} ${this.runningNotice()}
|
|
346
|
-
|
|
347
|
-
<div class="info">${this.coverage()} ${this.announcements()} ${this.history()}</div>
|
|
348
|
-
</div>
|
|
349
|
-
`;
|
|
350
|
-
}
|
|
351
|
-
/**
|
|
352
|
-
* 이 화면을 쓰는 법 — **보고도 모르는 것 셋만.**
|
|
353
|
-
*
|
|
354
|
-
* 화면을 보면 아는 것은 적지 않는다(「이름을 적으십시오」 같은 것). 적는 것은 화면에 답이 없는
|
|
355
|
-
* 것이다 — 표의 날짜 칸이 무엇을 뜻하는지, 예정한 시각이 오면 누가 무엇을 해야 하는지, 지금 편성이
|
|
356
|
-
* 어디로 가는지.
|
|
357
|
-
*
|
|
358
|
-
* 길어지면 아무도 안 읽는다. 세 줄에서 늘리지 않는다.
|
|
359
|
-
*/
|
|
360
|
-
howTo() {
|
|
361
|
-
return html `
|
|
362
|
-
<ul class="howto">
|
|
363
|
-
<li>${i18next.t('text.howto-crossing-midnight')}</li>
|
|
364
|
-
<li>${i18next.t('text.howto-scheduled-applies-itself')}</li>
|
|
365
|
-
<li>${i18next.t('text.howto-previous-is-kept')}</li>
|
|
366
|
-
</ul>
|
|
367
|
-
`;
|
|
368
|
-
}
|
|
369
|
-
/**
|
|
370
|
-
* 이 편성을 **언제부터 쓸까** — 저장과 예고가 같은 결정이라 한 자리에 둔다.
|
|
371
|
-
*
|
|
372
|
-
* 앞서 「수정」은 위 툴바에, 「예고」는 화면 맨 아래에 있었다. 둘은 같은 편성을 내보내는 같은 행위이고
|
|
373
|
-
* **시점만 다르다.** 화면 양 끝에 떨어져 있으면 사람은 그 둘이 다른 일인 줄 안다.
|
|
374
|
-
*
|
|
375
|
-
* 시점을 고르는 자리를 편집기 바로 아래에 둔다 — 무엇을 내보내는지가 바로 위에 있다.
|
|
376
|
-
*/
|
|
377
|
-
applyBar() {
|
|
378
|
-
const later = Boolean(this.announceFrom);
|
|
379
|
-
return html `
|
|
380
|
-
<div class="apply">
|
|
381
|
-
<span class="quiet">${i18next.t('text.apply-from-when')}</span>
|
|
382
|
-
|
|
383
|
-
<label class="choice">
|
|
384
|
-
<input type="radio" name="when" ?checked=${!later} @change=${() => (this.announceFrom = '')} />
|
|
385
|
-
${i18next.t('text.apply-now')}
|
|
386
|
-
</label>
|
|
387
|
-
|
|
388
|
-
<label class="choice">
|
|
389
|
-
<input type="radio" name="when" ?checked=${later} @change=${this.pickDefaultAnnounceTime} />
|
|
390
|
-
${i18next.t('text.apply-later')}
|
|
391
|
-
</label>
|
|
392
|
-
|
|
393
|
-
<input
|
|
394
|
-
type="datetime-local"
|
|
395
|
-
.value=${this.announceFrom}
|
|
396
|
-
@input=${(event) => (this.announceFrom = event.target.value)}
|
|
397
|
-
/>
|
|
398
|
-
|
|
399
|
-
<button class="apply-button" @click=${this.apply}>
|
|
400
|
-
${i18next.t(later ? 'button.announce-shift' : 'button.apply-now')}
|
|
401
|
-
</button>
|
|
402
|
-
</div>
|
|
403
|
-
`;
|
|
404
|
-
}
|
|
405
|
-
/** 「그때부터」를 고르면 시각 칸이 비어 있으면 안 된다 — 비어 있으면 무엇을 고른 것인지 알 수 없다. */
|
|
406
|
-
pickDefaultAnnounceTime() {
|
|
407
|
-
if (this.announceFrom) {
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
const from = new Date(Date.now() + 86_400_000);
|
|
411
|
-
from.setMinutes(0, 0, 0);
|
|
412
|
-
this.announceFrom = new Date(from.getTime() - from.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
|
|
413
|
-
}
|
|
414
|
-
/** 고른 시점으로 내보낸다. 두 길이 아니라 한 길에 시점이 붙은 것이다. */
|
|
415
|
-
apply() {
|
|
416
|
-
return this.announceFrom ? this.announce() : this.updateWorkShift();
|
|
417
|
-
}
|
|
418
|
-
/**
|
|
419
|
-
* 예고분이 이미 적용 중인가.
|
|
420
|
-
*
|
|
421
|
-
* 그렇다면 **편집기의 내용은 지금 쓰는 편성이 아니다.** 말하지 않으면 사람이 편집기를 고치고
|
|
422
|
-
* 저장했는데 아무것도 안 바뀐 것처럼 보인다(저장은 그 예고분을 닫으므로 실제로는 바뀐다).
|
|
423
|
-
*/
|
|
424
|
-
runningNotice() {
|
|
425
|
-
const running = this.revisions.find(revision => !revision.effectiveTo && new Date(revision.effectiveFrom) <= new Date());
|
|
426
|
-
if (!running) {
|
|
427
|
-
return '';
|
|
14
|
+
this.message = '';
|
|
15
|
+
this.busy = false;
|
|
16
|
+
this.statuses = {};
|
|
17
|
+
this.dirty = false;
|
|
18
|
+
}
|
|
19
|
+
static { this.styles = [DocumentPageStyles, ScrollbarStyles, revisionStyles, css `
|
|
20
|
+
.page { padding: 20px; display: grid; gap: 20px; min-width: 0; }
|
|
21
|
+
section { border: 1px solid var(--ui-line); border-radius: 6px; padding: 16px; min-width: 0; }
|
|
22
|
+
h2 { font-size: 18px; margin: 0 0 12px; }
|
|
23
|
+
.bar, .fields { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
|
|
24
|
+
.fields label { display: grid; gap: 5px; flex: 1 1 200px; }
|
|
25
|
+
input, select, textarea { font: inherit; padding: 6px 8px; border: 1px solid var(--ui-line); border-radius: 4px; background: var(--md-sys-color-surface); color: inherit; box-sizing: border-box; max-width: 100%; }
|
|
26
|
+
input[type=time] { width: 105px; }
|
|
27
|
+
.scroll table { min-width: 780px; }
|
|
28
|
+
.week { display:flex; flex-wrap:wrap; gap:4px; max-width:210px; }
|
|
29
|
+
.week label { white-space:nowrap; }
|
|
30
|
+
.current { border-left: 4px solid var(--md-sys-color-primary); }
|
|
31
|
+
.notice { padding:12px; background:var(--md-sys-color-surface-container); }
|
|
32
|
+
`]; }
|
|
33
|
+
get context() { return { title: i18next.t('title.work-shift'), frame: 'document', help: 'page/work-shift' }; }
|
|
34
|
+
async pageUpdated() { if (this.active && !this.dirty)
|
|
35
|
+
await this.load(); }
|
|
36
|
+
current() {
|
|
37
|
+
const segment = this.overview?.schedule.find((s) => s.state === 'current');
|
|
38
|
+
return segment && this.overview.revisions.find((r) => r.id === segment.revisionId);
|
|
39
|
+
}
|
|
40
|
+
async run(action) {
|
|
41
|
+
this.busy = true;
|
|
42
|
+
this.message = '';
|
|
43
|
+
try {
|
|
44
|
+
await action();
|
|
428
45
|
}
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
</div>`;
|
|
432
|
-
}
|
|
433
|
-
/**
|
|
434
|
-
* 지금 화면이 다루고 있는 편성.
|
|
435
|
-
*
|
|
436
|
-
* **띠가 보여 준 것과 저장·예고되는 것이 같아야 한다.** 다르면 사람은 띠에서 확인한 편성을 내보낸 줄
|
|
437
|
-
* 알지만 실제로는 다른 것이 나간다 — 그러면 확인하는 자리를 둔 뜻이 없다.
|
|
438
|
-
*/
|
|
439
|
-
editing() {
|
|
440
|
-
return this.draft.length ? this.draft : this.workShifts ?? [];
|
|
441
|
-
}
|
|
442
|
-
/** 앞으로 쓸 편성 — 아직 시작하지 않은 것만. */
|
|
443
|
-
announcements() {
|
|
444
|
-
const now = new Date();
|
|
445
|
-
const upcoming = this.revisions
|
|
446
|
-
.filter(revision => new Date(revision.effectiveFrom) > now)
|
|
447
|
-
.sort((a, b) => new Date(a.effectiveFrom).getTime() - new Date(b.effectiveFrom).getTime());
|
|
448
|
-
return html `
|
|
449
|
-
<section class="page-section">
|
|
450
|
-
<header class="page-section-header"><h3>${i18next.t('title.shift-announcements')}</h3></header>
|
|
451
|
-
|
|
452
|
-
${upcoming.length
|
|
453
|
-
? upcoming.map(revision => html `
|
|
454
|
-
<div class="entry">
|
|
455
|
-
<div class="entry-head">
|
|
456
|
-
<span class="when">${i18next.t('field.starts')} ${day(revision.effectiveFrom)}</span>
|
|
457
|
-
<button @click=${() => this.cancel(revision.id)}>
|
|
458
|
-
${i18next.t('button.cancel-announcement')}
|
|
459
|
-
</button>
|
|
460
|
-
</div>
|
|
461
|
-
${this.band(parseShifts(revision.shifts) ?? [], [], 'scheduled')}
|
|
462
|
-
</div>
|
|
463
|
-
`)
|
|
464
|
-
: html `<div class="quiet">${i18next.t('text.no-announcements')}</div>`}
|
|
465
|
-
</section>
|
|
466
|
-
`;
|
|
467
|
-
}
|
|
468
|
-
/** 편성이 하루를 어떻게 덮나 — 그리고 어디가 비었나. */
|
|
469
|
-
coverage() {
|
|
470
|
-
const shifts = this.editing();
|
|
471
|
-
if (!shifts.length) {
|
|
472
|
-
return html `<section class="page-section">
|
|
473
|
-
<header class="page-section-header"><h3>${i18next.t('title.shift-coverage')}</h3></header>
|
|
474
|
-
<div class="quiet">${i18next.t('text.no-shifts-declared')}</div>
|
|
475
|
-
</section>`;
|
|
46
|
+
catch (e) {
|
|
47
|
+
this.message = e.message;
|
|
476
48
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const cycle = cycleOf(shifts);
|
|
480
|
-
if (!cycle) {
|
|
481
|
-
/* 읽을 수 있는 교대가 하나도 없다. 띠를 그리면 빈 띠가 「하루가 다 비었다」로 보인다. */
|
|
482
|
-
return html `<section class="page-section">
|
|
483
|
-
<header class="page-section-header"><h3>${i18next.t('title.shift-coverage')}</h3></header>
|
|
484
|
-
${problems.map(problem => html `<div class="finding">${this.problemText(problem)}</div>`)}
|
|
485
|
-
</section>`;
|
|
49
|
+
finally {
|
|
50
|
+
this.busy = false;
|
|
486
51
|
}
|
|
487
|
-
return html `
|
|
488
|
-
<section class="page-section">
|
|
489
|
-
<header class="page-section-header"><h3>${i18next.t('title.shift-coverage')}</h3></header>
|
|
490
|
-
${this.band(shifts, problems)}
|
|
491
|
-
${covered < DAY_MINUTES
|
|
492
|
-
? html `<div class="finding">
|
|
493
|
-
${i18next.t('text.shift-covers-less-than-a-day', { hours: round(covered / HOUR_MINUTES) })}
|
|
494
|
-
</div>`
|
|
495
|
-
: ''}
|
|
496
|
-
${problems.map(problem => html `<div class="finding">${this.problemText(problem)}</div>`)}
|
|
497
|
-
${!problems.length && covered >= DAY_MINUTES
|
|
498
|
-
? html `<div class="settled">${i18next.t('text.shift-covers-the-whole-day')}</div>`
|
|
499
|
-
: ''}
|
|
500
|
-
</section>
|
|
501
|
-
`;
|
|
502
52
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
<div>
|
|
516
|
-
<div class="band" data-variant=${variant}>${this.pieces(shifts, problems, cycle)}</div>
|
|
517
|
-
<div class="ticks">
|
|
518
|
-
${[0, 6, 12, 18, 24].map(hour => html `<span>${cycleTime(cycle.startMinute + hour * 60)}</span>`)}
|
|
519
|
-
</div>
|
|
520
|
-
</div>
|
|
521
|
-
`;
|
|
522
|
-
}
|
|
523
|
-
/**
|
|
524
|
-
* 띠의 조각들 — **한 바퀴 위에 놓는다.**
|
|
525
|
-
*
|
|
526
|
-
* 자정을 왼쪽 끝으로 두면 야간 교대가 있는 공장에서 없는 틈이 보이고 있는 틈이 안 보인다. 첫 교대가
|
|
527
|
-
* 시작하는 시각이 왼쪽 끝이고, 거기서 24시간이 오른쪽 끝이다.
|
|
528
|
-
*/
|
|
529
|
-
pieces(shifts, problems, cycle) {
|
|
530
|
-
if (!cycle) {
|
|
531
|
-
return '';
|
|
532
|
-
}
|
|
533
|
-
const overlapping = new Set(problems.filter(p => p.kind === 'overlap').flatMap(p => p.names));
|
|
534
|
-
return shifts.map(shift => {
|
|
535
|
-
const from = Number(shift.fromDate ?? 0) * DAY_MINUTES + minutes(shift.fromTime);
|
|
536
|
-
const to = Number(shift.toDate ?? 0) * DAY_MINUTES + minutes(shift.toTime);
|
|
537
|
-
if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) {
|
|
538
|
-
return '';
|
|
539
|
-
}
|
|
540
|
-
const left = Math.max(cycle.startMinute, from);
|
|
541
|
-
const right = Math.min(cycle.endMinute, to);
|
|
542
|
-
if (right <= left) {
|
|
543
|
-
return '';
|
|
53
|
+
async load() {
|
|
54
|
+
await this.run(async () => {
|
|
55
|
+
const result = await client.query({ query: gql `query { workShiftRevisionOverview }`, fetchPolicy: 'no-cache' });
|
|
56
|
+
if (result.errors?.length)
|
|
57
|
+
throw new Error(result.errors[0].message);
|
|
58
|
+
this.overview = result.data.workShiftRevisionOverview;
|
|
59
|
+
const statuses = {};
|
|
60
|
+
for (const row of this.overview.revisions.filter((r) => r.activityInstanceId)) {
|
|
61
|
+
let promise;
|
|
62
|
+
document.dispatchEvent(new CustomEvent('master-change-status', { detail: { activityInstanceId: row.activityInstanceId, respondWith: (p) => promise = p } }));
|
|
63
|
+
if (promise)
|
|
64
|
+
statuses[row.id] = await promise;
|
|
544
65
|
}
|
|
545
|
-
|
|
546
|
-
return html `<div
|
|
547
|
-
class="piece"
|
|
548
|
-
?data-overlap=${overlapping.has(shift.name)}
|
|
549
|
-
style="left:${((left - cycle.startMinute) / DAY_MINUTES) * 100}%; width:${((right - left) / DAY_MINUTES) * 100}%"
|
|
550
|
-
title=${label}
|
|
551
|
-
>
|
|
552
|
-
${label}
|
|
553
|
-
</div>`;
|
|
66
|
+
this.statuses = statuses;
|
|
554
67
|
});
|
|
555
68
|
}
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
`;
|
|
582
|
-
}
|
|
583
|
-
return html `
|
|
584
|
-
<section class="page-section">
|
|
585
|
-
<header class="page-section-header"><h3>${i18next.t('title.shift-history')}</h3></header>
|
|
586
|
-
${past.map(revision => html `
|
|
587
|
-
<div class="entry">
|
|
588
|
-
<div class="entry-head">
|
|
589
|
-
<span class="when">${periodText(revision.effectiveFrom, revision.effectiveTo)}</span>
|
|
590
|
-
<span class="quiet">${revision.creator?.name ?? ''}</span>
|
|
591
|
-
</div>
|
|
592
|
-
${this.band(parseShifts(revision.shifts) ?? [], [], 'past')}
|
|
593
|
-
</div>
|
|
594
|
-
`)}
|
|
595
|
-
</section>
|
|
596
|
-
`;
|
|
597
|
-
}
|
|
598
|
-
async pageUpdated(changes, after, before) {
|
|
599
|
-
/* refresh always page revisited */
|
|
600
|
-
if (this.active) {
|
|
69
|
+
begin(row = this.current()) {
|
|
70
|
+
this.draft = { description: row?.description ?? '', timezone: row?.timezone ?? this.overview.timezone,
|
|
71
|
+
shifts: structuredClone(row?.shifts ?? [{ name: '', fromDate: 0, fromTime: '08:00', toDate: 0, toTime: '16:00', daysOfWeek: '' }]),
|
|
72
|
+
effectiveFrom: new Date().toISOString(), reason: '' };
|
|
73
|
+
this.dirty = false;
|
|
74
|
+
}
|
|
75
|
+
change(field, value) { this.draft = { ...this.draft, [field]: value }; this.dirty = true; }
|
|
76
|
+
changeShift(index, field, value) {
|
|
77
|
+
this.change('shifts', this.draft.shifts.map((s, i) => i === index ? { ...s, [field]: value } : s));
|
|
78
|
+
}
|
|
79
|
+
async save() {
|
|
80
|
+
await this.run(async () => {
|
|
81
|
+
const { description, timezone, shifts, effectiveFrom, reason } = this.draft;
|
|
82
|
+
const content = { description, timezone, shifts, effectiveFrom, reason };
|
|
83
|
+
const editing = !!this.draft.id;
|
|
84
|
+
const response = await client.mutate({ mutation: editing
|
|
85
|
+
? gql `mutation($revisionId:String!,$editGeneration:Int!,$content:Object!){editWorkShiftDraft(revisionId:$revisionId,editGeneration:$editGeneration,content:$content)}`
|
|
86
|
+
: gql `mutation($content:Object!){createWorkShiftDraft(content:$content)}`,
|
|
87
|
+
variables: { content, ...(editing ? { revisionId: this.draft.id, editGeneration: this.draft.editGeneration } : {}) } });
|
|
88
|
+
if (response.errors?.length)
|
|
89
|
+
throw new Error(response.errors[0].message);
|
|
90
|
+
this.draft = response.data[editing ? 'editWorkShiftDraft' : 'createWorkShiftDraft'];
|
|
91
|
+
this.dirty = false;
|
|
92
|
+
});
|
|
93
|
+
if (!this.message)
|
|
601
94
|
await this.load();
|
|
602
|
-
}
|
|
603
95
|
}
|
|
604
|
-
async
|
|
605
|
-
this.
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
toDate
|
|
616
|
-
toTime
|
|
617
|
-
}
|
|
96
|
+
async submit() {
|
|
97
|
+
await this.run(async () => {
|
|
98
|
+
if (this.overview.approvalRequired) {
|
|
99
|
+
let promise;
|
|
100
|
+
document.dispatchEvent(new CustomEvent('master-change-approval', { detail: {
|
|
101
|
+
axis: 'workShift', revisionId: this.draft.id, editGeneration: this.draft.editGeneration,
|
|
102
|
+
respondWith: (p) => promise = p
|
|
103
|
+
} }));
|
|
104
|
+
if (!promise)
|
|
105
|
+
throw new Error(t('approval-unavailable'));
|
|
106
|
+
await promise;
|
|
618
107
|
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
shifts
|
|
625
|
-
creator {
|
|
626
|
-
name
|
|
627
|
-
}
|
|
628
|
-
}
|
|
108
|
+
else {
|
|
109
|
+
const result = await client.mutate({ mutation: gql `mutation($revisionId:String!,$editGeneration:Int!){publishWorkShiftDraft(revisionId:$revisionId,editGeneration:$editGeneration)}`,
|
|
110
|
+
variables: { revisionId: this.draft.id, editGeneration: this.draft.editGeneration } });
|
|
111
|
+
if (result.errors?.length)
|
|
112
|
+
throw new Error(result.errors[0].message);
|
|
629
113
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
this.workShifts = response.data.workShifts.items;
|
|
635
|
-
/* 새로 읽었으면 고치던 것은 없다 — 남겨 두면 저장한 내용이 아닌 것이 띠에 남는다. */
|
|
636
|
-
this.draft = [];
|
|
637
|
-
this.revisions = response.data.workShiftRevisions?.items ?? [];
|
|
638
|
-
}
|
|
639
|
-
catch (error) {
|
|
640
|
-
/* 못 읽은 것을 빈 편성으로 두지 않는다 — 「아직 안 정했다」로 읽힌다. */
|
|
641
|
-
this.problem = i18next.t('text.could-not-read-work-shifts');
|
|
642
|
-
console.error(error);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
/**
|
|
646
|
-
* 예고한다 — **위 편집기의 내용을 그 시각부터 쓰겠다는 뜻이다.**
|
|
647
|
-
*
|
|
648
|
-
* 편집기의 내용을 그대로 보내므로, 예고하기 전에 편집기가 앞으로 쓸 편성이어야 한다. 그 사실을 버튼
|
|
649
|
-
* 옆에 적어 둔다.
|
|
650
|
-
*/
|
|
651
|
-
async announce() {
|
|
652
|
-
this.problem = undefined;
|
|
653
|
-
try {
|
|
654
|
-
await client.mutate({
|
|
655
|
-
mutation: gql `
|
|
656
|
-
mutation ($effectiveFrom: DateTimeISO!, $patches: [WorkShiftPatch!]!) {
|
|
657
|
-
announceWorkShift(effectiveFrom: $effectiveFrom, patches: $patches) {
|
|
658
|
-
id
|
|
659
|
-
effectiveFrom
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
`,
|
|
663
|
-
variables: { effectiveFrom: new Date(this.announceFrom), patches: this.editing() }
|
|
664
|
-
});
|
|
665
|
-
this.announceFrom = '';
|
|
666
|
-
await this.load();
|
|
667
|
-
}
|
|
668
|
-
catch (error) {
|
|
669
|
-
/* 서버가 왜 막았는지 그대로 보인다 — 「저장 실패」로 뭉개면 무엇을 고쳐야 할지 알 수 없다. */
|
|
670
|
-
this.problem = error?.message || i18next.t('text.could-not-announce-work-shifts');
|
|
671
|
-
console.error(error);
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
async cancel(id) {
|
|
675
|
-
this.problem = undefined;
|
|
676
|
-
try {
|
|
677
|
-
await client.mutate({
|
|
678
|
-
mutation: gql `
|
|
679
|
-
mutation ($id: String!) {
|
|
680
|
-
cancelWorkShiftAnnouncement(id: $id)
|
|
681
|
-
}
|
|
682
|
-
`,
|
|
683
|
-
variables: { id }
|
|
684
|
-
});
|
|
114
|
+
this.draft = undefined;
|
|
115
|
+
this.dirty = false;
|
|
116
|
+
});
|
|
117
|
+
if (!this.message)
|
|
685
118
|
await this.load();
|
|
686
|
-
}
|
|
687
|
-
catch (error) {
|
|
688
|
-
this.problem = error?.message || i18next.t('text.could-not-cancel-announcement');
|
|
689
|
-
console.error(error);
|
|
690
|
-
}
|
|
691
119
|
}
|
|
692
|
-
|
|
693
|
-
this.
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
120
|
+
rowStatus(row) {
|
|
121
|
+
const segment = this.overview.schedule.find((s) => s.revisionId === row.id);
|
|
122
|
+
return segment?.state ?? this.statuses[row.id]?.state ?? (row.frozenAt ? 'pending' : 'draft');
|
|
123
|
+
}
|
|
124
|
+
weekdays(row, index) {
|
|
125
|
+
const days = row.daysOfWeek ? row.daysOfWeek.split(',') : ['0', '1', '2', '3', '4', '5', '6'];
|
|
126
|
+
return html `<div class="week">${['1', '2', '3', '4', '5', '6', '0'].map(day => html `<label><input type="checkbox" .checked=${days.includes(day)}
|
|
127
|
+
@change=${(e) => {
|
|
128
|
+
const next = e.target.checked ? [...days, day] : days.filter(d => d !== day);
|
|
129
|
+
if (!next.length) {
|
|
130
|
+
e.target.checked = true;
|
|
131
|
+
return;
|
|
704
132
|
}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
}
|
|
133
|
+
this.changeShift(index, 'daysOfWeek', next.length === 7 ? '' : [...new Set(next)].sort().join(','));
|
|
134
|
+
}} />${t('weekday-' + day)}</label>`)}</div>`;
|
|
135
|
+
}
|
|
136
|
+
render() {
|
|
137
|
+
const current = this.current(), base = this.draft && this.overview?.revisions.find((r) => r.id === this.draft.baseRevisionId);
|
|
138
|
+
return html `<div class="page">
|
|
139
|
+
<div class="bar"><button ?disabled=${this.busy || this.dirty} @click=${() => this.load()}>${t('refresh')}</button>
|
|
140
|
+
<button ?disabled=${this.busy || !this.overview || !!this.draft} @click=${() => this.begin()}>${t('create-change')}</button></div>
|
|
141
|
+
${this.message ? html `<p role="alert">${this.message}</p>` : ''}
|
|
142
|
+
<section class="current"><h2>${t('current')}</h2>
|
|
143
|
+
${current ? html `<p>${t('revision-value', { n: current.revisionNumber })} · ${revisionDate(this.overview.schedule.find((s) => s.state === 'current').validFrom)}</p>${revisionContents(current, 'workShift')}`
|
|
144
|
+
: html `<p>${t('no-current')}</p>`}
|
|
145
|
+
</section>
|
|
146
|
+
${this.draft ? html `<section><h2>${this.draft.id ? t('revision-value', { n: this.draft.revisionNumber }) : t('new')} · ${t(this.draft.id ? this.rowStatus(this.draft) : 'draft')}</h2>
|
|
147
|
+
${this.draft.frozenAt ? revisionContents(this.draft, 'workShift') : html `
|
|
148
|
+
<div class="fields">${['description', 'timezone', 'reason'].map(field => html `<label>${t(field)}<input aria-label=${field} .value=${this.draft[field] ?? ''} @input=${(e) => this.change(field, e.target.value)} /></label>`)}
|
|
149
|
+
<label>${t('effectiveFrom')} · ${Intl.DateTimeFormat().resolvedOptions().timeZone}<input aria-label="effectiveFrom" type="datetime-local" .value=${localDateTime(this.draft.effectiveFrom)} @change=${(e) => { try {
|
|
150
|
+
this.change('effectiveFrom', isoFromLocal(e.target.value));
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
this.message = err.message;
|
|
154
|
+
} }} /></label>
|
|
155
|
+
</div>
|
|
156
|
+
<div class="scroll"><table><thead><tr>${['name', 'fromDate', 'fromTime', 'toDate', 'toTime', 'daysOfWeek'].map(f => html `<th>${t(f)}</th>`)}<th></th></tr></thead><tbody>
|
|
157
|
+
${this.draft.shifts.map((row, index) => html `<tr>
|
|
158
|
+
<td><input aria-label=${'name-' + index} .value=${row.name} @input=${(e) => this.changeShift(index, 'name', e.target.value)} />
|
|
159
|
+
<label>${t('description')}<input aria-label=${'description-' + index} .value=${row.description ?? ''} @input=${(e) => this.changeShift(index, 'description', e.target.value)} /></label></td>
|
|
160
|
+
${['from', 'to'].map(side => html `<td><select aria-label=${side + 'Date-' + index} .value=${String(row[side + 'Date'])} @change=${(e) => this.changeShift(index, side + 'Date', Number(e.target.value))}>${[-1, 0, 1].map(d => html `<option value=${d}>${t('day-offset-' + d)}</option>`)}</select></td>
|
|
161
|
+
<td><input aria-label=${side + 'Time-' + index} type="time" .value=${row[side + 'Time']} @input=${(e) => this.changeShift(index, side + 'Time', e.target.value)} /></td>`)}
|
|
162
|
+
<td>${this.weekdays(row, index)}</td><td><button ?disabled=${this.draft.shifts.length === 1} @click=${() => this.change('shifts', this.draft.shifts.filter((_, i) => i !== index))}>${t('remove')}</button></td>
|
|
163
|
+
</tr>`)}
|
|
164
|
+
</tbody></table></div><button @click=${() => this.change('shifts', [...this.draft.shifts, { name: '', fromDate: 0, fromTime: '16:00', toDate: 1, toTime: '00:00', daysOfWeek: '' }])}>${t('add-shift')}</button>
|
|
165
|
+
<p class="notice">${t('shift-impact')}</p>
|
|
166
|
+
<div class="bar"><button ?disabled=${this.busy} @click=${() => this.save()}>${t('save-draft')}</button>
|
|
167
|
+
<button class="primary" ?disabled=${this.busy || this.dirty || !this.draft.id} @click=${() => this.submit()}>${t(this.overview.approvalRequired ? 'request-approval' : 'publish')}</button></div>`}
|
|
168
|
+
<button ?disabled=${this.busy} @click=${() => { if (!this.dirty || window.confirm(t('discard-changes'))) {
|
|
169
|
+
this.draft = undefined;
|
|
170
|
+
this.dirty = false;
|
|
171
|
+
} }}>${t('close-details')}</button>
|
|
172
|
+
${this.draft.id ? html `<ops-master-revision-comparison axis="workShift" .before=${base} .after=${this.draft}></ops-master-revision-comparison>` : ''}
|
|
173
|
+
</section>` : ''}
|
|
174
|
+
${this.overview ? html `<section><h2>${t('history')}</h2>${[...this.overview.revisions].reverse().map((row) => {
|
|
175
|
+
const period = this.overview.schedule.find((s) => s.revisionId === row.id), status = this.statuses[row.id];
|
|
176
|
+
return html `<div class="bar"><button ?disabled=${this.dirty} @click=${() => { this.draft = structuredClone(row); this.dirty = false; }}>${t('revision-value', { n: row.revisionNumber })}</button><span class="pill">${t(this.rowStatus(row))}</span>
|
|
177
|
+
<span>${period ? html `${revisionDate(period.validFrom)} → ${period.validTo ? revisionDate(period.validTo) : t('until-change')}` : html `${t('effectiveFrom')}: ${revisionDate(row.effectiveFrom)}`}</span>
|
|
178
|
+
${status?.comment ? html `<span>${status.comment}</span>` : ''}${status?.open ? html `<button @click=${() => status.open()}>${t('approval-detail')}</button>` : ''}
|
|
179
|
+
<button ?disabled=${this.dirty} @click=${() => this.begin(row)}>${t('create-change')}</button></div>`;
|
|
180
|
+
})}</section>` : ''}
|
|
181
|
+
</div>`;
|
|
716
182
|
}
|
|
717
183
|
};
|
|
718
184
|
__decorate([
|
|
719
185
|
state(),
|
|
720
|
-
__metadata("design:type",
|
|
721
|
-
], WorkShift.prototype, "
|
|
186
|
+
__metadata("design:type", Object)
|
|
187
|
+
], WorkShift.prototype, "overview", void 0);
|
|
722
188
|
__decorate([
|
|
723
189
|
state(),
|
|
724
|
-
__metadata("design:type",
|
|
190
|
+
__metadata("design:type", Object)
|
|
725
191
|
], WorkShift.prototype, "draft", void 0);
|
|
726
192
|
__decorate([
|
|
727
193
|
state(),
|
|
728
|
-
__metadata("design:type",
|
|
729
|
-
], WorkShift.prototype, "
|
|
194
|
+
__metadata("design:type", Object)
|
|
195
|
+
], WorkShift.prototype, "message", void 0);
|
|
730
196
|
__decorate([
|
|
731
197
|
state(),
|
|
732
|
-
__metadata("design:type",
|
|
733
|
-
], WorkShift.prototype, "
|
|
198
|
+
__metadata("design:type", Object)
|
|
199
|
+
], WorkShift.prototype, "busy", void 0);
|
|
734
200
|
__decorate([
|
|
735
201
|
state(),
|
|
736
202
|
__metadata("design:type", Object)
|
|
737
|
-
], WorkShift.prototype, "
|
|
203
|
+
], WorkShift.prototype, "statuses", void 0);
|
|
738
204
|
WorkShift = __decorate([
|
|
739
205
|
customElement('work-shift')
|
|
740
206
|
], WorkShift);
|
|
741
|
-
function minutes(time) {
|
|
742
|
-
const parts = String(time ?? '').split(':');
|
|
743
|
-
return parts.length < 2 ? NaN : Number(parts[0]) * 60 + Number(parts[1]);
|
|
744
|
-
}
|
|
745
|
-
function round(value) {
|
|
746
|
-
return Math.round(value * 10) / 10;
|
|
747
|
-
}
|
|
748
|
-
/**
|
|
749
|
-
* 구간 하나를 사람이 읽는 말로.
|
|
750
|
-
*
|
|
751
|
-
* 양쪽이 다 다음 날이면 「익일」을 두 번 적지 않는다 — 「익일 02:00~익일 08:00」은 같은 말을 두 번
|
|
752
|
-
* 하는 것이고, 읽는 사람이 두 날을 오가는 것으로 잘못 본다.
|
|
753
|
-
*/
|
|
754
|
-
function spanText(from, to) {
|
|
755
|
-
if (from === undefined || to === undefined) {
|
|
756
|
-
return '';
|
|
757
|
-
}
|
|
758
|
-
if (from >= 1440 && to >= 1440) {
|
|
759
|
-
return i18next.t('text.next-day-time', { time: `${plainTime(from)}~${plainTime(to)}` });
|
|
760
|
-
}
|
|
761
|
-
return `${cycleTime(from)}~${cycleTime(to)}`;
|
|
762
|
-
}
|
|
763
|
-
/** 24로 접은 시각. 「익일」 표시는 부르는 쪽이 붙인다. */
|
|
764
|
-
function plainTime(minute) {
|
|
765
|
-
const wrapped = ((minute % 1440) + 1440) % 1440;
|
|
766
|
-
return `${String(Math.floor(wrapped / 60)).padStart(2, '0')}:${String(wrapped % 60).padStart(2, '0')}`;
|
|
767
|
-
}
|
|
768
|
-
/**
|
|
769
|
-
* 한 바퀴 안의 분을 **사람이 읽는 시각**으로.
|
|
770
|
-
*
|
|
771
|
-
* 한 바퀴는 첫 교대에서 시작하므로 그 안의 분은 24시간을 넘어간다. 그것을 그대로 적으면 `26:00` 이
|
|
772
|
-
* 되는데, 그런 시각은 시계에 없다. 24로 접고 넘어간 것은 「익일」로 적는다.
|
|
773
|
-
*/
|
|
774
|
-
function cycleTime(minute) {
|
|
775
|
-
const text = plainTime(minute);
|
|
776
|
-
return minute >= 1440 ? i18next.t('text.next-day-time', { time: text }) : text;
|
|
777
|
-
}
|
|
778
|
-
/** 초는 안 적는다 — 교대 경계에 초를 쓰는 공장은 없고, 그 자리를 이름이 쓰는 편이 낫다. */
|
|
779
|
-
function hhmm(time) {
|
|
780
|
-
const parts = String(time ?? '').split(':');
|
|
781
|
-
return parts.length < 2 ? String(time ?? '') : `${parts[0]}:${parts[1]}`;
|
|
782
|
-
}
|
|
783
|
-
/**
|
|
784
|
-
* 사용 기간 한 줄 — **같은 날이면 날짜를 두 번 적지 않는다.**
|
|
785
|
-
*
|
|
786
|
-
* 앞서 양쪽에 날짜와 초를 다 적었더니 한 줄이 너무 길어, 두 줄로 나눠 보았다. 그랬더니 옆의 이름이
|
|
787
|
-
* 두 줄 사이에 떠서 더 어색했다. 줄을 나눌 일이 아니라 **적을 것을 줄일 일**이었다.
|
|
788
|
-
*
|
|
789
|
-
* 초는 적지 않는다. 편성이 언제부터 언제까지 쓰였나에 초는 뜻이 없다.
|
|
790
|
-
*/
|
|
791
|
-
function periodText(from, to) {
|
|
792
|
-
if (!from || !to) {
|
|
793
|
-
return '';
|
|
794
|
-
}
|
|
795
|
-
const start = new Date(from);
|
|
796
|
-
const end = new Date(to);
|
|
797
|
-
const lang = i18next.language || undefined;
|
|
798
|
-
const sameDay = start.toDateString() === end.toDateString();
|
|
799
|
-
const clock = (at) => at.toLocaleTimeString(lang, { hour: '2-digit', minute: '2-digit' });
|
|
800
|
-
return sameDay
|
|
801
|
-
? `${start.toLocaleDateString(lang)} ${clock(start)} ~ ${clock(end)}`
|
|
802
|
-
: `${start.toLocaleDateString(lang)} ${clock(start)} ~ ${end.toLocaleDateString(lang)} ${clock(end)}`;
|
|
803
|
-
}
|
|
804
|
-
function day(value) {
|
|
805
|
-
return value ? new Date(value).toLocaleString(i18next.language || undefined) : '';
|
|
806
|
-
}
|
|
807
|
-
/** 저장된 편성은 JSON 문자열로 온다(스칼라). 읽을 수 없으면 없다고 답한다. */
|
|
808
|
-
function parseShifts(value) {
|
|
809
|
-
if (Array.isArray(value)) {
|
|
810
|
-
return value;
|
|
811
|
-
}
|
|
812
|
-
if (typeof value !== 'string') {
|
|
813
|
-
return undefined;
|
|
814
|
-
}
|
|
815
|
-
try {
|
|
816
|
-
const parsed = JSON.parse(value);
|
|
817
|
-
return Array.isArray(parsed) ? parsed : undefined;
|
|
818
|
-
}
|
|
819
|
-
catch {
|
|
820
|
-
return undefined;
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
207
|
export { WorkShift };
|
|
824
208
|
//# sourceMappingURL=work-shift.js.map
|