@things-factory/work-shift 10.1.202 → 10.1.205

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.
@@ -1,387 +1,437 @@
1
1
  import { __decorate, __metadata } from "tslib";
2
+ import '@operato/input/ox-input-select-buttons.js';
3
+ import '@operato/list-state/ox-list-state.js';
4
+ import '@operato/list-state/ox-page-state.js';
2
5
  import gql from 'graphql-tag';
3
- import { css, html } from 'lit';
6
+ import { css, html, nothing } from 'lit';
4
7
  import { customElement, state } from 'lit/decorators.js';
5
8
  import { client } from '@operato/graphql';
6
- import { i18next } from '@operato/i18n';
9
+ import { i18next, localize } from '@operato/i18n';
10
+ import { notify } from '@operato/layout';
11
+ import { OxPrompt } from '@operato/popup';
7
12
  import { PageView } from '@operato/shell';
8
- import { CommonHeaderStyles, 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';
13
+ import { ButtonStyles, DocumentPageStyles, FormFieldStyles, ScrollbarStyles } from '@operato/styles';
14
+ import { revisionText as rt, revisionDate } from '@things-factory/ops-master/dist-client/pages/master/revision-comparison.js';
10
15
  import { localDateTime, isoFromLocal } from '@things-factory/ops-master/dist-client/pages/master/revision-view.js';
11
- import { clockOf, coverageProblems, coveredMinutes, cycleOf, spanOf } from '../shift-coverage';
16
+ import { clockOf, coverageProblems, timelineOf } from '../shift-coverage';
17
+ import { daysLabel, daysOf, daysValue, draftIssues, groupOverlaps, lengthOf, nextShift, settle, shiftsContent, weekGroups, WEEK } from '../shift-editing';
18
+ import { isBaseline, placeRevisions, reasonOf, timeRange } from '../shift-revisions';
12
19
  const DAY_MINUTES = 24 * 60;
13
- let WorkShift = class WorkShift extends PageView {
20
+ const t = (key, params = {}) => String(i18next.t(key, params));
21
+ /**
22
+ * 근무 교대 — the shift arrangement in force, what is about to change, and what it was before.
23
+ *
24
+ * One arrangement is in force at a time. Changing it means writing a whole new arrangement with a reason and
25
+ * the moment it takes effect; where the site requires approval, the change goes through ops-master's approval
26
+ * and takes effect once approved. Work already recorded keeps the shift it was recorded under.
27
+ *
28
+ * The rules this screen applies - weekday groups, the end day of a shift, what blocks a save, where each
29
+ * revision belongs - live in shift-editing.ts, shift-revisions.ts and shift-coverage.ts, with tests.
30
+ */
31
+ let WorkShift = class WorkShift extends localize(i18next)(PageView) {
14
32
  constructor() {
15
33
  super(...arguments);
16
- this.message = '';
17
- this.busy = false;
18
- this.statuses = {};
34
+ this.loading = false;
35
+ this.error = null;
36
+ this.approvals = {};
19
37
  this.dirty = false;
38
+ this.busy = false;
39
+ this.saveError = '';
40
+ /** A save was tried: from then on every blocking problem is shown, not only the ones a person touched. */
41
+ this.tried = false;
42
+ /* ── pieces ────────────────────────────────────────────────────────── */
43
+ this.dayWord = (offset) => rt('day-offset-' + offset);
20
44
  }
21
45
  static { this.styles = [
46
+ FormFieldStyles,
47
+ ButtonStyles,
22
48
  DocumentPageStyles,
23
49
  ScrollbarStyles,
24
- CommonHeaderStyles,
25
- revisionStyles,
26
50
  css `
27
- .page {
28
- display: grid;
51
+ .arrangement {
52
+ display: flex;
53
+ flex-direction: column;
29
54
  gap: var(--spacing-large);
30
- min-width: 0;
31
55
  }
32
- .page-actions,
33
- .action-row,
34
- .history-head {
56
+
57
+ /* ── the day bar ─────────────────────────────────────────────── */
58
+ .day-groups {
59
+ display: grid;
60
+ grid-template-columns: max-content minmax(0, 1fr);
61
+ gap: var(--spacing-large) var(--spacing-large);
62
+ align-items: start;
63
+ }
64
+ .day-label {
65
+ padding-top: 7px;
66
+ color: var(--ui-muted);
67
+ font-size: var(--ui-t-small);
68
+ font-weight: 600;
69
+ white-space: nowrap;
70
+ }
71
+ .day-none {
72
+ padding-top: 7px;
73
+ color: var(--ui-muted);
74
+ }
75
+ .bar {
76
+ position: relative;
77
+ height: 32px;
78
+ border-radius: var(--md-sys-shape-corner-small, 4px);
79
+ background: var(--md-sys-color-surface-container-low);
80
+ overflow: hidden;
81
+ }
82
+ .piece {
83
+ position: absolute;
84
+ inset-block: 0;
85
+ box-sizing: border-box;
35
86
  display: flex;
36
87
  align-items: center;
37
- flex-wrap: wrap;
38
88
  gap: var(--spacing-small);
89
+ min-width: 0;
90
+ padding-inline: var(--spacing-medium);
91
+ overflow: hidden;
92
+ white-space: nowrap;
93
+ font-size: var(--ui-t-small);
39
94
  }
40
- .page-actions {
41
- justify-content: space-between;
95
+ .piece[data-kind='shift'] {
96
+ border-inline-start: 3px solid var(--ui-accent, var(--md-sys-color-primary));
97
+ border-inline-end: 1px solid var(--md-sys-color-surface-container-lowest);
98
+ background: color-mix(in srgb, var(--ui-accent, var(--md-sys-color-primary)) 18%, var(--md-sys-color-surface-container-lowest));
99
+ color: var(--ui-ink);
42
100
  }
43
- .page-section {
44
- min-width: 0;
101
+ .piece[data-kind='shift'] strong {
102
+ font-weight: 600;
103
+ overflow: hidden;
104
+ text-overflow: ellipsis;
45
105
  }
46
- .page-section-header {
47
- display: flex;
48
- align-items: center;
49
- justify-content: space-between;
50
- gap: var(--spacing-medium);
106
+ .piece .time {
107
+ color: var(--ui-muted);
108
+ font-variant-numeric: tabular-nums;
51
109
  }
52
- .page-section-header h2,
53
- .page-section-header h3 {
54
- margin: 0;
110
+ .piece[data-kind='gap'] {
111
+ background: repeating-linear-gradient(
112
+ -45deg,
113
+ transparent 0 5px,
114
+ color-mix(in srgb, var(--ui-warn) 35%, transparent) 5px 6px
115
+ );
55
116
  }
56
- .current-section {
57
- border-inline-start: 3px solid var(--ui-accent);
117
+ .piece[data-kind='overlap'] {
118
+ background: repeating-linear-gradient(
119
+ -45deg,
120
+ color-mix(in srgb, var(--ui-crit) 30%, transparent) 0 4px,
121
+ transparent 4px 7px
122
+ );
58
123
  }
59
- .editing {
60
- display: grid;
61
- gap: var(--spacing-medium);
62
- padding: var(--spacing-medium);
63
- border: 1px solid color-mix(in srgb, var(--ui-accent) 35%, transparent);
64
- border-radius: var(--ui-r-small, 6px);
65
- background: color-mix(in srgb, var(--ui-accent) 7%, var(--ui-bg));
124
+ .midnight {
125
+ position: absolute;
126
+ inset-block: 0;
127
+ border-inline-start: 1px dashed var(--ui-muted);
66
128
  }
67
- .field-grid {
68
- display: grid;
69
- grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
70
- gap: var(--spacing-medium);
129
+ .axis {
130
+ position: relative;
131
+ height: 16px;
132
+ margin-top: 2px;
133
+ color: var(--ui-muted);
134
+ font-size: var(--ui-t-micro);
135
+ font-variant-numeric: tabular-nums;
71
136
  }
72
- .field {
137
+ .axis span {
138
+ position: absolute;
139
+ white-space: nowrap;
140
+ transform: translateX(-50%);
141
+ }
142
+ .axis span:first-child {
143
+ transform: none;
144
+ }
145
+ .axis span:last-child {
146
+ transform: translateX(-100%);
147
+ }
148
+
149
+ .findings {
73
150
  display: grid;
74
- align-content: start;
75
151
  gap: var(--spacing-small);
76
- min-width: 0;
152
+ margin: 0;
153
+ padding: 0;
154
+ list-style: none;
155
+ }
156
+ .findings li {
157
+ display: flex;
158
+ gap: var(--spacing-medium);
159
+ font-variant-numeric: tabular-nums;
160
+ }
161
+ .findings li::before {
162
+ content: '';
163
+ flex: none;
164
+ width: 3px;
165
+ border-radius: 2px;
166
+ background: var(--ui-warn);
77
167
  }
78
- .field > span,
79
- .field-label {
168
+ .findings li[data-kind='overlap']::before,
169
+ .findings li[data-kind='error']::before {
170
+ background: var(--ui-crit);
171
+ }
172
+ .findings .why {
80
173
  display: block;
81
174
  color: var(--ui-muted);
82
175
  font-size: var(--ui-t-small);
83
176
  }
84
- input,
85
- select,
86
- textarea {
87
- box-sizing: border-box;
177
+
178
+ /* ── tables: the grid's look (page-standard §2-1) ───────────────── */
179
+ .table-scroll {
180
+ overflow-x: auto;
181
+ }
182
+ table {
88
183
  width: 100%;
89
- min-height: var(--form-element-height-medium, 40px);
90
- padding: 7px 10px;
91
- border: 1px solid var(--ui-line);
92
- border-radius: var(--ui-r-small, 6px);
93
- background: var(--ui-panel);
94
- color: var(--ui-ink);
95
- font: inherit;
184
+ border-collapse: collapse;
185
+ font-size: var(--md-sys-typescale-label-large-size, 14px);
186
+ }
187
+ th {
188
+ padding: var(--spacing-small) var(--spacing-medium);
189
+ border-top: 2px solid var(--md-sys-color-primary);
190
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
191
+ background: var(--md-sys-color-surface-container-low);
192
+ color: var(--md-sys-color-primary);
193
+ font-weight: 500;
194
+ text-align: start;
195
+ white-space: nowrap;
196
+ }
197
+ td {
198
+ padding: var(--spacing-medium);
199
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
200
+ vertical-align: top;
96
201
  }
97
- input[type='time'] {
202
+ td.figure,
203
+ .figure {
98
204
  font-variant-numeric: tabular-nums;
205
+ white-space: nowrap;
99
206
  }
100
- .shift-list {
101
- display: grid;
102
- gap: var(--spacing-small);
207
+ td.reason {
208
+ min-width: 16em;
103
209
  }
104
- .shift-card {
105
- min-width: 0;
106
- padding: var(--spacing-medium);
107
- border: 1px solid var(--ui-line);
108
- border-radius: var(--ui-r-small, 6px);
109
- background: var(--ui-panel);
210
+ td.actions {
211
+ width: 1%;
212
+ white-space: nowrap;
110
213
  }
111
- .shift-card-head {
214
+ td.actions > div {
112
215
  display: flex;
113
- align-items: center;
114
- justify-content: space-between;
216
+ justify-content: flex-end;
115
217
  gap: var(--spacing-small);
116
- margin-bottom: var(--spacing-medium);
117
- }
118
- .shift-card-head strong {
119
- font-size: var(--ui-t-title);
120
218
  }
121
- .shift-fields {
122
- display: grid;
123
- grid-template-columns: repeat(2, minmax(0, 1fr));
124
- gap: var(--spacing-medium);
219
+ .muted {
220
+ color: var(--ui-muted);
125
221
  }
126
- .period {
222
+ .shift-lines {
127
223
  display: grid;
128
- grid-template-columns: minmax(88px, 0.8fr) minmax(110px, 1fr);
129
- align-items: end;
130
- gap: var(--spacing-small);
224
+ gap: 2px;
131
225
  }
132
- .weekdays {
133
- margin: var(--spacing-medium) 0 0;
134
- padding: var(--spacing-small) var(--spacing-medium) var(--spacing-medium);
135
- border: 1px solid var(--ui-line);
136
- border-radius: var(--ui-r-small, 6px);
226
+ .shift-lines > div {
227
+ white-space: nowrap;
137
228
  }
138
- .weekdays legend {
139
- padding: 0 var(--spacing-tiny);
229
+ .shift-lines .figure {
140
230
  color: var(--ui-muted);
141
- font-size: var(--ui-t-small);
142
231
  }
143
- .week-options {
144
- display: flex;
145
- flex-wrap: wrap;
146
- gap: var(--spacing-small) var(--spacing-medium);
147
- }
148
- .week-options label {
149
- display: inline-flex;
150
- align-items: center;
151
- gap: var(--spacing-small);
232
+ .state {
233
+ display: inline-block;
234
+ padding: 1px var(--spacing-medium);
235
+ border-radius: var(--md-sys-shape-corner-small, 4px);
236
+ background: var(--md-sys-color-surface-container-high);
237
+ font-size: var(--ui-t-micro);
238
+ font-weight: 600;
152
239
  white-space: nowrap;
153
240
  }
154
- .week-options input {
155
- width: 18px;
156
- min-height: 18px;
157
- margin: 0;
158
- accent-color: var(--ui-accent);
241
+ .state[data-state='current'] {
242
+ background: color-mix(in srgb, var(--ui-good) 18%, transparent);
159
243
  }
160
- .every-day {
161
- margin-top: var(--spacing-small);
244
+ .state[data-state='scheduled'],
245
+ .state[data-state='pending'],
246
+ .state[data-state='approved'] {
247
+ background: color-mix(in srgb, var(--ui-info, var(--md-sys-color-primary)) 18%, transparent);
162
248
  }
163
- .shift-coverage {
164
- display: grid;
165
- gap: var(--spacing-small);
249
+ .state[data-state='rejected'] {
250
+ background: color-mix(in srgb, var(--ui-crit) 16%, transparent);
166
251
  }
167
- .coverage-title {
168
- margin: 0;
169
- font-size: var(--ui-t-title);
252
+ tr.detail > td {
253
+ background: var(--md-sys-color-surface-container-lowest);
170
254
  }
171
- .coverage-groups {
255
+
256
+ /* ── the editor ──────────────────────────────────────────────── */
257
+ .shift-rows {
258
+ container-type: inline-size;
172
259
  display: grid;
173
- gap: var(--spacing-medium);
260
+ margin-bottom: var(--spacing-large);
174
261
  }
175
- .history-bands,
176
- .history-band-group {
262
+ .shift-row {
177
263
  display: grid;
178
- gap: var(--spacing-small);
264
+ grid-template-columns:
265
+ minmax(110px, 1fr) minmax(184px, 1.1fr) minmax(190px, 1.1fr) minmax(392px, max-content)
266
+ minmax(110px, 1fr) max-content;
267
+ gap: var(--spacing-medium) var(--spacing-large);
268
+ align-items: start;
269
+ padding: var(--spacing-medium) 0;
270
+ border-bottom: 1px solid var(--md-sys-color-outline-variant);
271
+ }
272
+ .shift-row.head {
273
+ padding-block: var(--spacing-small);
274
+ border-top: 2px solid var(--md-sys-color-primary);
275
+ color: var(--md-sys-color-primary);
276
+ font-weight: 500;
179
277
  }
180
- .coverage-group {
278
+ .shift-row input:not([type='checkbox']),
279
+ .shift-row select {
280
+ width: 100%;
281
+ min-width: 0;
282
+ }
283
+ .cell {
181
284
  display: grid;
182
285
  gap: var(--spacing-small);
183
286
  min-width: 0;
184
- padding: var(--spacing-medium);
185
- border: 1px solid var(--ui-line);
186
- border-radius: var(--ui-r-small, 6px);
187
- background: var(--ui-panel);
188
287
  }
189
- .coverage-group h4 {
190
- margin: 0;
191
- font-size: var(--ui-t-body);
192
- }
193
- .band {
194
- position: relative;
195
- height: 48px;
196
- overflow: hidden;
197
- border: 1px solid var(--ui-line);
198
- border-radius: var(--ui-r-small, 6px);
199
- background: repeating-linear-gradient(45deg, var(--ui-panel) 0 5px, var(--ui-line) 5px 6px);
200
- }
201
- .band[data-variant='scheduled'] {
202
- height: 40px;
203
- }
204
- .band[data-variant='past'] {
205
- height: 32px;
288
+ .cell-label {
289
+ display: none;
290
+ color: var(--ui-muted);
291
+ font-size: var(--ui-t-micro);
206
292
  }
207
- .piece {
208
- position: absolute;
209
- inset-block: 0;
293
+ .clock {
210
294
  display: flex;
211
295
  align-items: center;
212
- justify-content: center;
213
- overflow: hidden;
214
- padding-inline: 3px;
215
- border-inline: 1px solid var(--ui-panel);
216
- background: var(--ui-accent);
217
- color: var(--ui-on-accent);
218
- font-size: var(--ui-t-small);
219
- line-height: 1.2;
220
- text-align: center;
221
- text-overflow: ellipsis;
222
- white-space: nowrap;
223
- }
224
- .piece-label {
225
- display: block;
226
- overflow: hidden;
227
- text-overflow: ellipsis;
228
- white-space: nowrap;
296
+ gap: var(--spacing-small);
229
297
  }
230
- .piece[data-overlap] {
231
- background: repeating-linear-gradient(
232
- -45deg,
233
- var(--ui-warn) 0 5px,
234
- color-mix(in srgb, var(--ui-warn) 72%, var(--ui-ink)) 5px 7px
235
- );
298
+ .clock select {
299
+ flex: none;
300
+ width: 72px !important;
236
301
  }
237
- .band[data-variant='scheduled'] .piece {
238
- border: 1px dashed var(--ui-accent);
239
- background: color-mix(in srgb, var(--ui-accent) 28%, var(--ui-panel));
240
- color: var(--ui-ink);
302
+ .clock input {
303
+ flex: 1;
241
304
  }
242
- .band[data-variant='past'] .piece {
243
- background: color-mix(in srgb, var(--ui-muted) 35%, var(--ui-panel));
244
- color: var(--ui-ink);
245
- }
246
- .ticks {
247
- display: flex;
248
- justify-content: space-between;
249
- gap: var(--spacing-small);
305
+ .derived {
250
306
  color: var(--ui-muted);
251
- font: var(--ui-t-micro) var(--ui-mono);
252
- }
253
- .finding,
254
- .settled,
255
- .notice,
256
- .empty {
257
- padding: var(--spacing-small) var(--spacing-medium);
258
- border-inline-start: 3px solid var(--ui-warn);
259
- background: var(--ui-panel);
260
- line-height: 1.6;
307
+ font-size: var(--ui-t-small);
308
+ font-variant-numeric: tabular-nums;
309
+ white-space: nowrap;
261
310
  }
262
- .settled {
263
- border-color: var(--ui-good);
264
- color: var(--ui-muted);
311
+ .row-issues {
312
+ grid-column: 1 / -1;
313
+ margin: 0;
314
+ color: var(--ui-crit);
315
+ font-size: var(--ui-t-small);
265
316
  }
266
- .notice {
267
- border-color: var(--ui-info);
317
+ .shift-row input[aria-invalid='true'] {
318
+ border-color: var(--ui-crit);
268
319
  }
269
- .guidance {
270
- margin: 0;
271
- padding-inline-start: 1.25rem;
272
- color: var(--ui-muted);
273
- line-height: 1.6;
320
+ ox-input-select-buttons {
321
+ --md-chip-set-gap: 4px;
274
322
  }
275
- .guidance li + li {
276
- margin-top: var(--spacing-small);
323
+ @container (max-width: 1080px) {
324
+ .shift-row {
325
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
326
+ }
327
+ .shift-row.head {
328
+ display: none;
329
+ }
330
+ .cell-label {
331
+ display: block;
332
+ }
333
+ .cell.name,
334
+ .cell.days,
335
+ .cell.note {
336
+ grid-column: 1 / -1;
337
+ }
338
+ .cell.remove {
339
+ grid-column: 1 / -1;
340
+ justify-items: start;
341
+ }
277
342
  }
278
- .empty {
279
- border-color: var(--ui-line);
280
- color: var(--ui-muted);
343
+ @container (max-width: 480px) {
344
+ .shift-row {
345
+ grid-template-columns: minmax(0, 1fr);
346
+ }
281
347
  }
282
- .history-list {
348
+
349
+ .form-grid {
283
350
  display: grid;
284
- gap: var(--spacing-medium);
351
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
352
+ gap: var(--spacing-large);
285
353
  }
286
- .history-item {
354
+ .field {
287
355
  display: grid;
356
+ align-content: start;
288
357
  gap: var(--spacing-small);
289
358
  min-width: 0;
290
- padding-block: var(--spacing-small);
291
- border-bottom: 1px solid var(--ui-line);
292
359
  }
293
- .history-head {
294
- justify-content: flex-start;
295
- }
296
- .history-period {
360
+ .field > span:first-child {
297
361
  color: var(--ui-muted);
298
- font-variant-numeric: tabular-nums;
362
+ font-size: var(--ui-t-micro);
299
363
  }
300
- .history-actions {
301
- display: flex;
302
- align-items: center;
303
- gap: var(--spacing-small);
304
- margin-inline-start: auto;
305
- }
306
- .status {
307
- display: inline-flex;
308
- align-items: center;
309
- min-height: 26px;
310
- padding: 2px 9px;
311
- border-radius: 999px;
312
- background: var(--ui-accent-wash);
313
- color: var(--ui-ink);
314
- }
315
- .status[data-state='current'],
316
- .status[data-state='approved'] {
317
- background: color-mix(in srgb, var(--ui-good) 16%, var(--ui-panel));
318
- }
319
- .status[data-state='pending'],
320
- .status[data-state='scheduled'] {
321
- background: color-mix(in srgb, var(--ui-info) 16%, var(--ui-panel));
364
+ .field input,
365
+ .field select {
366
+ width: 100%;
322
367
  }
323
- .status[data-state='rejected'] {
324
- background: color-mix(in srgb, var(--ui-warn) 18%, var(--ui-panel));
368
+ .field .error {
369
+ color: var(--ui-crit);
370
+ font-size: var(--ui-t-small);
325
371
  }
326
- .history-detail {
327
- margin-top: var(--spacing-small);
372
+ .when {
373
+ display: flex;
374
+ flex-wrap: wrap;
375
+ align-items: center;
376
+ gap: var(--spacing-medium);
328
377
  }
329
- button {
330
- min-height: var(--form-action-height, 40px);
331
- border-radius: var(--ui-r-small, 6px);
378
+ .when input {
379
+ flex: 1;
380
+ min-width: 200px;
332
381
  }
333
- button:focus-visible,
334
- input:focus-visible,
335
- select:focus-visible,
336
- summary:focus-visible {
337
- outline-color: var(--ui-accent);
382
+ .alert {
383
+ margin: 0;
384
+ padding: var(--spacing-medium) var(--spacing-large);
385
+ border-inline-start: 3px solid var(--ui-crit);
386
+ background: color-mix(in srgb, var(--ui-crit) 8%, transparent);
338
387
  }
339
- @media (max-width: 640px) {
340
- .shift-fields {
388
+
389
+ @media (max-width: 600px) {
390
+ .day-groups {
341
391
  grid-template-columns: minmax(0, 1fr);
392
+ gap: var(--spacing-small);
342
393
  }
343
- .period {
344
- grid-template-columns: minmax(82px, 0.8fr) minmax(0, 1fr);
345
- }
346
- .history-actions {
347
- width: 100%;
348
- margin-inline-start: 0;
349
- }
350
- .history-actions button {
351
- flex: 1;
352
- }
353
- .piece {
354
- font-size: var(--ui-t-micro);
394
+ .piece .time {
395
+ display: none;
355
396
  }
356
397
  }
357
398
  `
358
399
  ]; }
359
400
  get context() {
360
- return { title: i18next.t('title.work-shift'), frame: 'document', help: 'page/work-shift' };
361
- }
362
- async pageUpdated() {
363
- if (this.active && !this.dirty)
364
- await this.load();
365
- }
366
- current() {
367
- const segment = this.overview?.schedule.find((s) => s.state === 'current');
368
- return segment && this.overview.revisions.find((r) => r.id === segment.revisionId);
401
+ return {
402
+ title: t('title.work-shift'),
403
+ frame: 'document',
404
+ help: 'page/work-shift',
405
+ actions: this.actions()
406
+ };
369
407
  }
370
- async run(action) {
371
- this.busy = true;
372
- this.message = '';
373
- try {
374
- await action();
375
- }
376
- catch (e) {
377
- this.message = e.message;
378
- }
379
- finally {
380
- this.busy = false;
408
+ actions() {
409
+ if (!this.overview || this.error)
410
+ return [];
411
+ if (!this.draft) {
412
+ return [{ title: t('button.change-shifts'), icon: 'edit', emphasis: { raised: true }, action: () => this.begin() }];
381
413
  }
414
+ const approval = this.overview.approvalRequired;
415
+ return [
416
+ { title: t('button.cancel'), action: () => this.close() },
417
+ ...(approval ? [{ title: rt('save-draft'), action: () => this.saveDraft() }] : []),
418
+ {
419
+ title: approval ? rt('request-approval') : t('button.save'),
420
+ icon: approval ? 'send' : 'save',
421
+ emphasis: { raised: true },
422
+ action: () => this.commit()
423
+ }
424
+ ];
382
425
  }
426
+ async pageUpdated(changes) {
427
+ if (this.active && changes.active && !this.draft)
428
+ await this.load();
429
+ }
430
+ /* ── data ──────────────────────────────────────────────────────────── */
383
431
  async load() {
384
- await this.run(async () => {
432
+ this.loading = true;
433
+ this.error = null;
434
+ try {
385
435
  const result = await client.query({
386
436
  query: gql `
387
437
  query {
@@ -391,464 +441,678 @@ let WorkShift = class WorkShift extends PageView {
391
441
  fetchPolicy: 'no-cache'
392
442
  });
393
443
  if (result.errors?.length)
394
- throw new Error(result.errors[0].message);
444
+ throw result.errors[0];
395
445
  this.overview = result.data.workShiftRevisionOverview;
396
- const statuses = {};
397
- for (const row of this.overview.revisions.filter((r) => r.activityInstanceId)) {
398
- let promise;
399
- document.dispatchEvent(new CustomEvent('master-change-status', {
400
- detail: { activityInstanceId: row.activityInstanceId, respondWith: (p) => (promise = p) }
401
- }));
402
- if (promise)
403
- statuses[row.id] = await promise;
404
- }
405
- this.statuses = statuses;
406
- });
446
+ this.approvals = await this.readApprovals(this.overview.revisions);
447
+ }
448
+ catch (e) {
449
+ this.error = e;
450
+ }
451
+ finally {
452
+ this.loading = false;
453
+ this.updateContext();
454
+ }
407
455
  }
408
- begin(row = this.current()) {
456
+ /** Approval states come from ops-master's approval flow, one revision at a time. */
457
+ async readApprovals(revisions) {
458
+ const approvals = {};
459
+ for (const row of revisions.filter(r => r.activityInstanceId)) {
460
+ let promise;
461
+ document.dispatchEvent(new CustomEvent('master-change-status', {
462
+ detail: { activityInstanceId: row.activityInstanceId, respondWith: (p) => (promise = p) }
463
+ }));
464
+ if (promise)
465
+ approvals[row.id] = await promise.catch(() => ({ state: 'unknown' }));
466
+ }
467
+ return approvals;
468
+ }
469
+ get placed() {
470
+ return placeRevisions(this.overview ?? { revisions: [], schedule: [] }, this.approvals);
471
+ }
472
+ /* ── editing ───────────────────────────────────────────────────────── */
473
+ begin(from) {
474
+ const base = from ?? this.placed.current?.row;
475
+ const shifts = base?.shifts ?? this.overview.legacyShifts ?? [];
409
476
  this.draft = {
410
- description: row?.description ?? '',
411
- timezone: row?.timezone ?? this.overview.timezone,
412
- shifts: structuredClone(row?.shifts ??
413
- (this.overview.legacyShifts?.length
414
- ? this.overview.legacyShifts
415
- : [{ name: '', fromDate: 0, fromTime: '08:00', toDate: 0, toTime: '16:00', daysOfWeek: '' }])),
477
+ description: base?.description ?? '',
478
+ timezone: base?.timezone ?? this.overview.timezone,
479
+ shifts: shifts.length ? shifts.map(shift => settle({ ...shift })) : [nextShift([])],
480
+ effectiveMode: 'now',
416
481
  effectiveFrom: new Date().toISOString(),
417
482
  reason: ''
418
483
  };
484
+ this.enterEditor();
485
+ }
486
+ resume(row) {
487
+ this.draft = {
488
+ id: row.id,
489
+ editGeneration: row.editGeneration,
490
+ revisionNumber: row.revisionNumber,
491
+ description: row.description ?? '',
492
+ timezone: row.timezone ?? this.overview.timezone,
493
+ shifts: (row.shifts ?? []).map(shift => settle({ ...shift })),
494
+ effectiveMode: 'at',
495
+ effectiveFrom: row.effectiveFrom ?? new Date().toISOString(),
496
+ reason: row.reason ?? ''
497
+ };
498
+ this.enterEditor();
499
+ }
500
+ enterEditor() {
419
501
  this.dirty = false;
502
+ this.tried = false;
503
+ this.saveError = '';
504
+ this.updateContext();
505
+ /* The document frame scrolls the page element itself. */
506
+ this.scrollTo({ top: 0 });
420
507
  }
421
- change(field, value) {
422
- this.draft = { ...this.draft, [field]: value };
508
+ async close() {
509
+ if (this.busy)
510
+ return;
511
+ if (this.dirty &&
512
+ !(await OxPrompt.open({
513
+ type: 'warning',
514
+ title: rt('discard-question'),
515
+ confirmButton: { text: rt('discard'), destructive: true },
516
+ cancelButton: { text: rt('keep-editing') }
517
+ })))
518
+ return;
519
+ this.draft = undefined;
520
+ this.dirty = false;
521
+ this.updateContext();
522
+ await this.load();
523
+ }
524
+ change(patch) {
525
+ this.draft = { ...this.draft, ...patch };
423
526
  this.dirty = true;
424
527
  }
425
- changeShift(index, field, value) {
426
- this.change('shifts', this.draft.shifts.map((s, i) => (i === index ? { ...s, [field]: value } : s)));
427
- }
428
- async save() {
429
- await this.run(async () => {
430
- const { description, timezone, shifts, effectiveFrom, reason } = this.draft;
431
- const content = { description, timezone, shifts, effectiveFrom, reason };
432
- const editing = !!this.draft.id;
433
- const response = await client.mutate({
434
- mutation: editing
435
- ? gql `
528
+ changeShift(index, patch) {
529
+ this.change({ shifts: this.draft.shifts.map((s, i) => (i === index ? settle({ ...s, ...patch }) : s)) });
530
+ }
531
+ content() {
532
+ const { description, timezone, shifts, effectiveMode, effectiveFrom, reason } = this.draft;
533
+ return {
534
+ description,
535
+ timezone,
536
+ shifts: shiftsContent(shifts),
537
+ effectiveFrom: effectiveMode === 'now' ? new Date().toISOString() : effectiveFrom,
538
+ reason: reason.trim()
539
+ };
540
+ }
541
+ async run(work) {
542
+ if (this.busy)
543
+ return undefined;
544
+ this.busy = true;
545
+ this.saveError = '';
546
+ try {
547
+ return await work();
548
+ }
549
+ catch (e) {
550
+ /* The GraphQL client already announces server refusals and network failures; say only what it does not. */
551
+ if (!(e?.reported || e?.graphQLErrors?.length || e?.networkError))
552
+ this.saveError = e?.message ?? String(e);
553
+ return undefined;
554
+ }
555
+ finally {
556
+ this.busy = false;
557
+ }
558
+ }
559
+ async mutate(mutation, variables, field) {
560
+ const response = await client.mutate({ mutation, variables });
561
+ if (response.errors?.length)
562
+ throw Object.assign(new Error(response.errors[0].message), { reported: true });
563
+ return response.data[field];
564
+ }
565
+ /** Saves the editor as a draft and keeps it open. Returns false when it did not save. */
566
+ async saveDraft(quiet = false) {
567
+ this.tried = true;
568
+ if (draftIssues(this.draft).blocking)
569
+ return false;
570
+ const saved = await this.run(async () => {
571
+ const draft = this.draft;
572
+ const row = draft.id
573
+ ? await this.mutate(gql `
436
574
  mutation ($revisionId: String!, $editGeneration: Int!, $content: Object!) {
437
575
  editWorkShiftDraft(revisionId: $revisionId, editGeneration: $editGeneration, content: $content)
438
576
  }
439
- `
440
- : gql `
577
+ `, { revisionId: draft.id, editGeneration: draft.editGeneration, content: this.content() }, 'editWorkShiftDraft')
578
+ : await this.mutate(gql `
441
579
  mutation ($content: Object!) {
442
580
  createWorkShiftDraft(content: $content)
443
581
  }
444
- `,
445
- variables: {
446
- content,
447
- ...(editing ? { revisionId: this.draft.id, editGeneration: this.draft.editGeneration } : {})
448
- }
449
- });
450
- if (response.errors?.length)
451
- throw new Error(response.errors[0].message);
452
- this.draft = response.data[editing ? 'editWorkShiftDraft' : 'createWorkShiftDraft'];
582
+ `, { content: this.content() }, 'createWorkShiftDraft');
583
+ this.draft = { ...draft, id: row.id, editGeneration: row.editGeneration, revisionNumber: row.revisionNumber };
453
584
  this.dirty = false;
585
+ return true;
454
586
  });
455
- if (!this.message)
456
- await this.load();
587
+ if (saved && !quiet)
588
+ notify({ message: rt('saved') });
589
+ return !!saved;
457
590
  }
458
- async submit() {
459
- await this.run(async () => {
460
- if (this.overview.approvalRequired) {
591
+ /** Puts the change into effect, or asks for approval where the site requires it. */
592
+ async commit() {
593
+ this.tried = true;
594
+ if (draftIssues(this.draft).blocking)
595
+ return;
596
+ const approval = this.overview.approvalRequired;
597
+ if (approval && (this.dirty || !this.draft.id) && !(await this.saveDraft(true)))
598
+ return;
599
+ const done = await this.run(async () => {
600
+ const draft = this.draft;
601
+ if (approval) {
461
602
  let promise;
462
603
  document.dispatchEvent(new CustomEvent('master-change-approval', {
463
604
  detail: {
464
605
  axis: 'workShift',
465
- revisionId: this.draft.id,
466
- editGeneration: this.draft.editGeneration,
606
+ revisionId: draft.id,
607
+ editGeneration: draft.editGeneration,
467
608
  respondWith: (p) => (promise = p)
468
609
  }
469
610
  }));
470
611
  if (!promise)
471
- throw new Error(t('approval-unavailable'));
612
+ throw new Error(rt('approval-unavailable'));
472
613
  await promise;
473
614
  }
474
615
  else {
475
- const { description, timezone, shifts, effectiveFrom, reason } = this.draft;
476
- const result = await client.mutate({
477
- mutation: gql `
616
+ await this.mutate(gql `
478
617
  mutation ($revisionId: String, $editGeneration: Int, $content: Object!) {
479
618
  saveAndPublishWorkShift(revisionId: $revisionId, editGeneration: $editGeneration, content: $content)
480
619
  }
481
- `,
482
- variables: {
483
- revisionId: this.draft.id,
484
- editGeneration: this.draft.editGeneration,
485
- content: { description, timezone, shifts, effectiveFrom, reason }
486
- }
487
- });
488
- if (result.errors?.length)
489
- throw new Error(result.errors[0].message);
620
+ `, { revisionId: draft.id, editGeneration: draft.editGeneration, content: this.content() }, 'saveAndPublishWorkShift');
490
621
  }
491
- this.draft = undefined;
492
- this.dirty = false;
622
+ return true;
493
623
  });
494
- if (!this.message)
495
- await this.load();
624
+ if (!done)
625
+ return;
626
+ notify({ message: rt(approval ? 'submitted' : 'published') });
627
+ this.draft = undefined;
628
+ this.dirty = false;
629
+ this.updateContext();
630
+ await this.load();
496
631
  }
497
- rowStatus(row) {
498
- const segment = this.overview.schedule.find((s) => s.revisionId === row.id);
499
- return segment?.state ?? this.statuses[row.id]?.state ?? (row.frozenAt ? 'pending' : 'draft');
500
- }
501
- weekdays(row, index) {
502
- const days = row.daysOfWeek ? row.daysOfWeek.split(',') : ['0', '1', '2', '3', '4', '5', '6'];
503
- return html `<fieldset class="weekdays">
504
- <legend>${t('daysOfWeek')}</legend>
505
- <div class="week-options">
506
- ${['1', '2', '3', '4', '5', '6', '0'].map(day => html `<label
507
- ><input
508
- type="checkbox"
509
- ?disabled=${this.busy}
510
- .checked=${days.includes(day)}
511
- @change=${(e) => {
512
- const next = e.target.checked ? [...days, day] : days.filter((d) => d !== day);
513
- if (!next.length) {
514
- e.target.checked = true;
515
- return;
516
- }
517
- this.changeShift(index, 'daysOfWeek', next.length === 7 ? '' : [...new Set(next)].sort().join(','));
518
- }}
519
- />${t('weekday-' + day)}</label
520
- >`)}
521
- </div>
522
- <button
523
- class="every-day"
524
- ?disabled=${this.busy || days.length === 7}
525
- @click=${() => this.changeShift(index, 'daysOfWeek', '')}
526
- >
527
- ${t('every-day')}
528
- </button>
529
- <small class="field-label">${i18next.t('text.weekday-workdate-note')}</small>
530
- </fieldset>`;
531
- }
532
- timeLabel(minute) {
533
- const time = clockOf(minute);
534
- return minute >= DAY_MINUTES ? i18next.t('text.next-day-time', { time: clockOf(minute - DAY_MINUTES) }) : time;
535
- }
536
- band(shifts, variant) {
537
- const cycle = cycleOf(shifts);
538
- if (!cycle)
632
+ clock(minute) {
633
+ return minute >= DAY_MINUTES
634
+ ? `${this.dayWord(1)} ${clockOf(minute - DAY_MINUTES)}`
635
+ : minute < 0
636
+ ? `${this.dayWord(-1)} ${clockOf(minute + DAY_MINUTES)}`
637
+ : clockOf(minute);
638
+ }
639
+ span(from, to) {
640
+ return `${this.clock(from)} – ${this.clock(to)}`;
641
+ }
642
+ hours(minutes) {
643
+ if (minutes === undefined)
539
644
  return '';
540
- const overlaps = new Set(coverageProblems(shifts)
541
- .filter(problem => problem.kind === 'overlap')
542
- .flatMap(problem => problem.names));
645
+ const h = Math.floor(minutes / 60);
646
+ const m = minutes % 60;
647
+ return m ? t('text.hours-minutes', { h, m }) : t('text.hours', { h });
648
+ }
649
+ daysText(days) {
650
+ return daysLabel(days, day => rt('weekday-' + day), rt('every-day'));
651
+ }
652
+ bar(shifts) {
653
+ const line = timelineOf(shifts);
654
+ if (!line)
655
+ return html `<div class="day-none">${t('text.no-shift-on-these-days')}</div>`;
656
+ const label = (piece) => piece.kind === 'shift'
657
+ ? `${piece.names[0]} ${this.span(piece.fromMinute, piece.toMinute)}`
658
+ : piece.kind === 'gap'
659
+ ? t('text.shift-gap-span', { span: this.span(piece.fromMinute, piece.toMinute) })
660
+ : t('text.shift-overlap-span', { span: this.span(piece.fromMinute, piece.toMinute), first: piece.names[0], second: piece.names[1] });
543
661
  return html `<div>
544
- <div
545
- class="band"
546
- data-variant=${variant}
547
- role="img"
548
- aria-label=${shifts.map(shift => `${shift.name}: ${t('day-offset-' + (shift.fromDate ?? 0))} ${shift.fromTime}–${t('day-offset-' + (shift.toDate ?? 0))} ${shift.toTime}`).join(', ')}
549
- >
550
- ${shifts.map(shift => {
551
- const span = spanOf(shift);
552
- if (!span || span.toMinute <= span.fromMinute)
553
- return '';
554
- const left = Math.max(cycle.startMinute, span.fromMinute);
555
- const right = Math.min(cycle.endMinute, span.toMinute);
556
- if (right <= left)
557
- return '';
558
- const label = `${shift.name} ${t('day-offset-' + (shift.fromDate ?? 0))} ${shift.fromTime}–${t('day-offset-' + (shift.toDate ?? 0))} ${shift.toTime}`;
559
- return html `<div
560
- class="piece"
561
- ?data-overlap=${overlaps.has(shift.name)}
562
- title=${label}
563
- style=${`left:${((left - cycle.startMinute) / DAY_MINUTES) * 100}%;width:${((right - left) / DAY_MINUTES) * 100}%`}
564
- >
565
- <span class="piece-label">${label}</span>
566
- </div>`;
567
- })}
662
+ <div class="bar" role="img" aria-label=${line.pieces.map(label).join(', ')}>
663
+ ${line.midnights.map(left => html `<div class="midnight" style="left:${left}%"></div>`)}
664
+ ${line.pieces.map(piece => html `<div class="piece" data-kind=${piece.kind} style="left:${piece.left}%;width:${piece.width}%" title=${label(piece)}>
665
+ ${piece.kind === 'shift'
666
+ ? html `<strong>${piece.names[0]}</strong
667
+ ><span class="time">${clockOf(((piece.fromMinute % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES)}–${clockOf(((piece.toMinute % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES)}</span>`
668
+ : nothing}
669
+ </div>`)}
568
670
  </div>
569
- <div class="ticks">
570
- ${[0, 6, 12, 18, 24].map(hour => html `<span>${this.timeLabel(cycle.startMinute + hour * 60)}</span>`)}
671
+ <div class="axis" aria-hidden="true">
672
+ ${line.ticks.map((minute, i) => html `<span style="left:${i * 25}%">${clockOf(((minute % DAY_MINUTES) + DAY_MINUTES) % DAY_MINUTES)}</span>`)}
571
673
  </div>
572
674
  </div>`;
573
675
  }
574
- problemText(problem) {
575
- const span = problem.fromMinute === undefined || problem.toMinute === undefined
576
- ? ''
577
- : `${this.timeLabel(problem.fromMinute)}–${this.timeLabel(problem.toMinute)}`;
578
- if (problem.kind === 'gap')
579
- return i18next.t('text.shift-gap', { span, before: problem.names[0], after: problem.names[1] });
580
- if (problem.kind === 'overlap')
581
- return i18next.t('text.shift-overlap', { span, first: problem.names[0], second: problem.names[1] });
582
- return i18next.t('text.shift-not-a-span', { name: problem.names[0] || '—' });
583
- }
584
- weekGroups(shifts) {
585
- const groups = new Map();
586
- for (const day of ['1', '2', '3', '4', '5', '6', '0']) {
587
- const active = shifts.filter(shift => !shift.daysOfWeek || shift.daysOfWeek.split(',').includes(day));
588
- const key = shifts
589
- .map((shift, index) => (active.includes(shift) ? String(index) : ''))
590
- .filter(Boolean)
591
- .join(',');
592
- const group = groups.get(key) ?? { days: [], shifts: active };
593
- group.days.push(day);
594
- groups.set(key, group);
595
- }
596
- return [...groups.values()];
676
+ /**
677
+ * The day bars of an arrangement, one per group of weekdays that run the same shifts, and what is wrong with them.
678
+ * The editor passes the week's overlaps: they include a Friday night running into Saturday, which no single
679
+ * day's bar shows, and the server refuses every one of them.
680
+ */
681
+ timeline(shifts, weekOverlaps) {
682
+ const groups = weekGroups(shifts);
683
+ const findings = groups.flatMap(group => coverageProblems(group.shifts)
684
+ .filter(problem => problem.kind === 'gap' || (problem.kind === 'overlap' && !weekOverlaps))
685
+ .map(problem => ({ group, problem })));
686
+ const firstGap = findings.findIndex(({ problem }) => problem.kind === 'gap');
687
+ const prefix = (days) => (groups.length > 1 ? `${this.daysText(days)} · ` : '');
688
+ return html `
689
+ <div class="day-groups">
690
+ ${groups.map(group => html `<div class="day-label">${this.daysText(group.days)}</div>
691
+ ${group.shifts.length ? this.bar(group.shifts) : html `<div class="day-none">${t('text.no-shift-on-these-days')}</div>`}`)}
692
+ </div>
693
+ ${findings.length || weekOverlaps?.length
694
+ ? html `<ul class="findings">
695
+ ${findings.map(({ group, problem }, index) => problem.kind === 'gap'
696
+ ? html `<li data-kind="gap">
697
+ <span
698
+ >${prefix(group.days)}${t('text.shift-gap-span', { span: this.span(problem.fromMinute, problem.toMinute) })}
699
+ ${index === firstGap ? html `<span class="why">${t('text.shift-gap-why')}</span>` : nothing}</span
700
+ >
701
+ </li>`
702
+ : html `<li data-kind="overlap">
703
+ <span
704
+ >${prefix(group.days)}${t('text.shift-overlap-span', {
705
+ span: this.span(problem.fromMinute, problem.toMinute),
706
+ first: problem.names[0],
707
+ second: problem.names[1]
708
+ })}</span
709
+ >
710
+ </li>`)}
711
+ ${(weekOverlaps ?? []).map(overlap => html `<li data-kind="error">
712
+ <span
713
+ >${t('text.shift-overlap-on-days', {
714
+ days: this.daysText(overlap.days),
715
+ span: this.span(overlap.fromMinute, overlap.toMinute),
716
+ first: overlap.names[0],
717
+ second: overlap.names[1]
718
+ })}</span
719
+ >
720
+ </li>`)}
721
+ </ul>`
722
+ : nothing}
723
+ `;
597
724
  }
598
- weekLabel(days, showEveryDay) {
599
- return showEveryDay && days.length === 7 ? t('every-day') : days.map(day => t('weekday-' + day)).join(' · ');
725
+ shiftTable(shifts) {
726
+ return html `<div class="table-scroll">
727
+ <table>
728
+ <thead>
729
+ <tr>
730
+ <th>${t('field.shift')}</th>
731
+ <th>${t('field.shift-hours')}</th>
732
+ <th>${rt('daysOfWeek')}</th>
733
+ </tr>
734
+ </thead>
735
+ <tbody>
736
+ ${shifts.map(shift => html `<tr>
737
+ <td>${shift.name}${shift.description ? html `<div class="muted">${shift.description}</div>` : nothing}</td>
738
+ <td class="figure">${timeRange(shift, this.dayWord)} <span class="muted">· ${this.hours(lengthOf(shift))}</span></td>
739
+ <td>${this.daysText(daysOf(shift.daysOfWeek))}</td>
740
+ </tr>`)}
741
+ </tbody>
742
+ </table>
743
+ </div>`;
600
744
  }
601
- historyBands(shifts, variant) {
602
- const groups = this.weekGroups(shifts);
603
- return html `<div class="history-bands">
604
- ${groups.map(group => html `<div class="history-band-group">
605
- ${groups.length > 1 ? html `<h4>${this.weekLabel(group.days, true)}</h4>` : ''}
606
- ${group.shifts.length ? this.band(group.shifts, variant) : html `<div class="empty">${i18next.t('text.no-shifts-on-day')}</div>`}
745
+ shiftLines(shifts = []) {
746
+ return html `<div class="shift-lines">
747
+ ${shifts.map(shift => html `<div>
748
+ ${shift.name} <span class="figure">${timeRange(shift, this.dayWord)}</span>
749
+ ${daysOf(shift.daysOfWeek).length < WEEK.length ? html `<span class="muted">${this.daysText(daysOf(shift.daysOfWeek))}</span>` : nothing}
607
750
  </div>`)}
608
751
  </div>`;
609
752
  }
610
- coverage(shifts, variant) {
611
- if (!shifts.length)
612
- return html `<div class="empty">${i18next.t('text.no-shifts-declared')}</div>`;
613
- const dayGroups = this.weekGroups(shifts);
614
- return html `<section class="shift-coverage" aria-label=${i18next.t('title.shift-coverage')}>
615
- <h3 class="coverage-title">${i18next.t('title.shift-coverage')}</h3>
616
- <div class="coverage-groups">
617
- ${dayGroups.map(group => {
618
- const problems = coverageProblems(group.shifts);
619
- const cycle = cycleOf(group.shifts);
620
- const covered = coveredMinutes(group.shifts);
621
- const label = this.weekLabel(group.days, dayGroups.length === 1);
622
- return html `<div class="coverage-group">
623
- ${dayGroups.length > 1 ? html `<h4>${label}</h4>` : ''}
624
- ${group.shifts.length
625
- ? html `
626
- ${cycle ? this.band(group.shifts, variant) : ''}
627
- ${covered < DAY_MINUTES ? html `<div class="finding">${i18next.t('text.shift-covers-less-than-a-day', { hours: Math.round(covered / 6) / 10 })}</div>` : ''}
628
- ${problems.map(problem => html `<div class="finding" role="status">${this.problemText(problem)}</div>`)}
629
- ${!problems.length && covered >= DAY_MINUTES ? html `<div class="settled">${i18next.t('text.shift-covers-the-whole-day')}</div>` : ''}
630
- `
631
- : html `<div class="empty">${i18next.t('text.no-shifts-on-day')}</div>`}
632
- </div>`;
753
+ stateTag(state) {
754
+ return html `<span class="state" data-state=${state}>${rt(state)}</span>`;
755
+ }
756
+ period(item) {
757
+ if (!item.period)
758
+ return html `<span class="muted">${t('text.from-time', { time: revisionDate(item.row.effectiveFrom) })}</span>`;
759
+ return item.period.to
760
+ ? `${revisionDate(item.period.from)} – ${revisionDate(item.period.to)}`
761
+ : t('text.from-time', { time: revisionDate(item.period.from) });
762
+ }
763
+ /* ── view ──────────────────────────────────────────────────────────── */
764
+ viewCurrent(placed) {
765
+ const current = placed.current;
766
+ const legacy = !current ? this.overview.legacyShifts ?? [] : [];
767
+ return html `<section class="page-section">
768
+ <header class="page-section-header"><h3>${t('title.shift-current')}</h3></header>
769
+ ${current
770
+ ? html `<p class="page-section-note">
771
+ ${t('text.from-time', { time: revisionDate(current.period.from) })} · ${current.row.timezone}
772
+ </p>`
773
+ : nothing}
774
+ ${current?.row.shifts?.length || legacy.length
775
+ ? html `<div class="arrangement">
776
+ ${this.timeline(current?.row.shifts ?? legacy)} ${this.shiftTable(current?.row.shifts ?? legacy)}
777
+ </div>`
778
+ : html `<ox-list-state
779
+ .scale=${{ shown: 0, loaded: 0 }}
780
+ .emptyNote=${{ icon: 'schedule', title: t('text.no-shifts-declared') }}
781
+ ></ox-list-state>`}
782
+ </section>`;
783
+ }
784
+ viewScheduled(placed) {
785
+ if (!placed.scheduled.length)
786
+ return nothing;
787
+ return html `<section class="page-section">
788
+ <header class="page-section-header"><h3>${t('title.shift-announcements')}</h3></header>
789
+ ${placed.scheduled.map(
790
+ /* The bar only: the shifts themselves are listed in the history row right below. */
791
+ item => html `<p class="page-section-note">
792
+ ${t('text.from-time', { time: revisionDate(item.period.from) })}${item.row.reason ? ` · ${item.row.reason}` : ''}
793
+ </p>
794
+ <div class="arrangement">${this.timeline(item.row.shifts ?? [])}</div>`)}
795
+ </section>`;
796
+ }
797
+ /** A person's reason as written; the recorded first arrangement in plain words, with where it came from. */
798
+ reasonCell(row, approval) {
799
+ return html `<td class="reason">
800
+ ${reasonOf(row, t('text.shift-baseline'))}
801
+ ${isBaseline(row) ? html `<div class="muted">${t('text.shift-baseline-why')}</div>` : nothing}
802
+ ${approval?.comment ? html `<div class="muted">${approval.comment}</div>` : nothing}
803
+ </td>`;
804
+ }
805
+ viewOpen(placed) {
806
+ if (!placed.open.length)
807
+ return nothing;
808
+ return html `<section class="page-section">
809
+ <header class="page-section-header"><h3>${t('title.shift-open-changes')}</h3></header>
810
+ <div class="table-scroll">
811
+ <table>
812
+ <thead>
813
+ <tr>
814
+ <th>${rt('state')}</th>
815
+ <th>${rt('effectiveFrom')}</th>
816
+ <th>${t('field.shift')}</th>
817
+ <th>${rt('reason')}</th>
818
+ <th></th>
819
+ </tr>
820
+ </thead>
821
+ <tbody>
822
+ ${placed.open.map(item => {
823
+ const approval = this.approvals[item.row.id];
824
+ const open = this.opened === item.row.id;
825
+ return html `<tr>
826
+ <td>${this.stateTag(item.state)}</td>
827
+ <td class="figure">${revisionDate(item.row.effectiveFrom)}</td>
828
+ <td>${this.shiftLines(item.row.shifts)}</td>
829
+ ${this.reasonCell(item.row, approval)}
830
+ <td class="actions">
831
+ <div>
832
+ ${item.state === 'draft'
833
+ ? html `<button small @click=${() => this.resume(item.row)}>${t('button.continue-editing')}</button>`
834
+ : html `<button small quiet @click=${() => (this.opened = open ? undefined : item.row.id)}>
835
+ ${open ? t('button.close') : rt('changes')}
836
+ </button>`}
837
+ ${approval?.open ? html `<button small @click=${() => approval.open()}>${rt('approval-detail')}</button>` : nothing}
838
+ </div>
839
+ </td>
840
+ </tr>
841
+ ${open
842
+ ? html `<tr class="detail">
843
+ <td colspan="5">
844
+ <ops-master-revision-comparison
845
+ axis="workShift"
846
+ .before=${this.overview.revisions.find((r) => r.id === item.row.baseRevisionId)}
847
+ .after=${item.row}
848
+ ></ops-master-revision-comparison>
849
+ </td>
850
+ </tr>`
851
+ : nothing}`;
633
852
  })}
853
+ </tbody>
854
+ </table>
634
855
  </div>
635
856
  </section>`;
636
857
  }
637
- shiftEditor(row, index) {
638
- const period = (side) => html `<label class="field">
639
- <span>${t(side === 'from' ? 'fromTime' : 'toTime')}</span>
640
- <span class="period">
858
+ viewHistory(placed) {
859
+ return html `<section class="page-section">
860
+ <header class="page-section-header"><h3>${t('title.shift-history')}</h3></header>
861
+ ${placed.history.length
862
+ ? html `<div class="table-scroll">
863
+ <table>
864
+ <thead>
865
+ <tr>
866
+ <th>${t('field.revision-number')}</th>
867
+ <th>${rt('state')}</th>
868
+ <th>${t('field.shift-period')}</th>
869
+ <th>${t('field.shift')}</th>
870
+ <th>${rt('reason')}</th>
871
+ <th></th>
872
+ </tr>
873
+ </thead>
874
+ <tbody>
875
+ ${placed.history.map(item => {
876
+ const approval = this.approvals[item.row.id];
877
+ return html `<tr>
878
+ <td class="figure">${item.row.revisionNumber}</td>
879
+ <td>${this.stateTag(item.state)}</td>
880
+ <td class="figure">${this.period(item)}</td>
881
+ <td>${this.shiftLines(item.row.shifts)}</td>
882
+ ${this.reasonCell(item.row, approval)}
883
+ <td class="actions">
884
+ <div>
885
+ ${approval?.open ? html `<button small quiet @click=${() => approval.open()}>${rt('approval-detail')}</button>` : nothing}
886
+ <button small @click=${() => this.begin(item.row)}>${t('button.change-from-this')}</button>
887
+ </div>
888
+ </td>
889
+ </tr>`;
890
+ })}
891
+ </tbody>
892
+ </table>
893
+ </div>`
894
+ : html `<ox-list-state .scale=${{ shown: 0, loaded: 0 }} .emptyNote=${{ title: t('text.no-shift-history') }}></ox-list-state>`}
895
+ </section>`;
896
+ }
897
+ /* ── editor ────────────────────────────────────────────────────────── */
898
+ issueText(issue) {
899
+ return t('text.shift-issue-' + issue);
900
+ }
901
+ shiftRow(shift, index, issues) {
902
+ const draft = this.draft;
903
+ const nameBad = issues.includes('name-missing') || issues.includes('name-duplicate');
904
+ const timeBad = issues.includes('time-missing') || issues.includes('ends-too-late');
905
+ const length = lengthOf(shift);
906
+ return html `<div class="shift-row">
907
+ <label class="cell name">
908
+ <span class="cell-label">${rt('name')}</span>
909
+ <input
910
+ .value=${shift.name ?? ''}
911
+ aria-label=${rt('name')}
912
+ aria-invalid=${nameBad && (this.tried || !!shift.name) ? 'true' : 'false'}
913
+ ?disabled=${this.busy}
914
+ @input=${(e) => this.changeShift(index, { name: e.target.value })}
915
+ />
916
+ </label>
917
+ <div class="cell">
918
+ <span class="cell-label">${t('field.shift-start')}</span>
919
+ <div class="clock">
641
920
  <select
921
+ aria-label=${rt('fromDate')}
642
922
  ?disabled=${this.busy}
643
- aria-label=${t(side === 'from' ? 'fromDate' : 'toDate')}
644
- .value=${String(row[side + 'Date'] ?? 0)}
645
- @change=${(e) => this.changeShift(index, side + 'Date', Number(e.target.value))}
923
+ @change=${(e) => this.changeShift(index, { fromDate: Number(e.target.value) })}
646
924
  >
647
- ${[-1, 0, 1].map(day => html `<option value=${day}>${t('day-offset-' + day)}</option>`)}
925
+ ${[-1, 0, 1].map(day => html `<option value=${day} ?selected=${Number(shift.fromDate ?? 0) === day}>${this.dayWord(day)}</option>`)}
648
926
  </select>
649
927
  <input
928
+ type="time"
929
+ .value=${String(shift.fromTime ?? '').slice(0, 5)}
930
+ aria-label=${rt('fromTime')}
931
+ aria-invalid=${timeBad && this.tried ? 'true' : 'false'}
650
932
  ?disabled=${this.busy}
651
- aria-label=${t(side === 'from' ? 'fromTime' : 'toTime')}
933
+ @input=${(e) => this.changeShift(index, { fromTime: e.target.value })}
934
+ />
935
+ </div>
936
+ </div>
937
+ <div class="cell">
938
+ <span class="cell-label">${t('field.shift-end')}</span>
939
+ <div class="clock">
940
+ <input
652
941
  type="time"
653
- .value=${row[side + 'Time'] ?? ''}
654
- @input=${(e) => this.changeShift(index, side + 'Time', e.target.value)}
942
+ .value=${String(shift.toTime ?? '').slice(0, 5)}
943
+ aria-label=${rt('toTime')}
944
+ aria-invalid=${timeBad && this.tried ? 'true' : 'false'}
945
+ ?disabled=${this.busy}
946
+ @input=${(e) => this.changeShift(index, { toTime: e.target.value })}
655
947
  />
656
- </span>
657
- </label>`;
658
- return html `<article class="shift-card">
659
- <header class="shift-card-head">
660
- <strong>${row.name || `${i18next.t('field.shift')} ${index + 1}`}</strong>
948
+ <span class="derived"
949
+ >${shift.toDate ? `${this.dayWord(shift.toDate)} · ` : ''}${this.hours(length)}</span
950
+ >
951
+ </div>
952
+ </div>
953
+ <div class="cell days">
954
+ <span class="cell-label">${rt('daysOfWeek')}</span>
955
+ <ox-input-select-buttons
956
+ multiple
957
+ aria-label=${rt('daysOfWeek')}
958
+ .options=${WEEK.map(day => ({ display: rt('weekday-' + day), value: day }))}
959
+ .value=${daysOf(shift.daysOfWeek)}
960
+ ?disabled=${this.busy}
961
+ @change=${(e) => {
962
+ const value = daysValue(e.detail ?? []);
963
+ /* A shift on no day is not a shift: keep the last day chosen. */
964
+ if (value === undefined)
965
+ e.target.value = daysOf(shift.daysOfWeek);
966
+ else
967
+ this.changeShift(index, { daysOfWeek: value });
968
+ }}
969
+ ></ox-input-select-buttons>
970
+ </div>
971
+ <label class="cell note">
972
+ <span class="cell-label">${rt('description')}</span>
973
+ <input
974
+ .value=${shift.description ?? ''}
975
+ aria-label=${rt('description')}
976
+ ?disabled=${this.busy}
977
+ @input=${(e) => this.changeShift(index, { description: e.target.value })}
978
+ />
979
+ </label>
980
+ <div class="cell remove">
661
981
  <button
662
- ?disabled=${this.busy || this.draft.shifts.length === 1}
663
- aria-label=${`${t('remove')} ${row.name || index + 1}`}
664
- @click=${() => this.change('shifts', this.draft.shifts.filter((_, i) => i !== index))}
982
+ small
983
+ quiet
984
+ danger
985
+ ?disabled=${this.busy || draft.shifts.length === 1}
986
+ aria-label=${`${rt('remove')} ${shift.name || index + 1}`}
987
+ @click=${() => this.change({ shifts: draft.shifts.filter((_, i) => i !== index) })}
665
988
  >
666
- ${t('remove')}
989
+ ${rt('remove')}
667
990
  </button>
668
- </header>
669
- <div class="field-grid">
670
- <label class="field"
671
- ><span>${t('name')}</span
672
- ><input
673
- ?disabled=${this.busy}
674
- .value=${row.name ?? ''}
675
- @input=${(e) => this.changeShift(index, 'name', e.target.value)}
676
- /></label>
677
- <label class="field"
678
- ><span>${t('description')}</span
679
- ><input
680
- ?disabled=${this.busy}
681
- .value=${row.description ?? ''}
682
- @input=${(e) => this.changeShift(index, 'description', e.target.value)}
683
- /></label>
684
991
  </div>
685
- <div class="shift-fields">${period('from')}${period('to')}</div>
686
- ${this.weekdays(row, index)}
687
- </article>`;
992
+ ${issues.length && (this.tried || issues.some(issue => issue === 'name-duplicate' || issue === 'ends-too-late'))
993
+ ? html `<p class="row-issues">${issues.map(issue => this.issueText(issue)).join(' · ')}</p>`
994
+ : nothing}
995
+ </div>`;
688
996
  }
689
- render() {
690
- const current = this.current();
691
- const base = this.draft && this.overview?.revisions.find((r) => r.id === this.draft.baseRevisionId);
692
- return html `<div class="page">
693
- <div class="page-actions">
694
- <div class="action-row">
695
- <button ?disabled=${this.busy || this.dirty} @click=${() => this.load()}>${t('refresh')}</button>
696
- <button class="primary" ?disabled=${this.busy || !this.overview || !!this.draft} @click=${() => this.begin()}>
697
- ${t('create-change')}
698
- </button>
997
+ editor() {
998
+ const draft = this.draft;
999
+ const issues = draftIssues(draft);
1000
+ const timezones = Intl.supportedValuesOf?.('timeZone') ?? [];
1001
+ const zones = timezones.includes(draft.timezone) ? timezones : [draft.timezone, ...timezones];
1002
+ return html `
1003
+ ${this.saveError ? html `<p class="alert" role="alert">${this.saveError}</p>` : nothing}
1004
+ <section class="page-section">
1005
+ <header class="page-section-header">
1006
+ <h3>${draft.revisionNumber ? rt('revision-value', { n: draft.revisionNumber }) : t('title.shift-new-arrangement')}</h3>
1007
+ <div class="page-section-actions">
1008
+ <button ?disabled=${this.busy} @click=${() => this.change({ shifts: [...draft.shifts, nextShift(draft.shifts)] })}>
1009
+ ${rt('add-shift')}
1010
+ </button>
1011
+ </div>
1012
+ </header>
1013
+ <div class="shift-rows">
1014
+ <div class="shift-row head" aria-hidden="true">
1015
+ <span>${rt('name')}</span><span>${t('field.shift-start')}</span><span>${t('field.shift-end')}</span
1016
+ ><span>${rt('daysOfWeek')}</span><span>${rt('description')}</span><span></span>
1017
+ </div>
1018
+ ${draft.shifts.map((shift, index) => this.shiftRow(shift, index, issues.rows.get(index) ?? []))}
1019
+ </div>
1020
+ <div class="arrangement">
1021
+ ${this.timeline(draft.shifts, groupOverlaps(issues.overlaps))}
699
1022
  </div>
1023
+ </section>
1024
+
1025
+ <section class="page-section">
1026
+ <header class="page-section-header"><h3>${t('title.shift-apply')}</h3></header>
1027
+ <div class="form-grid">
1028
+ <div class="field">
1029
+ <span>${rt('effectiveFrom')}</span>
1030
+ <div class="when">
1031
+ <ox-input-select-buttons
1032
+ .options=${[
1033
+ { display: t('text.apply-now'), value: 'now' },
1034
+ { display: t('text.apply-later'), value: 'at' }
1035
+ ]}
1036
+ .value=${draft.effectiveMode}
1037
+ ?disabled=${this.busy}
1038
+ @change=${(e) => {
1039
+ if (!e.detail)
1040
+ e.target.value = draft.effectiveMode;
1041
+ else
1042
+ this.change({ effectiveMode: e.detail });
1043
+ }}
1044
+ ></ox-input-select-buttons>
1045
+ ${draft.effectiveMode === 'at'
1046
+ ? html `<input
1047
+ type="datetime-local"
1048
+ aria-label=${rt('effectiveFrom')}
1049
+ .value=${localDateTime(draft.effectiveFrom)}
1050
+ ?disabled=${this.busy}
1051
+ @change=${(e) => {
1052
+ try {
1053
+ this.change({ effectiveFrom: isoFromLocal(e.target.value) });
1054
+ }
1055
+ catch {
1056
+ e.target.value = localDateTime(draft.effectiveFrom);
1057
+ }
1058
+ }}
1059
+ />`
1060
+ : nothing}
1061
+ </div>
1062
+ </div>
1063
+ <label class="field">
1064
+ <span>${rt('reason')}</span>
1065
+ <input
1066
+ .value=${draft.reason}
1067
+ aria-invalid=${issues.reasonMissing && this.tried ? 'true' : 'false'}
1068
+ ?disabled=${this.busy}
1069
+ @input=${(e) => this.change({ reason: e.target.value })}
1070
+ />
1071
+ ${issues.reasonMissing && this.tried ? html `<span class="error">${t('text.shift-issue-reason-missing')}</span>` : nothing}
1072
+ </label>
1073
+ <label class="field">
1074
+ <span>${rt('timezone')}</span>
1075
+ <select ?disabled=${this.busy} @change=${(e) => this.change({ timezone: e.target.value })}>
1076
+ ${zones.map(zone => html `<option value=${zone} ?selected=${zone === draft.timezone}>${zone}</option>`)}
1077
+ </select>
1078
+ </label>
1079
+ <label class="field">
1080
+ <span>${rt('description')}</span>
1081
+ <input .value=${draft.description ?? ''} ?disabled=${this.busy} @input=${(e) => this.change({ description: e.target.value })} />
1082
+ </label>
1083
+ </div>
1084
+ </section>
1085
+ `;
1086
+ }
1087
+ render() {
1088
+ const placed = this.overview ? this.placed : undefined;
1089
+ return html `<ox-page-state .loading=${this.loading && !this.overview} .error=${this.error} @retry=${() => this.load()}>
1090
+ <div class="page">
1091
+ ${placed
1092
+ ? this.draft
1093
+ ? this.editor()
1094
+ : html `${this.viewCurrent(placed)} ${this.viewScheduled(placed)} ${this.viewOpen(placed)} ${this.viewHistory(placed)}`
1095
+ : nothing}
700
1096
  </div>
701
- ${this.message ? html `<p class="finding" role="alert">${this.message}</p>` : ''}
702
- ${this.overview
703
- ? html `<section class="page-section current-section">
704
- <header class="page-section-header">
705
- <h2>${t('current')}</h2>
706
- ${current ? html `<span class="status" data-state="current">${t('revision-value', { n: current.revisionNumber })}</span>` : ''}
707
- </header>
708
- ${current
709
- ? html `<p class="history-period">
710
- ${revisionDate(this.overview.schedule.find((s) => s.state === 'current').validFrom)}
711
- </p>
712
- ${this.coverage(current.shifts ?? [], 'current')}${revisionContents(current, 'workShift')}`
713
- : html `<div class="empty">${t('no-current')}</div>`}
714
- </section>`
715
- : ''}
716
- ${this.draft
717
- ? html `<section class="page-section">
718
- <header class="page-section-header">
719
- <h2>${this.draft.id ? t('revision-value', { n: this.draft.revisionNumber }) : t('new')}</h2>
720
- <span class="status" data-state=${this.rowStatus(this.draft)}
721
- >${t(this.draft.id ? this.rowStatus(this.draft) : 'draft')}</span
722
- >
723
- </header>
724
- ${this.draft.frozenAt
725
- ? html `${this.coverage(this.draft.shifts ?? [], 'scheduled')}${revisionContents(this.draft, 'workShift')}`
726
- : html `
727
- <div class="editing">
728
- <div class="field-grid">
729
- ${['description', 'timezone', 'reason'].map(field => html `<label class="field"
730
- ><span>${t(field)}</span>
731
- <input
732
- ?disabled=${this.busy}
733
- aria-label=${t(field)}
734
- .value=${this.draft[field] ?? ''}
735
- @input=${(e) => this.change(field, e.target.value)}
736
- /></label>`)}
737
- <label class="field"
738
- ><span>${t('effectiveFrom')} · ${Intl.DateTimeFormat().resolvedOptions().timeZone}</span>
739
- <input
740
- ?disabled=${this.busy}
741
- aria-label=${t('effectiveFrom')}
742
- type="datetime-local"
743
- .value=${localDateTime(this.draft.effectiveFrom)}
744
- @change=${(e) => {
745
- try {
746
- this.change('effectiveFrom', isoFromLocal(e.target.value));
747
- }
748
- catch (err) {
749
- this.message = err.message;
750
- }
751
- }}
752
- /></label>
753
- </div>
754
- <p class="field-label">
755
- ${i18next.t('text.effective-timezone-note', { timezone: this.draft.timezone, browserTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone })}
756
- </p>
757
- <ul class="guidance">
758
- <li>${i18next.t('text.howto-crossing-midnight')}</li>
759
- <li>${i18next.t('text.howto-scheduled-applies-itself')}</li>
760
- </ul>
761
- <div class="shift-list">
762
- ${this.draft.shifts.map((row, index) => this.shiftEditor(row, index))}
763
- </div>
764
- <div class="action-row">
765
- <button
766
- ?disabled=${this.busy}
767
- @click=${() => this.change('shifts', [...this.draft.shifts, { name: '', fromDate: 0, fromTime: '16:00', toDate: 1, toTime: '00:00', daysOfWeek: '' }])}
768
- >
769
- ${t('add-shift')}
770
- </button>
771
- </div>
772
- ${this.coverage(this.draft.shifts, 'current')}
773
- <p class="notice">${t('shift-impact')}</p>
774
- <div class="action-row">
775
- <button ?disabled=${this.busy} @click=${() => this.save()}>${t('save-draft')}</button>
776
- <button
777
- class="primary"
778
- ?disabled=${this.busy || (this.overview.approvalRequired && (this.dirty || !this.draft.id))}
779
- @click=${() => this.submit()}
780
- >
781
- ${this.overview.approvalRequired ? t('request-approval') : i18next.t('button.save')}
782
- </button>
783
- <button
784
- ?disabled=${this.busy}
785
- @click=${() => {
786
- if (!this.dirty || window.confirm(t('discard-changes'))) {
787
- this.draft = undefined;
788
- this.dirty = false;
789
- }
790
- }}
791
- >
792
- ${t('close-editor')}
793
- </button>
794
- </div>
795
- ${this.overview.approvalRequired && (this.dirty || !this.draft.id) ? html `<p class="field-label">${i18next.t('text.save-before-approval')}</p>` : ''}
796
- </div>
797
- `}
798
- ${this.draft.id ? html `<ops-master-revision-comparison axis="workShift" .before=${base} .after=${this.draft}></ops-master-revision-comparison>` : ''}
799
- </section>`
800
- : ''}
801
- ${this.overview
802
- ? html `<section class="page-section">
803
- <header class="page-section-header"><h2>${t('history')}</h2></header>
804
- ${this.overview.revisions.length
805
- ? html `<div class="history-list">
806
- ${[...this.overview.revisions].reverse().map((row) => {
807
- const period = this.overview.schedule.find((s) => s.revisionId === row.id);
808
- const status = this.statuses[row.id];
809
- const state = this.rowStatus(row);
810
- const variant = state === 'current' ? 'current' : state === 'scheduled' ? 'scheduled' : 'past';
811
- return html `<article class="history-item">
812
- <div class="history-head">
813
- <button
814
- ?disabled=${this.dirty}
815
- @click=${() => {
816
- this.draft = structuredClone(row);
817
- this.dirty = false;
818
- }}
819
- >
820
- ${t('revision-value', { n: row.revisionNumber })}
821
- </button>
822
- <span class="status" data-state=${state}>${t(state)}</span>
823
- <span class="history-period"
824
- >${period ? html `${revisionDate(period.validFrom)} → ${period.validTo ? revisionDate(period.validTo) : t('until-change')}` : html `${t('effectiveFrom')}: ${revisionDate(row.effectiveFrom)}`}</span
825
- >
826
- <div class="history-actions">
827
- ${status?.open ? html `<button ?disabled=${this.busy} @click=${() => status.open()}>${t('approval-detail')}</button>` : ''}
828
- <button ?disabled=${this.busy || this.dirty} @click=${() => this.begin(row)}>
829
- ${i18next.t('text.create-change-from-revision')}
830
- </button>
831
- </div>
832
- </div>
833
- ${row.shifts?.length ? this.historyBands(row.shifts, variant) : ''}
834
- ${status?.comment ? html `<p>${status.comment}</p>` : ''}
835
- <details class="history-detail">
836
- <summary>${i18next.t('text.view-revision-content')}</summary>
837
- ${revisionContents(row, 'workShift')}
838
- </details>
839
- </article>`;
840
- })}
841
- </div>`
842
- : html `<div class="empty">${i18next.t('text.no-shift-history')}</div>`}
843
- </section>`
844
- : ''}
845
- </div>`;
1097
+ </ox-page-state>`;
846
1098
  }
847
1099
  };
848
1100
  __decorate([
849
1101
  state(),
850
1102
  __metadata("design:type", Object)
851
1103
  ], WorkShift.prototype, "overview", void 0);
1104
+ __decorate([
1105
+ state(),
1106
+ __metadata("design:type", Object)
1107
+ ], WorkShift.prototype, "loading", void 0);
1108
+ __decorate([
1109
+ state(),
1110
+ __metadata("design:type", Object)
1111
+ ], WorkShift.prototype, "error", void 0);
1112
+ __decorate([
1113
+ state(),
1114
+ __metadata("design:type", Object)
1115
+ ], WorkShift.prototype, "approvals", void 0);
852
1116
  __decorate([
853
1117
  state(),
854
1118
  __metadata("design:type", Object)
@@ -856,7 +1120,7 @@ __decorate([
856
1120
  __decorate([
857
1121
  state(),
858
1122
  __metadata("design:type", Object)
859
- ], WorkShift.prototype, "message", void 0);
1123
+ ], WorkShift.prototype, "dirty", void 0);
860
1124
  __decorate([
861
1125
  state(),
862
1126
  __metadata("design:type", Object)
@@ -864,7 +1128,15 @@ __decorate([
864
1128
  __decorate([
865
1129
  state(),
866
1130
  __metadata("design:type", Object)
867
- ], WorkShift.prototype, "statuses", void 0);
1131
+ ], WorkShift.prototype, "saveError", void 0);
1132
+ __decorate([
1133
+ state(),
1134
+ __metadata("design:type", Object)
1135
+ ], WorkShift.prototype, "tried", void 0);
1136
+ __decorate([
1137
+ state(),
1138
+ __metadata("design:type", String)
1139
+ ], WorkShift.prototype, "opened", void 0);
868
1140
  WorkShift = __decorate([
869
1141
  customElement('work-shift')
870
1142
  ], WorkShift);