@things-factory/organization 10.1.226 → 10.1.228

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.
Files changed (55) hide show
  1. package/dist-client/pages/employee-profile/employee-profile-files.d.ts +23 -0
  2. package/dist-client/pages/employee-profile/employee-profile-files.js +50 -0
  3. package/dist-client/pages/employee-profile/employee-profile-files.js.map +1 -0
  4. package/dist-client/pages/employee-profile/employee-profile-form.d.ts +42 -10
  5. package/dist-client/pages/employee-profile/employee-profile-form.js +68 -22
  6. package/dist-client/pages/employee-profile/employee-profile-form.js.map +1 -1
  7. package/dist-client/pages/employee-profile/employee-profile-list-page.d.ts +19 -5
  8. package/dist-client/pages/employee-profile/employee-profile-list-page.js +89 -46
  9. package/dist-client/pages/employee-profile/employee-profile-list-page.js.map +1 -1
  10. package/dist-client/pages/employee-profile/employee-profile-preview.d.ts +28 -0
  11. package/dist-client/pages/employee-profile/employee-profile-preview.js +179 -0
  12. package/dist-client/pages/employee-profile/employee-profile-preview.js.map +1 -0
  13. package/dist-client/pages/employee-profile/my-employee-profile-page.d.ts +20 -2
  14. package/dist-client/pages/employee-profile/my-employee-profile-page.js +467 -93
  15. package/dist-client/pages/employee-profile/my-employee-profile-page.js.map +1 -1
  16. package/dist-client/tsconfig.tsbuildinfo +1 -1
  17. package/dist-server/ai-entry/employee-profile-entry.d.ts +10 -0
  18. package/dist-server/ai-entry/employee-profile-entry.js +88 -0
  19. package/dist-server/ai-entry/employee-profile-entry.js.map +1 -0
  20. package/dist-server/ai-entry/entry-target-shape.d.ts +11 -1
  21. package/dist-server/ai-entry/entry-target-shape.js.map +1 -1
  22. package/dist-server/ai-entry/organization-entry-targets.d.ts +8 -0
  23. package/dist-server/ai-entry/organization-entry-targets.js +26 -1
  24. package/dist-server/ai-entry/organization-entry-targets.js.map +1 -1
  25. package/dist-server/service/employee-profile/employee-profile-document.d.ts +122 -0
  26. package/dist-server/service/employee-profile/employee-profile-document.js +439 -0
  27. package/dist-server/service/employee-profile/employee-profile-document.js.map +1 -0
  28. package/dist-server/service/employee-profile/employee-profile-mutation.d.ts +1 -0
  29. package/dist-server/service/employee-profile/employee-profile-mutation.js +29 -0
  30. package/dist-server/service/employee-profile/employee-profile-mutation.js.map +1 -1
  31. package/dist-server/service/employee-profile/employee-profile-query.d.ts +9 -1
  32. package/dist-server/service/employee-profile/employee-profile-query.js +83 -4
  33. package/dist-server/service/employee-profile/employee-profile-query.js.map +1 -1
  34. package/dist-server/service/employee-profile/employee-profile-rules.d.ts +6 -0
  35. package/dist-server/service/employee-profile/employee-profile-rules.js +24 -0
  36. package/dist-server/service/employee-profile/employee-profile-rules.js.map +1 -1
  37. package/dist-server/service/employee-profile/employee-profile-type.d.ts +11 -0
  38. package/dist-server/service/employee-profile/employee-profile-type.js +30 -1
  39. package/dist-server/service/employee-profile/employee-profile-type.js.map +1 -1
  40. package/dist-server/tsconfig.tsbuildinfo +1 -1
  41. package/package.json +8 -7
  42. package/tests/employee-profile-db.test.ts +65 -4
  43. package/tests/employee-profile-document.test.ts +219 -0
  44. package/tests/employee-profile-entry.test.ts +72 -0
  45. package/tests/employee-profile-rules.test.ts +33 -6
  46. package/translations/en.json +43 -3
  47. package/translations/es.json +43 -3
  48. package/translations/ja.json +43 -3
  49. package/translations/ko.json +43 -3
  50. package/translations/ms.json +43 -3
  51. package/translations/zh.json +43 -3
  52. package/dist-client/pages/employee-profile/employee-profile-workbook.d.ts +0 -33
  53. package/dist-client/pages/employee-profile/employee-profile-workbook.js +0 -87
  54. package/dist-client/pages/employee-profile/employee-profile-workbook.js.map +0 -1
  55. package/tests/employee-profile-workbook.test.ts +0 -89
@@ -10,7 +10,10 @@ import { i18next, localize } from '@operato/i18n';
10
10
  import { notify } from '@operato/layout';
11
11
  import { PageView } from '@operato/shell';
12
12
  import { ButtonStyles, DocumentPageStyles, FormFieldStyles } from '@operato/styles';
13
- import { PROFILE_SECTIONS, profileFormOf, profileInputOf } from './employee-profile-form';
13
+ import { getLanguages } from '@things-factory/auth-base/dist-client';
14
+ import { addSkills, blankRow, completenessOf, headlineOf, periodLabelOf, PROFILE_SECTIONS, profileFormOf, profileInputOf } from './employee-profile-form';
15
+ import { readerLanguageOf } from './employee-profile-files';
16
+ import { openProfilePreview } from './employee-profile-preview';
14
17
  const PROFILE_FIELDS = `
15
18
  id
16
19
  summary
@@ -21,20 +24,30 @@ const PROFILE_FIELDS = `
21
24
  projects { name client startedOn endedOn role duties technologies }
22
25
  updatedAt
23
26
  `;
27
+ const SUMMARY_LIMIT = 500;
24
28
  /**
25
29
  * 내 프로필 — the employee writes their own career profile: a summary, skills, and the rows of education,
26
30
  * certificates, past jobs and projects; sales attaches it to quotations (ADR-0169 decisions 2 · 5). Saved whole.
27
- * An account not linked to an employee record has no profile to write, and is told so before anything is typed.
31
+ *
32
+ * The page is built to be filled in: who it is about and how complete it is stand at the top, each missing essential a
33
+ * click away; each row is a card whose headline reads like the document's line; a section with nothing yet says what
34
+ * it is for; and the document itself is one click away (PDF), as sales will hand it in. An account not linked to an
35
+ * employee record has no profile to write, and is told so before anything is typed.
28
36
  */
29
37
  let MyEmployeeProfilePage = class MyEmployeeProfilePage extends localize(i18next)(PageView) {
30
38
  constructor() {
31
39
  super(...arguments);
32
40
  this.form = profileFormOf(null);
41
+ /** Who the profile is about - the employee record's basics. */
42
+ this.me = null;
33
43
  /** Whether the account is linked to an employee record - unknown until read. */
34
44
  this.linked = null;
35
45
  this.loading = false;
36
46
  this.error = null;
37
47
  this.busy = false;
48
+ this.updatedAt = null;
49
+ /** The form as last read or saved - what is written differs from it until saved. */
50
+ this.saved = JSON.stringify(profileInputOf(profileFormOf(null)));
38
51
  }
39
52
  static { this.styles = [
40
53
  FormFieldStyles,
@@ -45,7 +58,8 @@ let MyEmployeeProfilePage = class MyEmployeeProfilePage extends localize(i18next
45
58
  display: flex;
46
59
  flex-direction: column;
47
60
  gap: var(--spacing-small, 4px);
48
- font-size: var(--ui-t-micro, 11px);
61
+ min-width: 0;
62
+ font-size: var(--ui-t-small, 11.5px);
49
63
  color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
50
64
  }
51
65
  label > :is(input, textarea) {
@@ -58,32 +72,218 @@ let MyEmployeeProfilePage = class MyEmployeeProfilePage extends localize(i18next
58
72
  width: 100%;
59
73
  font-family: inherit;
60
74
  }
61
- input {
75
+ input:not([type='checkbox']) {
62
76
  height: var(--form-element-height-medium, 32px);
63
77
  }
64
78
  textarea {
65
79
  min-height: 72px;
66
80
  resize: vertical;
81
+ line-height: 1.5;
67
82
  }
68
- textarea.summary {
69
- min-height: 120px;
70
- }
71
- .hint {
72
- font-size: var(--ui-t-small, 11.5px);
83
+ .muted {
73
84
  color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
74
85
  }
75
- /* A row of a section: its fields in a grid, the button that takes it away at its end. */
76
- .row {
86
+
87
+ /* ── Who it is about, and how far along ─────────────────────────────── */
88
+ .hero {
77
89
  display: grid;
78
- grid-template-columns: 1fr auto;
90
+ grid-template-columns: minmax(0, 1fr) minmax(240px, 340px);
91
+ gap: var(--spacing-huge, 24px);
92
+ align-items: center;
93
+ }
94
+ .who {
95
+ display: flex;
96
+ align-items: center;
79
97
  gap: var(--spacing-large, 12px);
80
- align-items: start;
81
- padding: var(--spacing-large, 12px) 0;
98
+ min-width: 0;
99
+ }
100
+ .avatar {
101
+ flex: none;
102
+ display: grid;
103
+ place-items: center;
104
+ width: 56px;
105
+ height: 56px;
106
+ border-radius: 50%;
107
+ background: var(--md-sys-color-primary-container);
108
+ color: var(--md-sys-color-on-primary-container);
109
+ font-size: 22px;
110
+ font-weight: 700;
111
+ }
112
+ .who h2 {
113
+ margin: 0;
114
+ font-size: var(--ui-t-headline, 20px);
115
+ font-weight: 700;
116
+ color: var(--ui-ink, var(--md-sys-color-on-surface));
117
+ }
118
+ .who p {
119
+ margin: 2px 0 0;
120
+ font-size: var(--ui-t-body, 13px);
121
+ }
122
+ .progress {
123
+ display: flex;
124
+ flex-direction: column;
125
+ gap: var(--spacing-small, 4px);
126
+ }
127
+ .progress-head {
128
+ display: flex;
129
+ align-items: baseline;
130
+ justify-content: space-between;
131
+ font-size: var(--ui-t-small, 11.5px);
132
+ }
133
+ .progress-head strong {
134
+ font-size: var(--ui-t-title, 15px);
135
+ color: var(--ui-ink, var(--md-sys-color-on-surface));
136
+ }
137
+ .bar {
138
+ height: 8px;
139
+ border-radius: 4px;
140
+ background: var(--md-sys-color-surface-container-high);
141
+ overflow: hidden;
142
+ }
143
+ .bar > span {
144
+ display: block;
145
+ height: 100%;
146
+ border-radius: 4px;
147
+ background: var(--md-sys-color-primary);
148
+ transition: width 0.3s ease;
149
+ }
150
+ .missing {
151
+ display: flex;
152
+ flex-wrap: wrap;
153
+ align-items: center;
154
+ gap: var(--spacing-small, 4px);
155
+ margin-top: var(--spacing-small, 4px);
156
+ font-size: var(--ui-t-small, 11.5px);
157
+ }
158
+ .hero-foot {
159
+ grid-column: 1 / -1;
160
+ display: flex;
161
+ align-items: center;
162
+ gap: var(--spacing-medium, 8px);
163
+ padding-top: var(--spacing-large, 12px);
82
164
  border-top: 1px solid var(--ui-line, var(--md-sys-color-outline-variant));
165
+ font-size: var(--ui-t-small, 11.5px);
166
+ }
167
+ .hero-foot .unsaved {
168
+ color: var(--md-sys-color-tertiary, var(--md-sys-color-primary));
169
+ font-weight: 600;
170
+ }
171
+ .hero-foot button {
172
+ margin-left: auto;
173
+ }
174
+
175
+ /* ── A section ─────────────────────────────────────────────────────── */
176
+ .page-section-header h3 {
177
+ display: flex;
178
+ align-items: center;
179
+ gap: var(--spacing-medium, 8px);
180
+ }
181
+ .page-section-header h3 md-icon {
182
+ --md-icon-size: 20px;
183
+ color: var(--md-sys-color-primary);
184
+ }
185
+ .count {
186
+ min-width: 20px;
187
+ padding: 1px 7px;
188
+ border-radius: 10px;
189
+ background: var(--md-sys-color-surface-container-high);
190
+ font-size: var(--ui-t-small, 11.5px);
191
+ font-weight: 600;
192
+ text-align: center;
193
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
194
+ }
195
+ .counter {
196
+ margin-left: auto;
197
+ font-size: var(--ui-t-small, 11.5px);
198
+ }
199
+ .summary textarea {
200
+ min-height: 132px;
201
+ }
202
+
203
+ /* Skills as chips, the field to add more inside the same box. */
204
+ .chips {
205
+ display: flex;
206
+ flex-wrap: wrap;
207
+ align-items: center;
208
+ gap: var(--spacing-small, 4px);
209
+ min-height: 88px;
210
+ align-content: flex-start;
211
+ box-sizing: border-box;
212
+ padding: var(--spacing-medium, 8px);
213
+ border: 1px solid var(--md-sys-color-outline);
214
+ border-radius: var(--input-field-border-radius, var(--md-sys-shape-corner-small, 4px));
215
+ background: var(--md-sys-color-surface-container-highest);
216
+ }
217
+ .chips:focus-within {
218
+ border-color: var(--md-sys-color-primary);
219
+ }
220
+ .chip {
221
+ display: inline-flex;
222
+ align-items: center;
223
+ gap: 2px;
224
+ height: 26px;
225
+ padding: 0 4px 0 10px;
226
+ border-radius: 13px;
227
+ background: var(--md-sys-color-secondary-container);
228
+ color: var(--md-sys-color-on-secondary-container);
229
+ font-size: var(--ui-t-body, 13px);
230
+ }
231
+ .chip button {
232
+ height: 20px;
233
+ width: 20px;
234
+ padding: 0;
235
+ border: 0;
236
+ background: transparent;
237
+ }
238
+ .chip button md-icon {
239
+ --md-icon-size: 14px;
240
+ color: inherit;
241
+ }
242
+ .chips input {
243
+ flex: 1 1 140px;
244
+ width: auto;
245
+ height: 26px;
246
+ border: 0;
247
+ padding: 0 4px;
248
+ background: transparent;
249
+ outline: none;
250
+ }
251
+
252
+ /* A row of a section is a card: its line as the document reads it, then its fields. */
253
+ .entries {
254
+ display: flex;
255
+ flex-direction: column;
256
+ gap: var(--spacing-medium, 8px);
257
+ }
258
+ .entry {
259
+ display: flex;
260
+ flex-direction: column;
261
+ gap: var(--spacing-large, 12px);
83
262
  }
84
- .row:first-of-type {
85
- border-top: 0;
86
- padding-top: 0;
263
+ .entry-head {
264
+ display: flex;
265
+ align-items: center;
266
+ gap: var(--spacing-medium, 8px);
267
+ min-width: 0;
268
+ }
269
+ .entry-head strong {
270
+ min-width: 0;
271
+ overflow: hidden;
272
+ text-overflow: ellipsis;
273
+ white-space: nowrap;
274
+ font-size: var(--ui-t-body, 13px);
275
+ color: var(--ui-ink, var(--md-sys-color-on-surface));
276
+ }
277
+ .entry-head strong.untitled {
278
+ font-weight: 400;
279
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
280
+ }
281
+ .entry-head .period {
282
+ flex: none;
283
+ font-size: var(--ui-t-small, 11.5px);
284
+ }
285
+ .entry-head button {
286
+ margin-left: auto;
87
287
  }
88
288
  .fields {
89
289
  display: grid;
@@ -93,13 +293,54 @@ let MyEmployeeProfilePage = class MyEmployeeProfilePage extends localize(i18next
93
293
  .fields .wide {
94
294
  grid-column: 1 / -1;
95
295
  }
96
- .row > button {
97
- margin-top: 18px;
296
+ .fields .start {
297
+ grid-column-start: 1;
98
298
  }
99
- .empty {
100
- margin: 0;
299
+ .end {
300
+ display: grid;
301
+ grid-template-columns: minmax(0, 1fr) auto;
302
+ gap: var(--spacing-medium, 8px);
303
+ align-items: end;
304
+ min-width: 0;
305
+ }
306
+ .ongoing {
307
+ flex-direction: row;
308
+ align-items: center;
309
+ gap: var(--spacing-small, 4px);
310
+ height: var(--form-element-height-medium, 32px);
101
311
  font-size: var(--ui-t-body, 13px);
102
- color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
312
+ white-space: nowrap;
313
+ color: var(--ui-ink, var(--md-sys-color-on-surface));
314
+ }
315
+ .ongoing input {
316
+ width: auto;
317
+ }
318
+
319
+ /* A section with nothing yet: what it is for, and the way to begin. */
320
+ .empty-slot {
321
+ display: flex;
322
+ align-items: center;
323
+ gap: var(--spacing-large, 12px);
324
+ padding: var(--spacing-large, 12px) 16px;
325
+ border: 1px dashed var(--md-sys-color-outline-variant);
326
+ border-radius: var(--ui-r, 10px);
327
+ font-size: var(--ui-t-body, 13px);
328
+ }
329
+ .empty-slot button {
330
+ margin-left: auto;
331
+ }
332
+
333
+ @media (max-width: 720px) {
334
+ .hero {
335
+ grid-template-columns: 1fr;
336
+ }
337
+ .empty-slot {
338
+ flex-direction: column;
339
+ align-items: flex-start;
340
+ }
341
+ .empty-slot button {
342
+ margin-left: 0;
343
+ }
103
344
  }
104
345
  @media (pointer: coarse) {
105
346
  input,
@@ -109,11 +350,14 @@ let MyEmployeeProfilePage = class MyEmployeeProfilePage extends localize(i18next
109
350
  }
110
351
  `
111
352
  ]; }
353
+ get dirty() {
354
+ return JSON.stringify(profileInputOf(this.form)) !== this.saved;
355
+ }
112
356
  get context() {
113
357
  return {
114
358
  title: i18next.t('title.my-employee-profile'),
115
359
  frame: 'document',
116
- actions: this.linked && !this.busy ? [{ title: i18next.t('button.save'), icon: 'save', emphasis: { raised: true }, action: () => this.save() }] : []
360
+ actions: this.linked ? [{ title: i18next.t('button.save'), icon: 'save', emphasis: { raised: true }, action: () => this.save() }] : []
117
361
  };
118
362
  }
119
363
  async pageUpdated(changes) {
@@ -127,7 +371,7 @@ let MyEmployeeProfilePage = class MyEmployeeProfilePage extends localize(i18next
127
371
  const response = await client.query({
128
372
  query: gql `
129
373
  query {
130
- myEmployees { id }
374
+ myEmployees { id name jobPosition jobResponsibility hiredOn department { name } }
131
375
  myEmployeeProfile { ${PROFILE_FIELDS} }
132
376
  }
133
377
  `,
@@ -135,8 +379,9 @@ let MyEmployeeProfilePage = class MyEmployeeProfilePage extends localize(i18next
135
379
  });
136
380
  if (response.errors?.length)
137
381
  throw response.errors[0];
138
- this.linked = (response.data?.myEmployees ?? []).length > 0;
139
- this.form = profileFormOf(response.data?.myEmployeeProfile);
382
+ this.me = response.data?.myEmployees?.[0] ?? null;
383
+ this.linked = !!this.me;
384
+ this.hold(response.data?.myEmployeeProfile);
140
385
  }
141
386
  catch (error) {
142
387
  this.error = error;
@@ -146,9 +391,15 @@ let MyEmployeeProfilePage = class MyEmployeeProfilePage extends localize(i18next
146
391
  this.updateContext();
147
392
  }
148
393
  }
394
+ hold(profile) {
395
+ this.form = profileFormOf(profile);
396
+ this.saved = JSON.stringify(profileInputOf(this.form));
397
+ this.updatedAt = profile?.updatedAt ?? null;
398
+ }
149
399
  async save() {
400
+ if (this.busy)
401
+ return false;
150
402
  this.busy = true;
151
- this.updateContext();
152
403
  try {
153
404
  const response = await client.mutate({
154
405
  mutation: gql `
@@ -160,20 +411,31 @@ let MyEmployeeProfilePage = class MyEmployeeProfilePage extends localize(i18next
160
411
  });
161
412
  if (response.errors?.length)
162
413
  throw response.errors[0];
163
- this.form = profileFormOf(response.data.saveMyEmployeeProfile);
414
+ this.hold(response.data.saveMyEmployeeProfile);
164
415
  notify({ message: i18next.t('text.saved') });
416
+ return true;
165
417
  }
166
418
  catch (error) {
167
419
  notify({ level: 'error', message: error?.message ?? String(error) });
420
+ return false;
168
421
  }
169
422
  finally {
170
423
  this.busy = false;
171
- this.updateContext();
172
424
  }
173
425
  }
426
+ /** The document as sales will hand it in - what is written is saved first, so the preview shows it. */
427
+ async preview() {
428
+ if (this.dirty && !(await this.save()))
429
+ return;
430
+ const languages = ((await getLanguages()) ?? []);
431
+ openProfilePreview({ own: true }, languages, readerLanguageOf(languages));
432
+ }
433
+ goTo(key) {
434
+ const section = this.renderRoot.querySelector(`#${key}`);
435
+ section?.scrollIntoView({ behavior: 'smooth', block: 'start' });
436
+ section?.querySelector('textarea, input:not([type="checkbox"])')?.focus({ preventScroll: true });
437
+ }
174
438
  render() {
175
- const form = this.form;
176
- const set = (patch) => (this.form = { ...form, ...patch });
177
439
  return html `<ox-page-state .loading=${this.loading && this.linked === null} .error=${this.linked === null ? this.error : null} @retry=${() => this.load()}>
178
440
  ${this.linked === false
179
441
  ? html `<ox-empty-note
@@ -183,82 +445,190 @@ let MyEmployeeProfilePage = class MyEmployeeProfilePage extends localize(i18next
183
445
  ></ox-empty-note>`
184
446
  : this.linked
185
447
  ? html `<div class="page">
186
- <section class="page-section">
187
- <header class="page-section-header"><h3>${i18next.t('label.employee-profile-summary')}</h3></header>
188
- <textarea
189
- class="summary"
190
- aria-label=${i18next.t('label.employee-profile-summary')}
191
- .value=${form.summary}
192
- ?disabled=${this.busy}
193
- @input=${(e) => set({ summary: e.target.value })}
194
- ></textarea>
195
- </section>
196
-
197
- <section class="page-section">
198
- <header class="page-section-header"><h3>${i18next.t('label.employee-profile-skills')}</h3></header>
199
- <input
200
- aria-label=${i18next.t('label.employee-profile-skills')}
201
- .value=${form.skills}
202
- ?disabled=${this.busy}
203
- @input=${(e) => set({ skills: e.target.value })}
204
- />
205
- <span class="hint">${i18next.t('text.employee-profile-skills-hint')}</span>
206
- </section>
207
-
208
- ${PROFILE_SECTIONS.map(section => this.renderSection(section.key, section.title, section.fields))}
448
+ ${this.renderHero()}
449
+ <div class="page-section-grid">${this.renderSummary()} ${this.renderSkills()}</div>
450
+ ${PROFILE_SECTIONS.map(section => this.renderSection(section))}
209
451
  </div>`
210
452
  : nothing}
211
453
  </ox-page-state>`;
212
454
  }
213
- renderSection(key, title, fields) {
214
- const rows = this.form[key];
215
- const setRows = (next) => (this.form = { ...this.form, [key]: next });
216
- const change = (index, name, value) => setRows(rows.map((row, at) => (at === index ? { ...row, [name]: value } : row)));
217
- const blank = () => Object.fromEntries(fields.map(field => [field.name, '']));
218
- return html `<section class="page-section">
219
- <header class="page-section-header">
220
- <h3>${i18next.t(title)}</h3>
221
- <div class="page-section-actions">
222
- <button ?disabled=${this.busy} @click=${() => setRows([...rows, blank()])}><md-icon>add</md-icon>${i18next.t('button.add')}</button>
455
+ renderHero() {
456
+ const me = this.me ?? {};
457
+ const { percent, missing } = completenessOf(this.form);
458
+ const role = [me.jobPosition, me.jobResponsibility].filter(Boolean).join(' / ');
459
+ const day = (value) => (value ? String(value).slice(0, 10).replace(/-/g, '.') : '');
460
+ return html `<section class="hero page-card">
461
+ <div class="who">
462
+ <div class="avatar" aria-hidden="true">${(me.name ?? '?').trim().slice(0, 1)}</div>
463
+ <div>
464
+ <h2>${me.name ?? ''}</h2>
465
+ <p class="muted">${[me.department?.name, role].filter(Boolean).join(' · ')}</p>
466
+ ${me.hiredOn ? html `<p class="muted">${i18next.t('label.hired-on')} ${day(me.hiredOn)}</p>` : nothing}
223
467
  </div>
468
+ </div>
469
+
470
+ <div class="progress">
471
+ <div class="progress-head">
472
+ <span class="muted">${i18next.t('text.employee-profile-completeness')}</span>
473
+ <strong>${percent}%</strong>
474
+ </div>
475
+ <div class="bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow=${percent}><span style="width:${percent}%"></span></div>
476
+ ${missing.length
477
+ ? html `<div class="missing">
478
+ <span class="muted">${i18next.t('text.employee-profile-to-fill')}</span>
479
+ ${missing.map(key => html `<button small quiet @click=${() => this.goTo(key)}>${i18next.t(`label.employee-profile-${key}`)}</button>`)}
480
+ </div>`
481
+ : html `<div class="missing muted">${i18next.t('text.employee-profile-ready')}</div>`}
482
+ </div>
483
+
484
+ <div class="hero-foot">
485
+ ${this.dirty
486
+ ? html `<span class="unsaved">${i18next.t('text.employee-profile-unsaved')}</span>`
487
+ : this.updatedAt
488
+ ? html `<span class="muted">${i18next.t('label.employee-profile-updated-at')} ${day(this.updatedAt)}</span>`
489
+ : nothing}
490
+ <button ?disabled=${this.busy} @click=${() => this.preview()}><md-icon>preview</md-icon>${i18next.t('button.employee-profile-preview')}</button>
491
+ </div>
492
+ </section>`;
493
+ }
494
+ renderSummary() {
495
+ const summary = this.form.summary;
496
+ return html `<section id="summary" class="page-section summary">
497
+ <header class="page-section-header">
498
+ <h3><md-icon>person</md-icon>${i18next.t('label.employee-profile-summary')}</h3>
499
+ <span class="counter muted">${summary.length} / ${SUMMARY_LIMIT}</span>
500
+ </header>
501
+ <textarea
502
+ aria-label=${i18next.t('label.employee-profile-summary')}
503
+ maxlength=${SUMMARY_LIMIT}
504
+ placeholder=${i18next.t('text.employee-profile-summary-example')}
505
+ .value=${summary}
506
+ @input=${(e) => (this.form = { ...this.form, summary: e.target.value })}
507
+ ></textarea>
508
+ </section>`;
509
+ }
510
+ renderSkills() {
511
+ const skills = this.form.skills;
512
+ const add = (input) => {
513
+ this.form = { ...this.form, skills: addSkills(skills, input.value) };
514
+ input.value = '';
515
+ };
516
+ return html `<section id="skills" class="page-section">
517
+ <header class="page-section-header">
518
+ <h3><md-icon>construction</md-icon>${i18next.t('label.employee-profile-skills')}</h3>
519
+ ${skills.length ? html `<span class="count">${skills.length}</span>` : nothing}
520
+ </header>
521
+ <div class="chips" @click=${(e) => e.currentTarget.querySelector('input')?.focus()}>
522
+ ${skills.map(skill => html `<span class="chip"
523
+ >${skill}
524
+ <button
525
+ unstyled
526
+ title=${i18next.t('button.delete')}
527
+ aria-label=${`${i18next.t('button.delete')} ${skill}`}
528
+ @click=${() => (this.form = { ...this.form, skills: skills.filter(one => one !== skill) })}
529
+ >
530
+ <md-icon>close</md-icon>
531
+ </button></span
532
+ >`)}
533
+ <input
534
+ aria-label=${i18next.t('label.employee-profile-skills')}
535
+ placeholder=${i18next.t('text.employee-profile-skill-placeholder')}
536
+ @keydown=${(e) => {
537
+ const input = e.target;
538
+ if ((e.key === 'Enter' || e.key === ',') && !e.isComposing) {
539
+ e.preventDefault();
540
+ add(input);
541
+ }
542
+ else if (e.key === 'Backspace' && !input.value && skills.length) {
543
+ this.form = { ...this.form, skills: skills.slice(0, -1) };
544
+ }
545
+ }}
546
+ @blur=${(e) => add(e.target)}
547
+ />
548
+ </div>
549
+ </section>`;
550
+ }
551
+ renderSection(section) {
552
+ const rows = this.form[section.key];
553
+ /* Each change reads the rows as they are now - two inputs before a render must not undo each other. */
554
+ const current = () => this.form[section.key];
555
+ const setRows = (next) => (this.form = { ...this.form, [section.key]: next });
556
+ const change = (index, patch) => setRows(current().map((row, at) => (at === index ? { ...row, ...patch } : row)));
557
+ const remove = (index) => setRows(current().filter((_, at) => at !== index));
558
+ /* The newest first, as the document is read; its first field takes the cursor. */
559
+ const add = async () => {
560
+ setRows([blankRow(section), ...current()]);
561
+ await this.updateComplete;
562
+ this.renderRoot.querySelector(`#${section.key} .entry input`)?.focus();
563
+ };
564
+ return html `<section id=${section.key} class="page-section">
565
+ <header class="page-section-header">
566
+ <h3><md-icon>${section.icon}</md-icon>${i18next.t(section.title)}</h3>
567
+ ${rows.length ? html `<span class="count">${rows.length}</span>` : nothing}
568
+ ${rows.length
569
+ ? html `<div class="page-section-actions">
570
+ <button @click=${add}><md-icon>add</md-icon>${i18next.t('button.add')}</button>
571
+ </div>`
572
+ : nothing}
224
573
  </header>
225
574
  ${rows.length
226
- ? rows.map((row, index) => html `<div class="row">
227
- <div class="fields">
228
- ${fields.map(field => field.input === 'textarea'
229
- ? html `<label class="wide"
230
- >${i18next.t(field.label)}
231
- <textarea .value=${row[field.name] ?? ''} ?disabled=${this.busy} @input=${(e) => change(index, field.name, e.target.value)}></textarea>
232
- </label>`
233
- : html `<label
234
- >${i18next.t(field.label)}
235
- <input
236
- type=${field.input ?? 'text'}
237
- .value=${row[field.name] ?? ''}
238
- ?disabled=${this.busy}
239
- @input=${(e) => change(index, field.name, e.target.value)}
240
- />
241
- </label>`)}
242
- </div>
243
- <button
244
- quiet
245
- icon-only
246
- title=${i18next.t('button.delete')}
247
- aria-label=${i18next.t('button.delete')}
248
- ?disabled=${this.busy}
249
- @click=${() => setRows(rows.filter((_, at) => at !== index))}
250
- >
251
- <md-icon>close</md-icon>
252
- </button>
253
- </div>`)
254
- : html `<p class="empty">${i18next.t('text.employee-profile-no-rows')}</p>`}
575
+ ? html `<div class="entries">${rows.map((row, index) => this.renderEntry(section, row, index, change, () => remove(index)))}</div>`
576
+ : html `<div class="empty-slot">
577
+ <span class="muted">${i18next.t(`text.employee-profile-why-${section.key}`)}</span>
578
+ <button @click=${add}><md-icon>add</md-icon>${i18next.t('button.add')}</button>
579
+ </div>`}
255
580
  </section>`;
256
581
  }
582
+ renderEntry(section, row, index, change, remove) {
583
+ const headline = headlineOf(section, row);
584
+ const period = periodLabelOf(row, i18next.t('text.employee-profile-doc-ongoing'));
585
+ const field = (one) => {
586
+ const label = i18next.t(one.label);
587
+ if (one.input === 'textarea') {
588
+ return html `<label class="wide"
589
+ >${label}<textarea .value=${row[one.name] ?? ''} @input=${(e) => change(index, { [one.name]: e.target.value })}></textarea
590
+ ></label>`;
591
+ }
592
+ const ended = one.name === 'endedOn' && section.ongoing;
593
+ const input = html `<input
594
+ type=${one.input ?? 'text'}
595
+ .value=${ended && row.ongoing ? '' : (row[one.name] ?? '')}
596
+ ?disabled=${ended && !!row.ongoing}
597
+ @input=${(e) => change(index, { [one.name]: e.target.value })}
598
+ />`;
599
+ /* A period begins a line of its own, its end beside it. */
600
+ if (!ended)
601
+ return html `<label class=${one.name === 'startedOn' ? 'start' : ''}>${label}${input}</label>`;
602
+ /* The end and 「going on」 share one cell - the period stays on one line. */
603
+ return html `<div class="end">
604
+ <label>${label}${input}</label>
605
+ <label class="ongoing"
606
+ ><input
607
+ type="checkbox"
608
+ .checked=${!!row.ongoing}
609
+ @change=${(e) => change(index, { ongoing: e.target.checked ? 'true' : '', ...(e.target.checked ? { endedOn: '' } : {}) })}
610
+ />${i18next.t('label.employee-profile-ongoing')}</label
611
+ >
612
+ </div>`;
613
+ };
614
+ return html `<div class="entry page-card">
615
+ <div class="entry-head">
616
+ <strong class=${headline ? '' : 'untitled'}>${headline || i18next.t('text.employee-profile-new-entry')}</strong>
617
+ ${period ? html `<span class="period muted">${period}</span>` : nothing}
618
+ <button quiet icon-only title=${i18next.t('button.delete')} aria-label=${i18next.t('button.delete')} @click=${remove}><md-icon>delete</md-icon></button>
619
+ </div>
620
+ <div class="fields">${section.fields.map(field)}</div>
621
+ </div>`;
622
+ }
257
623
  };
258
624
  __decorate([
259
625
  state(),
260
626
  __metadata("design:type", Object)
261
627
  ], MyEmployeeProfilePage.prototype, "form", void 0);
628
+ __decorate([
629
+ state(),
630
+ __metadata("design:type", Object)
631
+ ], MyEmployeeProfilePage.prototype, "me", void 0);
262
632
  __decorate([
263
633
  state(),
264
634
  __metadata("design:type", Object)
@@ -275,6 +645,10 @@ __decorate([
275
645
  state(),
276
646
  __metadata("design:type", Object)
277
647
  ], MyEmployeeProfilePage.prototype, "busy", void 0);
648
+ __decorate([
649
+ state(),
650
+ __metadata("design:type", Object)
651
+ ], MyEmployeeProfilePage.prototype, "updatedAt", void 0);
278
652
  MyEmployeeProfilePage = __decorate([
279
653
  customElement('my-employee-profile-page')
280
654
  ], MyEmployeeProfilePage);