@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.
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 +88 -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
@@ -0,0 +1,23 @@
1
+ export type ProfileFormat = 'XLSX' | 'PDF' | 'HTML';
2
+ export type ProfileFile = {
3
+ fileName: string;
4
+ mimeType: string;
5
+ content: string;
6
+ };
7
+ /** Whose profiles: the chosen employees, or the caller's own. */
8
+ export type ProfileSubject = {
9
+ employeeIds: string[];
10
+ } | {
11
+ own: true;
12
+ };
13
+ export declare function profileFile(subject: ProfileSubject, format: ProfileFormat, language?: string): Promise<ProfileFile>;
14
+ /** Hand a file the server made to the browser to save. */
15
+ export declare function saveProfileFile({ fileName, mimeType, content }: ProfileFile): void;
16
+ /** The HTML page of a preview, as text. */
17
+ export declare function textOf({ content }: ProfileFile): string;
18
+ export type Language = {
19
+ code: string;
20
+ display: string;
21
+ };
22
+ /** The reader's language among those the application runs - by its language part when the region differs. */
23
+ export declare function readerLanguageOf(languages: Language[], reader?: string): string;
@@ -0,0 +1,50 @@
1
+ import gql from 'graphql-tag';
2
+ import { client } from '@operato/graphql';
3
+ import { i18next } from '@operato/i18n';
4
+ const CHOSEN = gql `
5
+ query ($employeeIds: [String!]!, $format: EmployeeProfileFormat!, $language: String) {
6
+ employeeProfileDocument(employeeIds: $employeeIds, format: $format, language: $language) {
7
+ fileName
8
+ mimeType
9
+ content
10
+ }
11
+ }
12
+ `;
13
+ const OWN = gql `
14
+ query ($format: EmployeeProfileFormat!, $language: String) {
15
+ myEmployeeProfileDocument(format: $format, language: $language) {
16
+ fileName
17
+ mimeType
18
+ content
19
+ }
20
+ }
21
+ `;
22
+ export async function profileFile(subject, format, language) {
23
+ const own = 'own' in subject;
24
+ const response = await client.query({
25
+ query: own ? OWN : CHOSEN,
26
+ variables: { ...(own ? {} : { employeeIds: subject.employeeIds }), format, language: language || null },
27
+ fetchPolicy: 'no-cache'
28
+ });
29
+ const file = own ? response.data?.myEmployeeProfileDocument : response.data?.employeeProfileDocument;
30
+ if (!file)
31
+ throw response.errors?.[0] ?? new Error('no document');
32
+ return file;
33
+ }
34
+ const bytesOf = (content) => Uint8Array.from(atob(content), char => char.charCodeAt(0));
35
+ /** Hand a file the server made to the browser to save. */
36
+ export function saveProfileFile({ fileName, mimeType, content }) {
37
+ const url = URL.createObjectURL(new Blob([bytesOf(content)], { type: mimeType }));
38
+ const link = Object.assign(document.createElement('a'), { href: url, download: fileName });
39
+ link.click();
40
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
41
+ }
42
+ /** The HTML page of a preview, as text. */
43
+ export function textOf({ content }) {
44
+ return new TextDecoder().decode(bytesOf(content));
45
+ }
46
+ /** The reader's language among those the application runs - by its language part when the region differs. */
47
+ export function readerLanguageOf(languages, reader = i18next.language ?? '') {
48
+ return languages.find(one => one.code === reader)?.code ?? languages.find(one => one.code.split('-')[0] === reader.split('-')[0])?.code ?? languages[0]?.code ?? '';
49
+ }
50
+ //# sourceMappingURL=employee-profile-files.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"employee-profile-files.js","sourceRoot":"","sources":["../../../client/pages/employee-profile/employee-profile-files.ts"],"names":[],"mappings":"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","sourcesContent":["import gql from 'graphql-tag'\n\nimport { client } from '@operato/graphql'\nimport { i18next } from '@operato/i18n'\n\n/*\n * The profile documents the screens ask the server for (ADR-0169 decisions 6 · 9): the chosen people's - read under\n * `employee-profile` - or one's own, as Excel, PDF or the HTML page the preview shows. One way for the list, the\n * preview and one's own page.\n */\n\nexport type ProfileFormat = 'XLSX' | 'PDF' | 'HTML'\n\nexport type ProfileFile = { fileName: string; mimeType: string; content: string }\n\n/** Whose profiles: the chosen employees, or the caller's own. */\nexport type ProfileSubject = { employeeIds: string[] } | { own: true }\n\nconst CHOSEN = gql`\n query ($employeeIds: [String!]!, $format: EmployeeProfileFormat!, $language: String) {\n employeeProfileDocument(employeeIds: $employeeIds, format: $format, language: $language) {\n fileName\n mimeType\n content\n }\n }\n`\n\nconst OWN = gql`\n query ($format: EmployeeProfileFormat!, $language: String) {\n myEmployeeProfileDocument(format: $format, language: $language) {\n fileName\n mimeType\n content\n }\n }\n`\n\nexport async function profileFile(subject: ProfileSubject, format: ProfileFormat, language?: string): Promise<ProfileFile> {\n const own = 'own' in subject\n const response = await client.query({\n query: own ? OWN : CHOSEN,\n variables: { ...(own ? {} : { employeeIds: subject.employeeIds }), format, language: language || null },\n fetchPolicy: 'no-cache'\n })\n const file = own ? response.data?.myEmployeeProfileDocument : response.data?.employeeProfileDocument\n if (!file) throw response.errors?.[0] ?? new Error('no document')\n return file\n}\n\nconst bytesOf = (content: string) => Uint8Array.from(atob(content), char => char.charCodeAt(0))\n\n/** Hand a file the server made to the browser to save. */\nexport function saveProfileFile({ fileName, mimeType, content }: ProfileFile) {\n const url = URL.createObjectURL(new Blob([bytesOf(content)], { type: mimeType }))\n const link = Object.assign(document.createElement('a'), { href: url, download: fileName })\n link.click()\n setTimeout(() => URL.revokeObjectURL(url), 1000)\n}\n\n/** The HTML page of a preview, as text. */\nexport function textOf({ content }: ProfileFile): string {\n return new TextDecoder().decode(bytesOf(content))\n}\n\nexport type Language = { code: string; display: string }\n\n/** The reader's language among those the application runs - by its language part when the region differs. */\nexport function readerLanguageOf(languages: Language[], reader = i18next.language ?? ''): string {\n return languages.find(one => one.code === reader)?.code ?? languages.find(one => one.code.split('-')[0] === reader.split('-')[0])?.code ?? languages[0]?.code ?? ''\n}\n"]}
@@ -1,3 +1,4 @@
1
+ /** A row as the screen edits it - the fields, and `ongoing` for a job or project still going on (never sent). */
1
2
  export type ProfileRow = Record<string, string>;
2
3
  export type ProfileSectionKey = 'educations' | 'certificates' | 'careers' | 'projects';
3
4
  export type ProfileField = {
@@ -7,25 +8,56 @@ export type ProfileField = {
7
8
  /** `month` for a period's ends (YYYY-MM), `date` for a day, `textarea` for what was done, text otherwise. */
8
9
  input?: 'month' | 'date' | 'textarea';
9
10
  };
10
- export declare const PROFILE_SECTIONS: {
11
+ export type ProfileSection = {
11
12
  key: ProfileSectionKey;
12
13
  title: string;
14
+ /** The icon the section is shown with. */
15
+ icon: string;
16
+ /** The fields a row's one-line headline is made of, in order. */
17
+ headline: string[];
18
+ /** Whether a row may still be going on - a job held now, a project under way. */
19
+ ongoing?: boolean;
13
20
  fields: ProfileField[];
14
- }[];
15
- /** The skills as written in one line - split at commas and line ends, blanks dropped. */
16
- export declare function skillsOf(text: string): string[];
21
+ };
22
+ export declare const PROFILE_SECTIONS: ProfileSection[];
23
+ /** Skills as typed - split at commas and line ends, blanks and repeats dropped, added after those already held. */
24
+ export declare function addSkills(held: string[], text: string): string[];
17
25
  export type ProfileForm = {
18
26
  summary: string;
19
- skills: string;
27
+ skills: string[];
20
28
  } & Record<ProfileSectionKey, ProfileRow[]>;
29
+ /** An empty row of a section, to add. */
30
+ export declare function blankRow(section: ProfileSection): ProfileRow;
21
31
  /** The held profile as the form edits it - a profile not yet written is an empty form. */
22
32
  export declare function profileFormOf(profile: any): ProfileForm;
23
- /** The form as `saveMyEmployeeProfile` takes it - the whole profile. */
33
+ /** The form as `saveMyEmployeeProfile` takes it - the whole profile, the section's fields only; a row going on has no end. */
24
34
  export declare function profileInputOf(form: ProfileForm): {
25
35
  summary: string;
26
36
  skills: string[];
27
- educations: ProfileRow[];
28
- certificates: ProfileRow[];
29
- careers: ProfileRow[];
30
- projects: ProfileRow[];
37
+ educations: {
38
+ [k: string]: string;
39
+ }[];
40
+ certificates: {
41
+ [k: string]: string;
42
+ }[];
43
+ careers: {
44
+ [k: string]: string;
45
+ }[];
46
+ projects: {
47
+ [k: string]: string;
48
+ }[];
49
+ };
50
+ /** A row in one line: the headline fields that are written, joined. */
51
+ export declare function headlineOf(section: ProfileSection, row: ProfileRow): string;
52
+ /** 「2023.03 ~ 2024.08」, 「2024.01 ~ 현재」 - a row's period as the document prints it. */
53
+ export declare function periodLabelOf(row: ProfileRow, ongoing: string): string;
54
+ /**
55
+ * What a profile a quotation can stand on has: a summary, skills, education and projects. Past jobs and certificates
56
+ * count when written but are not asked of everyone - a first job has none.
57
+ */
58
+ export declare const PROFILE_ESSENTIALS: readonly ["summary", "skills", "educations", "projects"];
59
+ export type ProfileEssential = (typeof PROFILE_ESSENTIALS)[number];
60
+ export declare function completenessOf(form: ProfileForm): {
61
+ percent: number;
62
+ missing: ProfileEssential[];
31
63
  };
@@ -7,6 +7,8 @@ export const PROFILE_SECTIONS = [
7
7
  {
8
8
  key: 'educations',
9
9
  title: label('educations'),
10
+ icon: 'school',
11
+ headline: ['school', 'major', 'degree'],
10
12
  fields: [
11
13
  { name: 'school', label: label('school') },
12
14
  { name: 'major', label: label('major') },
@@ -18,6 +20,8 @@ export const PROFILE_SECTIONS = [
18
20
  {
19
21
  key: 'certificates',
20
22
  title: label('certificates'),
23
+ icon: 'workspace_premium',
24
+ headline: ['name', 'issuer'],
21
25
  fields: [
22
26
  { name: 'name', label: label('certificate') },
23
27
  { name: 'issuer', label: label('issuer') },
@@ -27,6 +31,9 @@ export const PROFILE_SECTIONS = [
27
31
  {
28
32
  key: 'careers',
29
33
  title: label('careers'),
34
+ icon: 'work_history',
35
+ headline: ['company', 'department', 'position'],
36
+ ongoing: true,
30
37
  fields: [
31
38
  { name: 'company', label: label('company') },
32
39
  { name: 'department', label: 'label.department' },
@@ -39,48 +46,87 @@ export const PROFILE_SECTIONS = [
39
46
  {
40
47
  key: 'projects',
41
48
  title: label('projects'),
49
+ icon: 'rocket_launch',
50
+ headline: ['name', 'client', 'role'],
51
+ ongoing: true,
42
52
  fields: [
43
53
  { name: 'name', label: label('project') },
44
54
  { name: 'client', label: label('client') },
45
55
  { name: 'role', label: label('role') },
56
+ { name: 'technologies', label: label('technologies') },
46
57
  { name: 'startedOn', label: label('started-on'), input: 'month' },
47
58
  { name: 'endedOn', label: label('ended-on'), input: 'month' },
48
- { name: 'technologies', label: label('technologies') },
49
59
  { name: 'duties', label: label('duties'), input: 'textarea' }
50
60
  ]
51
61
  }
52
62
  ];
53
- /** The skills as written in one line - split at commas and line ends, blanks dropped. */
54
- export function skillsOf(text) {
55
- return text
56
- .split(/[,\n]/)
57
- .map(one => one.trim())
58
- .filter(Boolean);
63
+ /** Skills as typed - split at commas and line ends, blanks and repeats dropped, added after those already held. */
64
+ export function addSkills(held, text) {
65
+ const next = [...held];
66
+ for (const one of text.split(/[,\n]/).map(word => word.trim())) {
67
+ if (one && !next.some(skill => skill.toLowerCase() === one.toLowerCase()))
68
+ next.push(one);
69
+ }
70
+ return next;
71
+ }
72
+ /** An empty row of a section, to add. */
73
+ export function blankRow(section) {
74
+ return Object.fromEntries([...section.fields.map(field => [field.name, '']), ...(section.ongoing ? [['ongoing', '']] : [])]);
59
75
  }
60
76
  /** The held profile as the form edits it - a profile not yet written is an empty form. */
61
77
  export function profileFormOf(profile) {
62
- const rows = (key) => {
63
- const fields = PROFILE_SECTIONS.find(section => section.key === key).fields;
64
- return (profile?.[key] ?? []).map(row => Object.fromEntries(fields.map(field => [field.name, row?.[field.name] ?? ''])));
65
- };
78
+ const rows = (section) => (profile?.[section.key] ?? []).map(row => ({
79
+ ...Object.fromEntries(section.fields.map(field => [field.name, row?.[field.name] ?? ''])),
80
+ /* Begun and not ended: still going on. */
81
+ ...(section.ongoing ? { ongoing: row?.startedOn && !row?.endedOn ? 'true' : '' } : {})
82
+ }));
83
+ const sectionOf = (key) => PROFILE_SECTIONS.find(section => section.key === key);
66
84
  return {
67
85
  summary: profile?.summary ?? '',
68
- skills: (profile?.skills ?? []).join(', '),
69
- educations: rows('educations'),
70
- certificates: rows('certificates'),
71
- careers: rows('careers'),
72
- projects: rows('projects')
86
+ skills: [...(profile?.skills ?? [])],
87
+ educations: rows(sectionOf('educations')),
88
+ certificates: rows(sectionOf('certificates')),
89
+ careers: rows(sectionOf('careers')),
90
+ projects: rows(sectionOf('projects'))
73
91
  };
74
92
  }
75
- /** The form as `saveMyEmployeeProfile` takes it - the whole profile. */
93
+ /** The form as `saveMyEmployeeProfile` takes it - the whole profile, the section's fields only; a row going on has no end. */
76
94
  export function profileInputOf(form) {
95
+ const rows = (section) => form[section.key].map(row => {
96
+ const kept = Object.fromEntries(section.fields.map(field => [field.name, row[field.name] ?? '']));
97
+ return section.ongoing && row.ongoing ? { ...kept, endedOn: '' } : kept;
98
+ });
99
+ const sectionOf = (key) => PROFILE_SECTIONS.find(section => section.key === key);
77
100
  return {
78
101
  summary: form.summary,
79
- skills: skillsOf(form.skills),
80
- educations: form.educations,
81
- certificates: form.certificates,
82
- careers: form.careers,
83
- projects: form.projects
102
+ skills: form.skills,
103
+ educations: rows(sectionOf('educations')),
104
+ certificates: rows(sectionOf('certificates')),
105
+ careers: rows(sectionOf('careers')),
106
+ projects: rows(sectionOf('projects'))
84
107
  };
85
108
  }
109
+ /** A row in one line: the headline fields that are written, joined. */
110
+ export function headlineOf(section, row) {
111
+ return section.headline.map(name => (row[name] ?? '').trim()).filter(Boolean).join(' · ');
112
+ }
113
+ /** 「2023.03 ~ 2024.08」, 「2024.01 ~ 현재」 - a row's period as the document prints it. */
114
+ export function periodLabelOf(row, ongoing) {
115
+ const dot = (day) => (day ?? '').replace(/-/g, '.');
116
+ if (row.acquiredOn !== undefined && row.startedOn === undefined)
117
+ return dot(row.acquiredOn);
118
+ if (!row.startedOn && !row.endedOn && !row.ongoing)
119
+ return '';
120
+ return `${dot(row.startedOn)} ~ ${row.ongoing ? ongoing : dot(row.endedOn)}`.trim();
121
+ }
122
+ /**
123
+ * What a profile a quotation can stand on has: a summary, skills, education and projects. Past jobs and certificates
124
+ * count when written but are not asked of everyone - a first job has none.
125
+ */
126
+ export const PROFILE_ESSENTIALS = ['summary', 'skills', 'educations', 'projects'];
127
+ export function completenessOf(form) {
128
+ const has = (key) => (key === 'summary' ? !!form.summary.trim() : form[key].length > 0);
129
+ const missing = PROFILE_ESSENTIALS.filter(key => !has(key));
130
+ return { percent: Math.round(((PROFILE_ESSENTIALS.length - missing.length) / PROFILE_ESSENTIALS.length) * 100), missing };
131
+ }
86
132
  //# sourceMappingURL=employee-profile-form.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"employee-profile-form.js","sourceRoot":"","sources":["../../../client/pages/employee-profile/employee-profile-form.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * What the profile screen edits (ADR-0169 decision 2): the four sections of rows, each with its fields and the input a\n * field takes. Pure, so the shape the screen sends is read by the tests against what the server keeps.\n */\n\nexport type ProfileRow = Record<string, string>\n\nexport type ProfileSectionKey = 'educations' | 'certificates' | 'careers' | 'projects'\n\nexport type ProfileField = {\n name: string\n /** The translation key of its label. */\n label: string\n /** `month` for a period's ends (YYYY-MM), `date` for a day, `textarea` for what was done, text otherwise. */\n input?: 'month' | 'date' | 'textarea'\n}\n\nconst label = (name: string) => `label.employee-profile-${name}`\n\nexport const PROFILE_SECTIONS: { key: ProfileSectionKey; title: string; fields: ProfileField[] }[] = [\n {\n key: 'educations',\n title: label('educations'),\n fields: [\n { name: 'school', label: label('school') },\n { name: 'major', label: label('major') },\n { name: 'degree', label: label('degree') },\n { name: 'startedOn', label: label('started-on'), input: 'month' },\n { name: 'endedOn', label: label('ended-on'), input: 'month' }\n ]\n },\n {\n key: 'certificates',\n title: label('certificates'),\n fields: [\n { name: 'name', label: label('certificate') },\n { name: 'issuer', label: label('issuer') },\n { name: 'acquiredOn', label: label('acquired-on'), input: 'date' }\n ]\n },\n {\n key: 'careers',\n title: label('careers'),\n fields: [\n { name: 'company', label: label('company') },\n { name: 'department', label: 'label.department' },\n { name: 'position', label: label('position') },\n { name: 'startedOn', label: label('started-on'), input: 'month' },\n { name: 'endedOn', label: label('ended-on'), input: 'month' },\n { name: 'duties', label: label('duties'), input: 'textarea' }\n ]\n },\n {\n key: 'projects',\n title: label('projects'),\n fields: [\n { name: 'name', label: label('project') },\n { name: 'client', label: label('client') },\n { name: 'role', label: label('role') },\n { name: 'startedOn', label: label('started-on'), input: 'month' },\n { name: 'endedOn', label: label('ended-on'), input: 'month' },\n { name: 'technologies', label: label('technologies') },\n { name: 'duties', label: label('duties'), input: 'textarea' }\n ]\n }\n]\n\n/** The skills as written in one line - split at commas and line ends, blanks dropped. */\nexport function skillsOf(text: string): string[] {\n return text\n .split(/[,\\n]/)\n .map(one => one.trim())\n .filter(Boolean)\n}\n\nexport type ProfileForm = { summary: string; skills: string } & Record<ProfileSectionKey, ProfileRow[]>\n\n/** The held profile as the form edits it - a profile not yet written is an empty form. */\nexport function profileFormOf(profile: any): ProfileForm {\n const rows = (key: ProfileSectionKey) => {\n const fields = PROFILE_SECTIONS.find(section => section.key === key)!.fields\n return ((profile?.[key] ?? []) as any[]).map(row => Object.fromEntries(fields.map(field => [field.name, row?.[field.name] ?? ''])))\n }\n return {\n summary: profile?.summary ?? '',\n skills: (profile?.skills ?? []).join(', '),\n educations: rows('educations'),\n certificates: rows('certificates'),\n careers: rows('careers'),\n projects: rows('projects')\n }\n}\n\n/** The form as `saveMyEmployeeProfile` takes it - the whole profile. */\nexport function profileInputOf(form: ProfileForm) {\n return {\n summary: form.summary,\n skills: skillsOf(form.skills),\n educations: form.educations,\n certificates: form.certificates,\n careers: form.careers,\n projects: form.projects\n }\n}\n"]}
1
+ {"version":3,"file":"employee-profile-form.js","sourceRoot":"","sources":["../../../client/pages/employee-profile/employee-profile-form.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * What the profile screen edits (ADR-0169 decision 2): the four sections of rows, each with its fields and the input a\n * field takes. Pure, so the shape the screen sends is read by the tests against what the server keeps.\n */\n\n/** A row as the screen edits it - the fields, and `ongoing` for a job or project still going on (never sent). */\nexport type ProfileRow = Record<string, string>\n\nexport type ProfileSectionKey = 'educations' | 'certificates' | 'careers' | 'projects'\n\nexport type ProfileField = {\n name: string\n /** The translation key of its label. */\n label: string\n /** `month` for a period's ends (YYYY-MM), `date` for a day, `textarea` for what was done, text otherwise. */\n input?: 'month' | 'date' | 'textarea'\n}\n\nconst label = (name: string) => `label.employee-profile-${name}`\n\nexport type ProfileSection = {\n key: ProfileSectionKey\n title: string\n /** The icon the section is shown with. */\n icon: string\n /** The fields a row's one-line headline is made of, in order. */\n headline: string[]\n /** Whether a row may still be going on - a job held now, a project under way. */\n ongoing?: boolean\n fields: ProfileField[]\n}\n\nexport const PROFILE_SECTIONS: ProfileSection[] = [\n {\n key: 'educations',\n title: label('educations'),\n icon: 'school',\n headline: ['school', 'major', 'degree'],\n fields: [\n { name: 'school', label: label('school') },\n { name: 'major', label: label('major') },\n { name: 'degree', label: label('degree') },\n { name: 'startedOn', label: label('started-on'), input: 'month' },\n { name: 'endedOn', label: label('ended-on'), input: 'month' }\n ]\n },\n {\n key: 'certificates',\n title: label('certificates'),\n icon: 'workspace_premium',\n headline: ['name', 'issuer'],\n fields: [\n { name: 'name', label: label('certificate') },\n { name: 'issuer', label: label('issuer') },\n { name: 'acquiredOn', label: label('acquired-on'), input: 'date' }\n ]\n },\n {\n key: 'careers',\n title: label('careers'),\n icon: 'work_history',\n headline: ['company', 'department', 'position'],\n ongoing: true,\n fields: [\n { name: 'company', label: label('company') },\n { name: 'department', label: 'label.department' },\n { name: 'position', label: label('position') },\n { name: 'startedOn', label: label('started-on'), input: 'month' },\n { name: 'endedOn', label: label('ended-on'), input: 'month' },\n { name: 'duties', label: label('duties'), input: 'textarea' }\n ]\n },\n {\n key: 'projects',\n title: label('projects'),\n icon: 'rocket_launch',\n headline: ['name', 'client', 'role'],\n ongoing: true,\n fields: [\n { name: 'name', label: label('project') },\n { name: 'client', label: label('client') },\n { name: 'role', label: label('role') },\n { name: 'technologies', label: label('technologies') },\n { name: 'startedOn', label: label('started-on'), input: 'month' },\n { name: 'endedOn', label: label('ended-on'), input: 'month' },\n { name: 'duties', label: label('duties'), input: 'textarea' }\n ]\n }\n]\n\n/** Skills as typed - split at commas and line ends, blanks and repeats dropped, added after those already held. */\nexport function addSkills(held: string[], text: string): string[] {\n const next = [...held]\n for (const one of text.split(/[,\\n]/).map(word => word.trim())) {\n if (one && !next.some(skill => skill.toLowerCase() === one.toLowerCase())) next.push(one)\n }\n return next\n}\n\nexport type ProfileForm = { summary: string; skills: string[] } & Record<ProfileSectionKey, ProfileRow[]>\n\n/** An empty row of a section, to add. */\nexport function blankRow(section: ProfileSection): ProfileRow {\n return Object.fromEntries([...section.fields.map(field => [field.name, '']), ...(section.ongoing ? [['ongoing', '']] : [])])\n}\n\n/** The held profile as the form edits it - a profile not yet written is an empty form. */\nexport function profileFormOf(profile: any): ProfileForm {\n const rows = (section: ProfileSection) =>\n ((profile?.[section.key] ?? []) as any[]).map(row => ({\n ...Object.fromEntries(section.fields.map(field => [field.name, row?.[field.name] ?? ''])),\n /* Begun and not ended: still going on. */\n ...(section.ongoing ? { ongoing: row?.startedOn && !row?.endedOn ? 'true' : '' } : {})\n }))\n const sectionOf = (key: ProfileSectionKey) => PROFILE_SECTIONS.find(section => section.key === key)!\n return {\n summary: profile?.summary ?? '',\n skills: [...(profile?.skills ?? [])],\n educations: rows(sectionOf('educations')),\n certificates: rows(sectionOf('certificates')),\n careers: rows(sectionOf('careers')),\n projects: rows(sectionOf('projects'))\n }\n}\n\n/** The form as `saveMyEmployeeProfile` takes it - the whole profile, the section's fields only; a row going on has no end. */\nexport function profileInputOf(form: ProfileForm) {\n const rows = (section: ProfileSection) =>\n form[section.key].map(row => {\n const kept = Object.fromEntries(section.fields.map(field => [field.name, row[field.name] ?? '']))\n return section.ongoing && row.ongoing ? { ...kept, endedOn: '' } : kept\n })\n const sectionOf = (key: ProfileSectionKey) => PROFILE_SECTIONS.find(section => section.key === key)!\n return {\n summary: form.summary,\n skills: form.skills,\n educations: rows(sectionOf('educations')),\n certificates: rows(sectionOf('certificates')),\n careers: rows(sectionOf('careers')),\n projects: rows(sectionOf('projects'))\n }\n}\n\n/** A row in one line: the headline fields that are written, joined. */\nexport function headlineOf(section: ProfileSection, row: ProfileRow): string {\n return section.headline.map(name => (row[name] ?? '').trim()).filter(Boolean).join(' · ')\n}\n\n/** 「2023.03 ~ 2024.08」, 「2024.01 ~ 현재」 - a row's period as the document prints it. */\nexport function periodLabelOf(row: ProfileRow, ongoing: string): string {\n const dot = (day?: string) => (day ?? '').replace(/-/g, '.')\n if (row.acquiredOn !== undefined && row.startedOn === undefined) return dot(row.acquiredOn)\n if (!row.startedOn && !row.endedOn && !row.ongoing) return ''\n return `${dot(row.startedOn)} ~ ${row.ongoing ? ongoing : dot(row.endedOn)}`.trim()\n}\n\n/**\n * What a profile a quotation can stand on has: a summary, skills, education and projects. Past jobs and certificates\n * count when written but are not asked of everyone - a first job has none.\n */\nexport const PROFILE_ESSENTIALS = ['summary', 'skills', 'educations', 'projects'] as const\n\nexport type ProfileEssential = (typeof PROFILE_ESSENTIALS)[number]\n\nexport function completenessOf(form: ProfileForm): { percent: number; missing: ProfileEssential[] } {\n const has = (key: ProfileEssential) => (key === 'summary' ? !!form.summary.trim() : form[key].length > 0)\n const missing = PROFILE_ESSENTIALS.filter(key => !has(key))\n return { percent: Math.round(((PROFILE_ESSENTIALS.length - missing.length) / PROFILE_ESSENTIALS.length) * 100), missing }\n}\n"]}
@@ -1,15 +1,21 @@
1
1
  import '@operato/data-grist/ox-grist.js';
2
+ import '@operato/input/ox-select.js';
3
+ import '@operato/popup/ox-popup-list.js';
2
4
  import { FetchOption } from '@operato/data-grist';
3
5
  import { PageView } from '@operato/shell';
6
+ import { type Language } from './employee-profile-files';
4
7
  declare const EmployeeProfileListPage_base: (new (...args: any[]) => import("lit").LitElement) & typeof PageView;
5
8
  /**
6
9
  * 인력 프로필 — every active employee and when their profile was last written; the rows picked are downloaded as one
7
- * workbook, a sheet a person, to attach to a quotation (ADR-0169 decision 5). Read under `employee-profile`: the
8
- * basics and the profile, nothing else of the employee record.
10
+ * document to attach to a quotation - Excel or PDF, laid out once on the server (ADR-0169 decision 5). Read under
11
+ * `employee-profile`: the basics and the profile, nothing else of the employee record.
9
12
  */
10
13
  export declare class EmployeeProfileListPage extends EmployeeProfileListPage_base {
11
14
  static styles: import("lit").CSSResult[];
12
15
  gristConfig: any;
16
+ /** The languages the application runs, and the one the document's own words are printed in - the reader's at first. */
17
+ languages: Language[];
18
+ language: string;
13
19
  private grist;
14
20
  get context(): {
15
21
  title: string;
@@ -17,14 +23,19 @@ export declare class EmployeeProfileListPage extends EmployeeProfileListPage_bas
17
23
  handler: (search: string) => void;
18
24
  value: string;
19
25
  };
20
- actions: {
26
+ actions: ({
27
+ title: string;
28
+ icon: string;
29
+ action: () => void;
30
+ emphasis?: undefined;
31
+ } | {
21
32
  title: string;
22
33
  icon: string;
23
34
  emphasis: {
24
35
  raised: boolean;
25
36
  };
26
- action: () => void;
27
- }[];
37
+ action: () => Promise<void>;
38
+ })[];
28
39
  };
29
40
  render(): import("lit-html").TemplateResult<1>;
30
41
  pageInitialized(): Promise<void>;
@@ -33,6 +44,9 @@ export declare class EmployeeProfileListPage extends EmployeeProfileListPage_bas
33
44
  total: number;
34
45
  records: any[];
35
46
  }>;
47
+ /** The ids of the rows picked - or nothing, having said so. */
48
+ private picked;
49
+ private preview;
36
50
  private download;
37
51
  }
38
52
  export {};
@@ -1,50 +1,20 @@
1
1
  import { __decorate, __metadata } from "tslib";
2
2
  import '@operato/data-grist/ox-grist.js';
3
+ import '@operato/input/ox-select.js';
4
+ import '@operato/popup/ox-popup-list.js';
3
5
  import gql from 'graphql-tag';
4
- import { html } from 'lit';
6
+ import { css, html } from 'lit';
5
7
  import { customElement, query, state } from 'lit/decorators.js';
6
- import * as XLSX from 'xlsx';
7
8
  import { DataGrist } from '@operato/data-grist';
8
9
  import { client } from '@operato/graphql';
9
10
  import { i18next, localize } from '@operato/i18n';
10
11
  import { notify } from '@operato/layout';
11
12
  import { PageView } from '@operato/shell';
12
- import { CommonGristStyles, ScrollbarStyles, WorkSurfacePageStyles } from '@operato/styles';
13
+ import { CommonGristStyles, CommonHeaderStyles, ScrollbarStyles, WorkSurfacePageStyles } from '@operato/styles';
13
14
  import { isMobileDevice } from '@operato/utils';
14
- import { profileFileName, profileWorkbook } from './employee-profile-workbook';
15
- /** The sheet's words in the reader's language. */
16
- function sheetWords() {
17
- const t = (key) => i18next.t(key);
18
- const p = (name) => t(`label.employee-profile-${name}`);
19
- return {
20
- name: t('field.name'),
21
- department: t('label.department'),
22
- jobPosition: t('label.job-position'),
23
- jobResponsibility: t('label.job-responsibility'),
24
- hiredOn: t('label.hired-on'),
25
- summary: p('summary'),
26
- skills: p('skills'),
27
- educations: p('educations'),
28
- certificates: p('certificates'),
29
- careers: p('careers'),
30
- projects: p('projects'),
31
- school: p('school'),
32
- major: p('major'),
33
- degree: p('degree'),
34
- period: p('period'),
35
- certificate: p('certificate'),
36
- issuer: p('issuer'),
37
- acquiredOn: p('acquired-on'),
38
- company: p('company'),
39
- position: p('position'),
40
- duties: p('duties'),
41
- project: p('project'),
42
- client: p('client'),
43
- role: p('role'),
44
- technologies: p('technologies')
45
- };
46
- }
47
- /** A profile's last change as a day of the reader's calendar. */
15
+ import { getLanguages } from '@things-factory/auth-base/dist-client';
16
+ import { profileFile, readerLanguageOf, saveProfileFile } from './employee-profile-files';
17
+ import { openProfilePreview } from './employee-profile-preview';
48
18
  const dayOf = (value) => {
49
19
  if (!value)
50
20
  return '';
@@ -54,11 +24,35 @@ const dayOf = (value) => {
54
24
  };
55
25
  /**
56
26
  * 인력 프로필 — every active employee and when their profile was last written; the rows picked are downloaded as one
57
- * workbook, a sheet a person, to attach to a quotation (ADR-0169 decision 5). Read under `employee-profile`: the
58
- * basics and the profile, nothing else of the employee record.
27
+ * document to attach to a quotation - Excel or PDF, laid out once on the server (ADR-0169 decision 5). Read under
28
+ * `employee-profile`: the basics and the profile, nothing else of the employee record.
59
29
  */
60
30
  let EmployeeProfileListPage = class EmployeeProfileListPage extends localize(i18next)(PageView) {
61
- static { this.styles = [WorkSurfacePageStyles, CommonGristStyles, ScrollbarStyles]; }
31
+ constructor() {
32
+ super(...arguments);
33
+ /** The languages the application runs, and the one the document's own words are printed in - the reader's at first. */
34
+ this.languages = [];
35
+ this.language = '';
36
+ }
37
+ static { this.styles = [
38
+ WorkSurfacePageStyles,
39
+ CommonGristStyles,
40
+ CommonHeaderStyles,
41
+ ScrollbarStyles,
42
+ css `
43
+ .filters {
44
+ display: flex;
45
+ flex-wrap: wrap;
46
+ align-items: center;
47
+ gap: var(--spacing-medium, 8px);
48
+ font-size: var(--ui-t-small, 11.5px);
49
+ color: var(--ui-muted, var(--md-sys-color-on-surface-variant));
50
+ }
51
+ ox-select {
52
+ min-width: 140px;
53
+ }
54
+ `
55
+ ]; }
62
56
  get context() {
63
57
  return {
64
58
  title: i18next.t('title.employee-profile-list'),
@@ -68,18 +62,42 @@ let EmployeeProfileListPage = class EmployeeProfileListPage extends localize(i18
68
62
  },
69
63
  value: this.grist?.searchText || ''
70
64
  },
71
- actions: [{ title: i18next.t('button.employee-profile-download'), icon: 'download', emphasis: { raised: true }, action: () => this.download() }]
65
+ actions: [
66
+ { title: i18next.t('button.employee-profile-preview'), icon: 'preview', action: () => this.preview() },
67
+ { title: i18next.t('button.employee-profile-download-pdf'), icon: 'picture_as_pdf', action: () => this.download('PDF') },
68
+ { title: i18next.t('button.employee-profile-download'), icon: 'download', emphasis: { raised: true }, action: () => this.download('XLSX') }
69
+ ]
72
70
  };
73
71
  }
74
72
  render() {
73
+ const shown = this.languages.find(one => one.code === this.language)?.display ?? this.language;
75
74
  return html `<ox-grist
76
75
  class="page-fill"
77
76
  .mode=${isMobileDevice() ? 'CARD' : 'GRID'}
78
77
  .config=${this.gristConfig}
79
78
  .fetchHandler=${this.fetchHandler.bind(this)}
80
- ></ox-grist>`;
79
+ >
80
+ ${this.languages.length > 1
81
+ ? html `<div slot="headroom" class="header">
82
+ <div class="filters">
83
+ <span>${i18next.t('label.employee-profile-document-language')}</span>
84
+ <ox-select
85
+ .value=${this.language}
86
+ aria-label=${i18next.t('label.employee-profile-document-language')}
87
+ @change=${(e) => (this.language = String(e.detail || this.language))}
88
+ >
89
+ <span slot="label">${shown}</span>
90
+ <ox-popup-list align-left nowrap with-search>${this.languages.map(one => html `<div option value=${one.code}>${one.display}</div>`)}</ox-popup-list>
91
+ </ox-select>
92
+ </div>
93
+ </div>`
94
+ : ''}
95
+ </ox-grist>`;
81
96
  }
82
97
  async pageInitialized() {
98
+ /* The reader's language first - matched to a code the application runs, by its language part when the region differs. */
99
+ this.languages = ((await getLanguages()) ?? []);
100
+ this.language = readerLanguageOf(this.languages);
83
101
  const text = (name, header, width, extra = {}) => ({
84
102
  type: 'string',
85
103
  name,
@@ -140,19 +158,43 @@ let EmployeeProfileListPage = class EmployeeProfileListPage extends localize(i18
140
158
  .filter(one => !words || [one.name, one.departmentName, one.jobPosition, one.jobResponsibility].some(value => value?.toLowerCase().includes(words)));
141
159
  return { total: records.length, records };
142
160
  }
143
- download() {
161
+ /** The ids of the rows picked - or nothing, having said so. */
162
+ picked() {
144
163
  const picked = this.grist?.selected ?? [];
145
- if (!picked.length) {
146
- notify({ level: 'warn', message: i18next.t('text.employee-profile-select-rows-first') });
164
+ if (picked.length)
165
+ return picked.map((one) => one.employeeId);
166
+ notify({ level: 'warn', message: i18next.t('text.employee-profile-select-rows-first') });
167
+ return null;
168
+ }
169
+ preview() {
170
+ const employeeIds = this.picked();
171
+ if (employeeIds)
172
+ openProfilePreview({ employeeIds }, this.languages, this.language);
173
+ }
174
+ async download(format) {
175
+ const employeeIds = this.picked();
176
+ if (!employeeIds)
147
177
  return;
178
+ try {
179
+ saveProfileFile(await profileFile({ employeeIds }, format, this.language));
180
+ }
181
+ catch (error) {
182
+ notify({ level: 'error', message: error?.message ?? String(error) });
148
183
  }
149
- XLSX.writeFile(profileWorkbook(picked, sheetWords()), profileFileName(i18next.t('text.employee-profile-file')));
150
184
  }
151
185
  };
152
186
  __decorate([
153
187
  state(),
154
188
  __metadata("design:type", Object)
155
189
  ], EmployeeProfileListPage.prototype, "gristConfig", void 0);
190
+ __decorate([
191
+ state(),
192
+ __metadata("design:type", Array)
193
+ ], EmployeeProfileListPage.prototype, "languages", void 0);
194
+ __decorate([
195
+ state(),
196
+ __metadata("design:type", Object)
197
+ ], EmployeeProfileListPage.prototype, "language", void 0);
156
198
  __decorate([
157
199
  query('ox-grist'),
158
200
  __metadata("design:type", DataGrist)