@things-factory/work-shift 10.1.155 → 10.1.157

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.
@@ -5,9 +5,11 @@ import { customElement, state } from 'lit/decorators.js';
5
5
  import { client } from '@operato/graphql';
6
6
  import { i18next } from '@operato/i18n';
7
7
  import { PageView } from '@operato/shell';
8
- import { DocumentPageStyles, ScrollbarStyles } from '@operato/styles';
8
+ import { CommonHeaderStyles, DocumentPageStyles, ScrollbarStyles } from '@operato/styles';
9
9
  import { revisionText as t, revisionDate, revisionContents, revisionStyles } from '@things-factory/ops-master/dist-client/pages/master/revision-comparison.js';
10
10
  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';
12
+ const DAY_MINUTES = 24 * 60;
11
13
  let WorkShift = class WorkShift extends PageView {
12
14
  constructor() {
13
15
  super(...arguments);
@@ -16,23 +18,351 @@ let WorkShift = class WorkShift extends PageView {
16
18
  this.statuses = {};
17
19
  this.dirty = false;
18
20
  }
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(); }
21
+ static { this.styles = [
22
+ DocumentPageStyles,
23
+ ScrollbarStyles,
24
+ CommonHeaderStyles,
25
+ revisionStyles,
26
+ css `
27
+ .page {
28
+ display: grid;
29
+ gap: var(--spacing-large);
30
+ min-width: 0;
31
+ }
32
+ .page-actions,
33
+ .action-row,
34
+ .history-head {
35
+ display: flex;
36
+ align-items: center;
37
+ flex-wrap: wrap;
38
+ gap: var(--spacing-small);
39
+ }
40
+ .page-actions {
41
+ justify-content: space-between;
42
+ }
43
+ .page-section {
44
+ min-width: 0;
45
+ }
46
+ .page-section-header {
47
+ display: flex;
48
+ align-items: center;
49
+ justify-content: space-between;
50
+ gap: var(--spacing-medium);
51
+ }
52
+ .page-section-header h2,
53
+ .page-section-header h3 {
54
+ margin: 0;
55
+ }
56
+ .current-section {
57
+ border-inline-start: 3px solid var(--ui-accent);
58
+ }
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));
66
+ }
67
+ .field-grid {
68
+ display: grid;
69
+ grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
70
+ gap: var(--spacing-medium);
71
+ }
72
+ .field {
73
+ display: grid;
74
+ align-content: start;
75
+ gap: var(--spacing-small);
76
+ min-width: 0;
77
+ }
78
+ .field > span,
79
+ .field-label {
80
+ display: block;
81
+ color: var(--ui-muted);
82
+ font-size: var(--ui-t-small);
83
+ }
84
+ input,
85
+ select,
86
+ textarea {
87
+ box-sizing: border-box;
88
+ 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;
96
+ }
97
+ input[type='time'] {
98
+ font-variant-numeric: tabular-nums;
99
+ }
100
+ .shift-list {
101
+ display: grid;
102
+ gap: var(--spacing-small);
103
+ }
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);
110
+ }
111
+ .shift-card-head {
112
+ display: flex;
113
+ align-items: center;
114
+ justify-content: space-between;
115
+ gap: var(--spacing-small);
116
+ margin-bottom: var(--spacing-medium);
117
+ }
118
+ .shift-card-head strong {
119
+ font-size: var(--ui-t-title);
120
+ }
121
+ .shift-fields {
122
+ display: grid;
123
+ grid-template-columns: repeat(2, minmax(0, 1fr));
124
+ gap: var(--spacing-medium);
125
+ }
126
+ .period {
127
+ display: grid;
128
+ grid-template-columns: minmax(88px, 0.8fr) minmax(110px, 1fr);
129
+ align-items: end;
130
+ gap: var(--spacing-small);
131
+ }
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);
137
+ }
138
+ .weekdays legend {
139
+ padding: 0 var(--spacing-tiny);
140
+ color: var(--ui-muted);
141
+ font-size: var(--ui-t-small);
142
+ }
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);
152
+ white-space: nowrap;
153
+ }
154
+ .week-options input {
155
+ width: 18px;
156
+ min-height: 18px;
157
+ margin: 0;
158
+ accent-color: var(--ui-accent);
159
+ }
160
+ .every-day {
161
+ margin-top: var(--spacing-small);
162
+ }
163
+ .shift-coverage {
164
+ display: grid;
165
+ gap: var(--spacing-small);
166
+ }
167
+ .coverage-title {
168
+ margin: 0;
169
+ font-size: var(--ui-t-title);
170
+ }
171
+ .coverage-groups {
172
+ display: grid;
173
+ gap: var(--spacing-medium);
174
+ }
175
+ .history-bands,
176
+ .history-band-group {
177
+ display: grid;
178
+ gap: var(--spacing-small);
179
+ }
180
+ .coverage-group {
181
+ display: grid;
182
+ gap: var(--spacing-small);
183
+ 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
+ }
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;
206
+ }
207
+ .piece {
208
+ position: absolute;
209
+ inset-block: 0;
210
+ display: flex;
211
+ 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;
229
+ }
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
+ );
236
+ }
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);
241
+ }
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);
250
+ 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;
261
+ }
262
+ .settled {
263
+ border-color: var(--ui-good);
264
+ color: var(--ui-muted);
265
+ }
266
+ .notice {
267
+ border-color: var(--ui-info);
268
+ }
269
+ .guidance {
270
+ margin: 0;
271
+ padding-inline-start: 1.25rem;
272
+ color: var(--ui-muted);
273
+ line-height: 1.6;
274
+ }
275
+ .guidance li + li {
276
+ margin-top: var(--spacing-small);
277
+ }
278
+ .empty {
279
+ border-color: var(--ui-line);
280
+ color: var(--ui-muted);
281
+ }
282
+ .history-list {
283
+ display: grid;
284
+ gap: var(--spacing-medium);
285
+ }
286
+ .history-item {
287
+ display: grid;
288
+ gap: var(--spacing-small);
289
+ min-width: 0;
290
+ padding-block: var(--spacing-small);
291
+ border-bottom: 1px solid var(--ui-line);
292
+ }
293
+ .history-head {
294
+ justify-content: flex-start;
295
+ }
296
+ .history-period {
297
+ color: var(--ui-muted);
298
+ font-variant-numeric: tabular-nums;
299
+ }
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));
322
+ }
323
+ .status[data-state='rejected'] {
324
+ background: color-mix(in srgb, var(--ui-warn) 18%, var(--ui-panel));
325
+ }
326
+ .history-detail {
327
+ margin-top: var(--spacing-small);
328
+ }
329
+ button {
330
+ min-height: var(--form-action-height, 40px);
331
+ border-radius: var(--ui-r-small, 6px);
332
+ }
333
+ button:focus-visible,
334
+ input:focus-visible,
335
+ select:focus-visible,
336
+ summary:focus-visible {
337
+ outline-color: var(--ui-accent);
338
+ }
339
+ @media (max-width: 640px) {
340
+ .shift-fields {
341
+ grid-template-columns: minmax(0, 1fr);
342
+ }
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);
355
+ }
356
+ }
357
+ `
358
+ ]; }
359
+ 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
+ }
36
366
  current() {
37
367
  const segment = this.overview?.schedule.find((s) => s.state === 'current');
38
368
  return segment && this.overview.revisions.find((r) => r.id === segment.revisionId);
@@ -52,14 +382,23 @@ let WorkShift = class WorkShift extends PageView {
52
382
  }
53
383
  async load() {
54
384
  await this.run(async () => {
55
- const result = await client.query({ query: gql `query { workShiftRevisionOverview }`, fetchPolicy: 'no-cache' });
385
+ const result = await client.query({
386
+ query: gql `
387
+ query {
388
+ workShiftRevisionOverview
389
+ }
390
+ `,
391
+ fetchPolicy: 'no-cache'
392
+ });
56
393
  if (result.errors?.length)
57
394
  throw new Error(result.errors[0].message);
58
395
  this.overview = result.data.workShiftRevisionOverview;
59
396
  const statuses = {};
60
397
  for (const row of this.overview.revisions.filter((r) => r.activityInstanceId)) {
61
398
  let promise;
62
- document.dispatchEvent(new CustomEvent('master-change-status', { detail: { activityInstanceId: row.activityInstanceId, respondWith: (p) => promise = p } }));
399
+ document.dispatchEvent(new CustomEvent('master-change-status', {
400
+ detail: { activityInstanceId: row.activityInstanceId, respondWith: (p) => (promise = p) }
401
+ }));
63
402
  if (promise)
64
403
  statuses[row.id] = await promise;
65
404
  }
@@ -67,27 +406,47 @@ let WorkShift = class WorkShift extends PageView {
67
406
  });
68
407
  }
69
408
  begin(row = this.current()) {
70
- this.draft = { description: row?.description ?? '', timezone: row?.timezone ?? this.overview.timezone,
71
- /* 편성이 아직 없으면 옛 교대로 시작한다 — 운영자가 그것을 보고 고친다(2026-09-24). */
72
- shifts: structuredClone(row?.shifts ?? (this.overview.legacyShifts?.length
73
- ? this.overview.legacyShifts
74
- : [{ name: '', fromDate: 0, fromTime: '08:00', toDate: 0, toTime: '16:00', daysOfWeek: '' }])),
75
- effectiveFrom: new Date().toISOString(), reason: '' };
409
+ 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: '' }])),
416
+ effectiveFrom: new Date().toISOString(),
417
+ reason: ''
418
+ };
76
419
  this.dirty = false;
77
420
  }
78
- change(field, value) { this.draft = { ...this.draft, [field]: value }; this.dirty = true; }
421
+ change(field, value) {
422
+ this.draft = { ...this.draft, [field]: value };
423
+ this.dirty = true;
424
+ }
79
425
  changeShift(index, field, value) {
80
- this.change('shifts', this.draft.shifts.map((s, i) => i === index ? { ...s, [field]: value } : s));
426
+ this.change('shifts', this.draft.shifts.map((s, i) => (i === index ? { ...s, [field]: value } : s)));
81
427
  }
82
428
  async save() {
83
429
  await this.run(async () => {
84
430
  const { description, timezone, shifts, effectiveFrom, reason } = this.draft;
85
431
  const content = { description, timezone, shifts, effectiveFrom, reason };
86
432
  const editing = !!this.draft.id;
87
- const response = await client.mutate({ mutation: editing
88
- ? gql `mutation($revisionId:String!,$editGeneration:Int!,$content:Object!){editWorkShiftDraft(revisionId:$revisionId,editGeneration:$editGeneration,content:$content)}`
89
- : gql `mutation($content:Object!){createWorkShiftDraft(content:$content)}`,
90
- variables: { content, ...(editing ? { revisionId: this.draft.id, editGeneration: this.draft.editGeneration } : {}) } });
433
+ const response = await client.mutate({
434
+ mutation: editing
435
+ ? gql `
436
+ mutation ($revisionId: String!, $editGeneration: Int!, $content: Object!) {
437
+ editWorkShiftDraft(revisionId: $revisionId, editGeneration: $editGeneration, content: $content)
438
+ }
439
+ `
440
+ : gql `
441
+ mutation ($content: Object!) {
442
+ createWorkShiftDraft(content: $content)
443
+ }
444
+ `,
445
+ variables: {
446
+ content,
447
+ ...(editing ? { revisionId: this.draft.id, editGeneration: this.draft.editGeneration } : {})
448
+ }
449
+ });
91
450
  if (response.errors?.length)
92
451
  throw new Error(response.errors[0].message);
93
452
  this.draft = response.data[editing ? 'editWorkShiftDraft' : 'createWorkShiftDraft'];
@@ -100,19 +459,32 @@ let WorkShift = class WorkShift extends PageView {
100
459
  await this.run(async () => {
101
460
  if (this.overview.approvalRequired) {
102
461
  let promise;
103
- document.dispatchEvent(new CustomEvent('master-change-approval', { detail: {
104
- axis: 'workShift', revisionId: this.draft.id, editGeneration: this.draft.editGeneration,
105
- respondWith: (p) => promise = p
106
- } }));
462
+ document.dispatchEvent(new CustomEvent('master-change-approval', {
463
+ detail: {
464
+ axis: 'workShift',
465
+ revisionId: this.draft.id,
466
+ editGeneration: this.draft.editGeneration,
467
+ respondWith: (p) => (promise = p)
468
+ }
469
+ }));
107
470
  if (!promise)
108
471
  throw new Error(t('approval-unavailable'));
109
472
  await promise;
110
473
  }
111
474
  else {
112
475
  const { description, timezone, shifts, effectiveFrom, reason } = this.draft;
113
- const result = await client.mutate({ mutation: gql `mutation($revisionId:String,$editGeneration:Int,$content:Object!){saveAndPublishWorkShift(revisionId:$revisionId,editGeneration:$editGeneration,content:$content)}`,
114
- variables: { revisionId: this.draft.id, editGeneration: this.draft.editGeneration,
115
- content: { description, timezone, shifts, effectiveFrom, reason } } });
476
+ const result = await client.mutate({
477
+ mutation: gql `
478
+ mutation ($revisionId: String, $editGeneration: Int, $content: Object!) {
479
+ saveAndPublishWorkShift(revisionId: $revisionId, editGeneration: $editGeneration, content: $content)
480
+ }
481
+ `,
482
+ variables: {
483
+ revisionId: this.draft.id,
484
+ editGeneration: this.draft.editGeneration,
485
+ content: { description, timezone, shifts, effectiveFrom, reason }
486
+ }
487
+ });
116
488
  if (result.errors?.length)
117
489
  throw new Error(result.errors[0].message);
118
490
  }
@@ -128,61 +500,348 @@ let WorkShift = class WorkShift extends PageView {
128
500
  }
129
501
  weekdays(row, index) {
130
502
  const days = row.daysOfWeek ? row.daysOfWeek.split(',') : ['0', '1', '2', '3', '4', '5', '6'];
131
- return html `<div class="week">${['1', '2', '3', '4', '5', '6', '0'].map(day => html `<label><input type="checkbox" .checked=${days.includes(day)}
132
- @change=${(e) => {
133
- const next = e.target.checked ? [...days, day] : days.filter(d => d !== day);
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);
134
513
  if (!next.length) {
135
514
  e.target.checked = true;
136
515
  return;
137
516
  }
138
517
  this.changeShift(index, 'daysOfWeek', next.length === 7 ? '' : [...new Set(next)].sort().join(','));
139
- }} />${t('weekday-' + day)}</label>`)}</div>`;
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)
539
+ return '';
540
+ const overlaps = new Set(coverageProblems(shifts)
541
+ .filter(problem => problem.kind === 'overlap')
542
+ .flatMap(problem => problem.names));
543
+ 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
+ })}
568
+ </div>
569
+ <div class="ticks">
570
+ ${[0, 6, 12, 18, 24].map(hour => html `<span>${this.timeLabel(cycle.startMinute + hour * 60)}</span>`)}
571
+ </div>
572
+ </div>`;
573
+ }
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()];
597
+ }
598
+ weekLabel(days, showEveryDay) {
599
+ return showEveryDay && days.length === 7 ? t('every-day') : days.map(day => t('weekday-' + day)).join(' · ');
600
+ }
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>`}
607
+ </div>`)}
608
+ </div>`;
609
+ }
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>`;
633
+ })}
634
+ </div>
635
+ </section>`;
636
+ }
637
+ shiftEditor(row, index) {
638
+ const period = (side) => html `<label class="field">
639
+ <span>${t(side === 'from' ? 'fromTime' : 'toTime')}</span>
640
+ <span class="period">
641
+ <select
642
+ ?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))}
646
+ >
647
+ ${[-1, 0, 1].map(day => html `<option value=${day}>${t('day-offset-' + day)}</option>`)}
648
+ </select>
649
+ <input
650
+ ?disabled=${this.busy}
651
+ aria-label=${t(side === 'from' ? 'fromTime' : 'toTime')}
652
+ type="time"
653
+ .value=${row[side + 'Time'] ?? ''}
654
+ @input=${(e) => this.changeShift(index, side + 'Time', e.target.value)}
655
+ />
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>
661
+ <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))}
665
+ >
666
+ ${t('remove')}
667
+ </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
+ </div>
685
+ <div class="shift-fields">${period('from')}${period('to')}</div>
686
+ ${this.weekdays(row, index)}
687
+ </article>`;
140
688
  }
141
689
  render() {
142
- const current = this.current(), base = this.draft && this.overview?.revisions.find((r) => r.id === this.draft.baseRevisionId);
690
+ const current = this.current();
691
+ const base = this.draft && this.overview?.revisions.find((r) => r.id === this.draft.baseRevisionId);
143
692
  return html `<div class="page">
144
- <div class="bar"><button ?disabled=${this.busy || this.dirty} @click=${() => this.load()}>${t('refresh')}</button>
145
- <button ?disabled=${this.busy || !this.overview || !!this.draft} @click=${() => this.begin()}>${t('create-change')}</button></div>
146
- ${this.message ? html `<p role="alert">${this.message}</p>` : ''}
147
- <section class="current"><h2>${t('current')}</h2>
148
- ${current ? html `<p>${t('revision-value', { n: current.revisionNumber })} · ${revisionDate(this.overview.schedule.find((s) => s.state === 'current').validFrom)}</p>${revisionContents(current, 'workShift')}`
149
- : html `<p>${t('no-current')}</p>`}
150
- </section>
151
- ${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>
152
- ${this.draft.frozenAt ? revisionContents(this.draft, 'workShift') : html `
153
- <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>`)}
154
- <label>${t('effectiveFrom')} · ${Intl.DateTimeFormat().resolvedOptions().timeZone}<input aria-label="effectiveFrom" type="datetime-local" .value=${localDateTime(this.draft.effectiveFrom)} @change=${(e) => { try {
155
- this.change('effectiveFrom', isoFromLocal(e.target.value));
156
- }
157
- catch (err) {
158
- this.message = err.message;
159
- } }} /></label>
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>
160
699
  </div>
161
- <div class="scroll"><table><thead><tr>${['name', 'fromDate', 'fromTime', 'toDate', 'toTime', 'daysOfWeek'].map(f => html `<th>${t(f)}</th>`)}<th></th></tr></thead><tbody>
162
- ${this.draft.shifts.map((row, index) => html `<tr>
163
- <td><input aria-label=${'name-' + index} .value=${row.name} @input=${(e) => this.changeShift(index, 'name', e.target.value)} />
164
- <label>${t('description')}<input aria-label=${'description-' + index} .value=${row.description ?? ''} @input=${(e) => this.changeShift(index, 'description', e.target.value)} /></label></td>
165
- ${['from', 'to'].map(side => html `<td><select aria-label=${side + 'Date-' + index} @change=${(e) => this.changeShift(index, side + 'Date', Number(e.target.value))}>${[-1, 0, 1].map(d => html `<option value=${d} .selected=${d === row[side + 'Date']}>${t('day-offset-' + d)}</option>`)}</select></td>
166
- <td><input aria-label=${side + 'Time-' + index} type="time" .value=${row[side + 'Time']} @input=${(e) => this.changeShift(index, side + 'Time', e.target.value)} /></td>`)}
167
- <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>
168
- </tr>`)}
169
- </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>
170
- <p class="notice">${t('shift-impact')}</p>
171
- <div class="bar"><button ?disabled=${this.busy} @click=${() => this.save()}>${t('save-draft')}</button>
172
- <button class="primary" ?disabled=${this.busy || (this.overview.approvalRequired && (this.dirty || !this.draft.id))} @click=${() => this.submit()}>${this.overview.approvalRequired ? t('request-approval') : i18next.t('button.save')}</button></div>`}
173
- <button ?disabled=${this.busy} @click=${() => { if (!this.dirty || window.confirm(t('discard-changes'))) {
174
- this.draft = undefined;
175
- this.dirty = false;
176
- } }}>${t('close-editor')}</button>
177
- ${this.draft.id ? html `<ops-master-revision-comparison axis="workShift" .before=${base} .after=${this.draft}></ops-master-revision-comparison>` : ''}
178
- </section>` : ''}
179
- ${this.overview ? html `<section><h2>${t('history')}</h2>${[...this.overview.revisions].reverse().map((row) => {
180
- const period = this.overview.schedule.find((s) => s.revisionId === row.id), status = this.statuses[row.id];
181
- 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>
182
- <span>${period ? html `${revisionDate(period.validFrom)} → ${period.validTo ? revisionDate(period.validTo) : t('until-change')}` : html `${t('effectiveFrom')}: ${revisionDate(row.effectiveFrom)}`}</span>
183
- ${status?.comment ? html `<span>${status.comment}</span>` : ''}${status?.open ? html `<button @click=${() => status.open()}>${t('approval-detail')}</button>` : ''}
184
- <button ?disabled=${this.dirty} @click=${() => this.begin(row)}>${t('create-change')}</button></div>`;
185
- })}</section>` : ''}
700
+ </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
+ : ''}
186
845
  </div>`;
187
846
  }
188
847
  };