@things-factory/organization 10.1.226 → 10.1.227
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist-client/pages/employee-profile/employee-profile-files.d.ts +23 -0
- package/dist-client/pages/employee-profile/employee-profile-files.js +50 -0
- package/dist-client/pages/employee-profile/employee-profile-files.js.map +1 -0
- package/dist-client/pages/employee-profile/employee-profile-form.d.ts +42 -10
- package/dist-client/pages/employee-profile/employee-profile-form.js +68 -22
- package/dist-client/pages/employee-profile/employee-profile-form.js.map +1 -1
- package/dist-client/pages/employee-profile/employee-profile-list-page.d.ts +19 -5
- package/dist-client/pages/employee-profile/employee-profile-list-page.js +88 -46
- package/dist-client/pages/employee-profile/employee-profile-list-page.js.map +1 -1
- package/dist-client/pages/employee-profile/employee-profile-preview.d.ts +28 -0
- package/dist-client/pages/employee-profile/employee-profile-preview.js +179 -0
- package/dist-client/pages/employee-profile/employee-profile-preview.js.map +1 -0
- package/dist-client/pages/employee-profile/my-employee-profile-page.d.ts +20 -2
- package/dist-client/pages/employee-profile/my-employee-profile-page.js +467 -93
- package/dist-client/pages/employee-profile/my-employee-profile-page.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/ai-entry/employee-profile-entry.d.ts +10 -0
- package/dist-server/ai-entry/employee-profile-entry.js +88 -0
- package/dist-server/ai-entry/employee-profile-entry.js.map +1 -0
- package/dist-server/ai-entry/entry-target-shape.d.ts +11 -1
- package/dist-server/ai-entry/entry-target-shape.js.map +1 -1
- package/dist-server/ai-entry/organization-entry-targets.d.ts +8 -0
- package/dist-server/ai-entry/organization-entry-targets.js +26 -1
- package/dist-server/ai-entry/organization-entry-targets.js.map +1 -1
- package/dist-server/service/employee-profile/employee-profile-document.d.ts +122 -0
- package/dist-server/service/employee-profile/employee-profile-document.js +439 -0
- package/dist-server/service/employee-profile/employee-profile-document.js.map +1 -0
- package/dist-server/service/employee-profile/employee-profile-mutation.d.ts +1 -0
- package/dist-server/service/employee-profile/employee-profile-mutation.js +29 -0
- package/dist-server/service/employee-profile/employee-profile-mutation.js.map +1 -1
- package/dist-server/service/employee-profile/employee-profile-query.d.ts +9 -1
- package/dist-server/service/employee-profile/employee-profile-query.js +83 -4
- package/dist-server/service/employee-profile/employee-profile-query.js.map +1 -1
- package/dist-server/service/employee-profile/employee-profile-rules.d.ts +6 -0
- package/dist-server/service/employee-profile/employee-profile-rules.js +24 -0
- package/dist-server/service/employee-profile/employee-profile-rules.js.map +1 -1
- package/dist-server/service/employee-profile/employee-profile-type.d.ts +11 -0
- package/dist-server/service/employee-profile/employee-profile-type.js +30 -1
- package/dist-server/service/employee-profile/employee-profile-type.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -7
- package/tests/employee-profile-db.test.ts +65 -4
- package/tests/employee-profile-document.test.ts +219 -0
- package/tests/employee-profile-entry.test.ts +72 -0
- package/tests/employee-profile-rules.test.ts +33 -6
- package/translations/en.json +43 -3
- package/translations/es.json +43 -3
- package/translations/ja.json +43 -3
- package/translations/ko.json +43 -3
- package/translations/ms.json +43 -3
- package/translations/zh.json +43 -3
- package/dist-client/pages/employee-profile/employee-profile-workbook.d.ts +0 -33
- package/dist-client/pages/employee-profile/employee-profile-workbook.js +0 -87
- package/dist-client/pages/employee-profile/employee-profile-workbook.js.map +0 -1
- 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 {
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
76
|
-
|
|
86
|
+
|
|
87
|
+
/* ── Who it is about, and how far along ─────────────────────────────── */
|
|
88
|
+
.hero {
|
|
77
89
|
display: grid;
|
|
78
|
-
grid-template-columns: 1fr
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
.
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
.
|
|
97
|
-
|
|
296
|
+
.fields .start {
|
|
297
|
+
grid-column-start: 1;
|
|
98
298
|
}
|
|
99
|
-
.
|
|
100
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
139
|
-
this.
|
|
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.
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
214
|
-
const
|
|
215
|
-
const
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
return html `<section class="page-
|
|
219
|
-
<
|
|
220
|
-
<
|
|
221
|
-
<div
|
|
222
|
-
<
|
|
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) =>
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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);
|