@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/organization",
3
- "version": "10.1.226",
3
+ "version": "10.1.228",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "dist-client/index.js",
6
6
  "things-factory": true,
@@ -36,11 +36,12 @@
36
36
  "@operato/p13n": "^10.0.0",
37
37
  "@operato/popup": "^10.15.0",
38
38
  "@operato/shell": "^10.0.0",
39
- "@things-factory/auth-base": "^10.1.226",
40
- "@things-factory/code-ui": "^10.1.226",
41
- "@things-factory/contact": "^10.1.226",
42
- "@things-factory/shell": "^10.1.208",
43
- "xlsx": "^0.18.5"
39
+ "@things-factory/auth-base": "^10.1.228",
40
+ "@things-factory/code-ui": "^10.1.228",
41
+ "@things-factory/contact": "^10.1.228",
42
+ "@things-factory/pdf": "^10.1.228",
43
+ "@things-factory/shell": "^10.1.228",
44
+ "exceljs": "4.2.0"
44
45
  },
45
- "gitHead": "abc5cf99effa2de579a1cbfda74ddc5eb41dba50"
46
+ "gitHead": "fc4ede44617418df8701e83b83d177db4142a8f3"
46
47
  }
@@ -123,6 +123,20 @@ describe("an employee's own profile", () => {
123
123
  expect(await dataSource.getRepository(organization.EmployeeProfile).count()).toBe(1)
124
124
  })
125
125
 
126
+ it('★ adding from a résumé takes nothing away, and reaches only one\'s own', async () => {
127
+ const kim = await person('kim')
128
+ const park = await person('park')
129
+ await ask(park, SAVE, { profile: { summary: 'park' } })
130
+ await ask(kim, SAVE, { profile: { summary: '옛 요약', skills: ['TypeScript'], projects: [{ name: 'A' }] } })
131
+ const ADD = `mutation ($profile: EmployeeProfileInput!) { addToMyEmployeeProfile(profile: $profile) { ${ROWS} } }`
132
+ const added = await ask(kim, ADD, { profile: { skills: ['typescript', 'PLC'], projects: [{ name: 'A' }, { name: 'B', role: 'PM' }] } })
133
+ expect(added.errors).toBeUndefined()
134
+ expect(added.data.addToMyEmployeeProfile).toMatchObject({ summary: '옛 요약', skills: ['TypeScript', 'PLC'], projects: [{ name: 'A' }, { name: 'B', role: 'PM' }] })
135
+ expect((await ask(park, MINE)).data.myEmployeeProfile.summary).toBe('park')
136
+ const stranger = await person('stranger', null)
137
+ expect((await ask(stranger, ADD, { profile: { summary: 'x' } })).errors?.[0]?.extensions?.requires).toBe('no-employee')
138
+ })
139
+
126
140
  it("★ one employee's save never touches another's profile", async () => {
127
141
  const kim = await person('kim')
128
142
  const park = await person('park')
@@ -151,6 +165,48 @@ describe('the profiles of every active employee', () => {
151
165
  expect(list.data.employeeProfiles[0]).toMatchObject({ jobPosition: '과장', jobResponsibility: '팀장', hiredOn: '2020-03-02', profile: { skills: ['A'] } })
152
166
  })
153
167
 
168
+ it('★ the document of the chosen: active ones only, in the order chosen, as an Excel workbook', async () => {
169
+ const kim = await person('kim')
170
+ await person('lee')
171
+ await person('gone', { retiredOn: '2020-01-01' })
172
+ const ids = (await ask(kim, LIST)).data.employeeProfiles.map((one: any) => one.employeeId)
173
+ const goneId = (await dataSource.getRepository(organization.Employee).findOneBy({ name: 'gone' })).id
174
+ const answer = await ask(kim, `query ($ids: [String!]!) { employeeProfileDocument(employeeIds: $ids, format: XLSX) { fileName mimeType content } }`, { ids: [ids[1], goneId, ids[0]] })
175
+ expect(answer.errors).toBeUndefined()
176
+ const file = answer.data.employeeProfileDocument
177
+ expect(file.fileName).toMatch(/^text\.employee-profile-file-\d{8}\.xlsx$/)
178
+ expect(file.mimeType).toBe('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
179
+ const { Workbook } = require('exceljs')
180
+ const book = new Workbook()
181
+ await book.xlsx.load(Buffer.from(file.content, 'base64'))
182
+ expect(book.worksheets.map((sheet: any) => sheet.name)).toEqual(['Text.employee-profile-doc-roste', 'lee', 'kim'])
183
+ })
184
+
185
+ it('the preview is an HTML page of the same people', async () => {
186
+ const kim = await person('kim')
187
+ const ids = (await ask(kim, LIST)).data.employeeProfiles.map((one: any) => one.employeeId)
188
+ const answer = await ask(kim, `query ($ids: [String!]!) { employeeProfileDocument(employeeIds: $ids, format: HTML) { fileName mimeType content } }`, { ids })
189
+ expect(answer.errors).toBeUndefined()
190
+ const file = answer.data.employeeProfileDocument
191
+ expect(file.mimeType).toBe('text/html')
192
+ expect(file.fileName).toMatch(/\.html$/)
193
+ expect(Buffer.from(file.content, 'base64').toString('utf8')).toContain('<td class="name" colspan="6">kim</td>')
194
+ })
195
+
196
+ it("★ the document's own words follow the language asked for - one the application runs - or the reader's", () => {
197
+ const { documentTranslator } = loadCompiled('service/employee-profile/employee-profile-query')
198
+ const env = require('@things-factory/env')
199
+ const runs = jest.spyOn(env.config, 'get').mockImplementation((key: string, fallback: any) => (key === 'i18n/languages' ? [{ code: 'ko-KR' }, { code: 'en-US' }] : fallback))
200
+ try {
201
+ const context = { t: (key: string) => `reader:${key}`, i18next: { getFixedT: (lng: string) => (key: string) => `${lng}:${key}` } }
202
+ expect(documentTranslator(context, null)('text.employee-profile-doc-title')).toBe('reader:text.employee-profile-doc-title')
203
+ expect(documentTranslator(context, 'en-US')('text.employee-profile-doc-title')).toBe('en-US:text.employee-profile-doc-title')
204
+ expect(() => documentTranslator(context, 'fr-FR')).toThrow('does not run the language "fr-FR"')
205
+ } finally {
206
+ runs.mockRestore()
207
+ }
208
+ })
209
+
154
210
  it('★ reaching a profile does not reach the employee record behind it', () => {
155
211
  const type = schema.getType('EmployeeProfile').getFields()
156
212
  expect(Object.keys(type)).not.toContain('employee')
@@ -172,10 +228,15 @@ describe('what each door asks', () => {
172
228
  expect(asks(source('employee-profile-query.ts'), 'myEmployeeProfile')).toEqual(['employee-self', 'query', 'true'])
173
229
  expect(asks(source('employee-profile-query.ts'), 'employeeProfiles')).toEqual(['employee-profile', 'query', 'true'])
174
230
  const mutation = source('employee-profile-mutation.ts')
175
- expect(asks(mutation, 'saveMyEmployeeProfile')).toEqual(['employee-self', 'mutation', 'true'])
176
- expect(mutation).toMatch(/@Directive\('@transaction'\)\n @Directive\('@privilege\(category: "employee-self", privilege: "mutation"/)
177
- expect([...mutation.matchAll(/@Mutation\(/g)]).toHaveLength(1)
178
- expect(mutation).toMatch(/requireMyEmployee\(context\)/)
231
+ /* Two doors write - saving the whole and adding from a résumé - and each finds the profile from the caller alone. */
232
+ expect([...mutation.matchAll(/@Mutation\(/g)]).toHaveLength(2)
233
+ for (const door of ['saveMyEmployeeProfile', 'addToMyEmployeeProfile']) {
234
+ expect(asks(mutation, door)).toEqual(['employee-self', 'mutation', 'true'])
235
+ const body = mutation.slice(mutation.indexOf(`async ${door}(`)).split('\n }')[0]
236
+ expect(body).toMatch(/requireMyEmployee\(context\)/)
237
+ expect(body).not.toMatch(/@Arg\('(employee|employeeId|user|userId)'/)
238
+ }
239
+ expect([...mutation.matchAll(/@Directive\('@transaction'\)\n @Directive\('@privilege\(category: "employee-self", privilege: "mutation"/g)]).toHaveLength(2)
179
240
  })
180
241
 
181
242
  it('★ the tasks grant them: own-profile to write one\'s own, profiles-reading to read everyone\'s', () => {
@@ -0,0 +1,219 @@
1
+ import { Workbook } from 'exceljs'
2
+
3
+ import {
4
+ careerLengthOf,
5
+ careerMonths,
6
+ heightOf,
7
+ rosterTaglineOf,
8
+ taglineOf,
9
+ dotted,
10
+ layoutHtml,
11
+ periodOf,
12
+ profileFileName,
13
+ profileFooterHtml,
14
+ profileHtml,
15
+ profilePreviewHtml,
16
+ profileSheetLayout,
17
+ profileWorkbook,
18
+ profileWordsOf,
19
+ lead,
20
+ rosterLayout,
21
+ sheetNamesOf,
22
+ type ProfileSheetWords
23
+ } from '../server/service/employee-profile/employee-profile-document'
24
+
25
+ /** The profiles a quotation attaches - one layout, painted as Excel and as HTML for PDF (ADR-0169 decision 5). */
26
+ const COUNTS: Record<string, (...args: number[]) => string> = {
27
+ years: (years, months) => `${years}y${months}m`,
28
+ projectCount: n => `${n}p`,
29
+ certificateCount: n => `${n}c`,
30
+ peopleCount: n => `${n}people`
31
+ }
32
+ const WORDS = new Proxy({} as any, { get: (_target, key) => COUNTS[String(key)] ?? `<${String(key)}>` }) as ProfileSheetWords
33
+
34
+ const TODAY = new Date('2026-10-28T00:00:00Z')
35
+
36
+ const KIM = {
37
+ name: '김하늘',
38
+ departmentName: '개발팀',
39
+ jobPosition: '과장',
40
+ jobResponsibility: '팀장',
41
+ hiredOn: '2020-03-02',
42
+ profile: {
43
+ summary: 'MES 10년',
44
+ skills: ['TypeScript', 'PostgreSQL'],
45
+ educations: [{ school: '서울대', major: '컴퓨터공학', degree: '학사', startedOn: '2005-03', endedOn: '2009-02' }],
46
+ certificates: [{ name: '정보처리기사', issuer: '한국산업인력공단', acquiredOn: '2010-05-01' }],
47
+ careers: [{ company: 'A사', department: '연구소', position: '대리', startedOn: '2009-03', endedOn: '2019-12', duties: 'ERP' }],
48
+ projects: [{ name: '미라텍 MES', client: '미라텍', startedOn: '2024-01', role: 'PM', duties: '설계', technologies: 'Node' }]
49
+ }
50
+ }
51
+
52
+ const valuesOf = (line: { cells: { value: string }[] }) => line.cells.map(cell => cell.value)
53
+
54
+ describe('one layout of a staffing profile', () => {
55
+ const cellsOf = (lines: ReturnType<typeof profileSheetLayout>, role: string) => lines.flatMap(line => line.cells.filter(cell => cell.role === role).map(cell => cell.value))
56
+
57
+ it('★ the name large with where the person stands, then the figures a buyer weighs', () => {
58
+ const lines = profileSheetLayout(KIM, WORDS, '(주)하티오랩', TODAY)
59
+ expect(valuesOf(lines[0])).toEqual(['(주)하티오랩', '<title>'])
60
+ expect(lines[1].cells).toEqual([{ col: 1, span: 6, value: '김하늘', role: 'name' }])
61
+ expect(valuesOf(lines[2])).toEqual(['개발팀 · 과장 / 팀장 · <hiredOn> 2020.03.02'])
62
+ expect(lines[3].cells[0].role).toBe('rule')
63
+ expect(cellsOf(lines, 'statLabel')).toEqual(['<careerLength>', '<projectsDone>', '<certificatesHeld>'])
64
+ expect(cellsOf(lines, 'statValue')).toEqual([careerLengthOf(KIM, WORDS, TODAY), '1p', '1c'])
65
+ })
66
+
67
+ it('★ the expertise, then the projects first - careers, certificates and education after; no line to sign', () => {
68
+ const lines = profileSheetLayout(KIM, WORDS, '(주)하티오랩', TODAY)
69
+ expect(cellsOf(lines, 'section')).toEqual(['<expertise>', '<projects>', '<careers>', '<certificates>', '<educations>'])
70
+ expect(cellsOf(lines, 'summary')).toEqual(['MES 10년'])
71
+ expect(cellsOf(lines, 'skills')).toEqual(['TypeScript, PostgreSQL'])
72
+ const rowAfter = (section: string) => valuesOf(lines[lines.findIndex(line => line.cells[0]?.value === section && line.cells[0].role === 'section') + 3])
73
+ expect(rowAfter('<projects>')).toEqual(['2024.01 ~ <ongoing>', '미라텍 MES', '미라텍', 'PM', '설계', 'Node'])
74
+ expect(rowAfter('<careers>')).toEqual(['2009.03 ~ 2019.12', 'A사', '연구소', '대리', 'ERP'])
75
+ expect(rowAfter('<certificates>')).toEqual(['2010.05.01', '정보처리기사', '한국산업인력공단'])
76
+ expect(rowAfter('<educations>')).toEqual(['2005.03 ~ 2009.02', '서울대', '컴퓨터공학', '학사'])
77
+ expect(JSON.stringify(lines)).not.toMatch(/affirmation|signature/)
78
+ })
79
+
80
+ it('every other row of a table is shaded', () => {
81
+ const two = { ...KIM, profile: { ...KIM.profile, projects: [KIM.profile.projects[0], KIM.profile.projects[0], KIM.profile.projects[0]] } }
82
+ const lines = profileSheetLayout(two, WORDS, '', TODAY)
83
+ const rows = lines.filter(line => line.cells[0]?.role === 'value' && line.cells.length === 6)
84
+ expect(rows.map(line => !!line.zebra)).toEqual([false, true, false])
85
+ })
86
+
87
+ it('a person with nothing written: dashes for the figures, no summary box, every table says so', () => {
88
+ const lines = profileSheetLayout({ name: '박지민', profile: null }, WORDS, '', TODAY)
89
+ expect(cellsOf(lines, 'statValue')).toEqual(['—', '—', '—'])
90
+ expect(cellsOf(lines, 'summary')).toEqual([])
91
+ expect(cellsOf(lines, 'none')).toEqual(['<none>', '<none>', '<none>', '<none>'])
92
+ expect(taglineOf({ name: '박지민' }, WORDS)).toBe('')
93
+ })
94
+
95
+ it('★ the career counts the months worked once - earlier jobs and the time here, overlaps not twice', () => {
96
+ expect(careerMonths({ hiredOn: '2026-01-15' }, TODAY)).toBe(10)
97
+ expect(careerMonths({ hiredOn: '2025-10-01', profile: { careers: [{ startedOn: '2025-01', endedOn: '2025-12' }] } }, TODAY)).toBe(22)
98
+ expect(careerMonths({ profile: { careers: [{ startedOn: '2024-01' }] } }, TODAY)).toBe(34)
99
+ expect(careerMonths({ profile: { careers: [{ endedOn: '2020-01' }] } }, TODAY)).toBe(0)
100
+ expect(careerLengthOf(KIM, WORDS, TODAY)).toBe(`${Math.floor(careerMonths(KIM, TODAY) / 12)}y${careerMonths(KIM, TODAY) % 12}m`)
101
+ expect(careerLengthOf({}, WORDS, TODAY)).toBe('')
102
+ })
103
+
104
+ it('periods and days in the dotted form, an open end still going on', () => {
105
+ expect(dotted('2023-03')).toBe('2023.03')
106
+ expect(periodOf('2024-01', '', 'now')).toBe('2024.01 ~ now')
107
+ expect(periodOf('', '2024-01', 'now')).toBe('~ 2024.01')
108
+ expect(periodOf(undefined, undefined, 'now')).toBe('')
109
+ })
110
+
111
+ it('a roster: how many and their experience on average, then one line a person in the order picked', () => {
112
+ const lines = rosterLayout([KIM, { name: '박지민', hiredOn: '2025-10-01' }], WORDS, 'C', TODAY)
113
+ expect(valuesOf(lines[0])).toEqual(['C', '<writtenOn> 2026.10.28'])
114
+ expect(valuesOf(lines[1])).toEqual(['<rosterTitle>'])
115
+ const average = Math.round((careerMonths(KIM, TODAY) + 13) / 2)
116
+ expect(valuesOf(lines[2])).toEqual([`2people · <averageCareer> ${Math.floor(average / 12)}y${average % 12}m`])
117
+ expect(valuesOf(lines[5])).toEqual(['<number>', '<name>', '<department>', '<jobPosition>', '<careerLength>', '<skills>'])
118
+ expect(lines.slice(6).map(line => valuesOf(line).slice(0, 2))).toEqual([['1', '김하늘'], ['2', '박지민']])
119
+ expect(rosterTaglineOf([], WORDS, TODAY)).toBe('0people')
120
+ })
121
+ })
122
+
123
+ describe('painted as Excel', () => {
124
+ it('★ a ruled line is as tall as its longest cell wraps - Excel does not grow it for written text', () => {
125
+ const one = heightOf({ cells: [{ col: 1, value: '2012.03 ~ 2019.02', role: 'value' }, { col: 2, span: 5, value: '짧음', role: 'value' }] })!
126
+ const wrapped = heightOf({ cells: [{ col: 5, value: '요구 분석, 설계, 일정·인력 관리와 고객 보고', role: 'value' }] })!
127
+ expect(one).toBe(22)
128
+ expect(wrapped).toBeGreaterThan(one)
129
+ expect(heightOf({ cells: [{ col: 1, span: 6, value: 'x', role: 'name' }] })).toBeUndefined()
130
+ expect(heightOf({ cells: [{ col: 1, value: 'a\nb\nc', role: 'value' }] })).toBe(52)
131
+ expect(heightOf({ cells: [{ col: 1, span: 6, value: '짧은 소개', role: 'summary' }] })).toBe(30)
132
+ })
133
+
134
+ async function readBack(people: any[]) {
135
+ const buffer = await profileWorkbook(people, WORDS, '(주)하티오랩', TODAY).xlsx.writeBuffer()
136
+ const book = new Workbook()
137
+ await book.xlsx.load(buffer as any)
138
+ return book
139
+ }
140
+
141
+ it('★ several people open with the roster, then a sheet a person in the order picked - read back from the file', async () => {
142
+ const book = await readBack([KIM, { name: '박지민' }])
143
+ expect(book.worksheets.map(sheet => sheet.name)).toEqual(['<rosterTitle>', '김하늘', '박지민'])
144
+ expect((await readBack([KIM])).worksheets.map(sheet => sheet.name)).toEqual(['김하늘'])
145
+ })
146
+
147
+ it('★ the title spans the page, labels and heads are shaded and ruled, values ruled and wrapped, A4 to one page wide', async () => {
148
+ const sheet = (await readBack([KIM])).getWorksheet('김하늘')!
149
+ expect(sheet.getCell('A2').value).toBe('김하늘')
150
+ expect(sheet.getCell('A2').font).toMatchObject({ bold: true, size: 22, color: { argb: 'FF1F3A5F' } })
151
+ expect(sheet.getCell('F2').isMerged).toBe(true)
152
+ expect(sheet.getCell('A4').border?.bottom?.style).toBe('thick')
153
+ const rows = sheet.getRows(1, sheet.rowCount)!
154
+ const head = rows.find(row => row.getCell(1).value === '<period>')!
155
+ expect((head.getCell(1).fill as any)?.fgColor?.argb).toBe('FF1F3A5F')
156
+ expect(head.getCell(1).font?.color?.argb).toBe('FFFFFFFF')
157
+ const value = sheet.getRow(head.number + 1).getCell(2)
158
+ expect(value.value).toBe('미라텍 MES')
159
+ expect(value.border?.right?.style).toBe('thin')
160
+ expect(value.alignment).toMatchObject({ wrapText: true, indent: 1 })
161
+ expect(sheet.headerFooter.oddFooter).toBe('&L&8(주)하티오랩 · <title>&R&8&P / &N')
162
+ expect(sheet.pageSetup).toMatchObject({ paperSize: 9, orientation: 'portrait', fitToWidth: 1, fitToHeight: 0 })
163
+ expect(sheet.views[0]).toMatchObject({ showGridLines: false })
164
+ expect(sheet.getColumn(1).width).toBe(18)
165
+ })
166
+
167
+ it('★ sheet names Excel takes: at most 31 characters, none of \\ / ? * [ ] :, no two alike, none the roster has', () => {
168
+ const names = sheetNamesOf(['홍길동', '홍길동', 'HONG', 'hong', 'a/b:c*d?[e]', '', null, 'x'.repeat(40), 'x'.repeat(40), '인력 현황'], ['인력 현황'])
169
+ expect(names.slice(0, 7)).toEqual(['홍길동', '홍길동 (2)', 'HONG', 'hong (2)', 'a b c d e', '#6', '#7'])
170
+ expect(names[7]).toBe('x'.repeat(31))
171
+ expect(names[8]).toBe('x'.repeat(27) + ' (2)')
172
+ expect(names[9]).toBe('인력 현황 (2)')
173
+ })
174
+ })
175
+
176
+ describe('painted as HTML for the PDF', () => {
177
+ it('★ a page a person after the roster, the same cells, spans as colspan, text escaped', () => {
178
+ const html = profileHtml([KIM, { name: '<b>박</b>' }], WORDS, 'C', TODAY)
179
+ expect(html.match(/<section class="sheet">/g)).toHaveLength(3)
180
+ expect(html).toContain('<td class="name" colspan="6">김하늘</td>')
181
+ expect(html).toContain('<span class="tag">TypeScript</span><span class="tag">PostgreSQL</span>')
182
+ expect(html).toMatch(/<tr class="zebra"|<tr style/)
183
+ expect(html).toContain('&lt;b&gt;박&lt;/b&gt;')
184
+ expect(html).not.toContain('<b>박</b>')
185
+ })
186
+
187
+ it('a line keeps its line breaks, an empty line is a gap, a cell left of the first one is filled', () => {
188
+ expect(layoutHtml([{ cells: [{ col: 2, value: 'a\nb', role: 'value' }] }])).toContain('<td colspan="1"></td><td class="value">a<br>b</td><td colspan="4"></td>')
189
+ expect(layoutHtml([{ cells: [] }])).toContain('<tr class="gap">')
190
+ })
191
+
192
+ it('the foot of every PDF page: the employer and the page of how many, a brace in the name opening no template', () => {
193
+ const footer = profileFooterHtml('{{evil}} & Co', WORDS)
194
+ expect(footer).toContain('&#123;&#123;evil&#125;&#125; &amp; Co · &lt;title&gt;')
195
+ expect(footer).toContain('<span class="pageNumber"></span> / <span class="totalPages"></span>')
196
+ })
197
+
198
+ it('★ the preview is the same pages as one document, shown as sheets of A4 on a screen', () => {
199
+ const page = profilePreviewHtml([KIM], WORDS, 'C', TODAY)
200
+ expect(page.startsWith('<!doctype html>')).toBe(true)
201
+ expect(page).toContain(profileHtml([KIM], WORDS, 'C', TODAY))
202
+ expect(page).toMatch(/@media screen \{[\s\S]*\.sheet \{[^}]*width: 210mm/)
203
+ })
204
+
205
+ it('the words head as a document heads - a capital first where the language has one', () => {
206
+ expect(lead('proyectos')).toBe('Proyectos')
207
+ expect(lead('프로젝트 이력')).toBe('프로젝트 이력')
208
+ expect(lead('')).toBe('')
209
+ const words = profileWordsOf(key => (key === 'text.employee-profile-doc-years' ? '{years} años' : 'habilidades'))
210
+ expect(words.skills).toBe('Habilidades')
211
+ expect(words.years(2, 0)).toBe('{years} años')
212
+ })
213
+
214
+ it('the file is named by the word and the day', () => {
215
+ expect(profileFileName('인력프로필', 'xlsx', new Date(2026, 9, 7))).toBe('인력프로필-20261007.xlsx')
216
+ expect(profileFileName('인력프로필', 'pdf', new Date(2026, 9, 7))).toBe('인력프로필-20261007.pdf')
217
+ expect(profileFileName('인력프로필', 'html', new Date(2026, 9, 7))).toBe('인력프로필-20261007.html')
218
+ })
219
+ })
@@ -0,0 +1,72 @@
1
+ /*
2
+ * The person's own profile as the assistant's 입력안 (ADR-0169 decision 10): a résumé read into rows of sections, each
3
+ * section added to one's own profile by one call of addToMyEmployeeProfile, nothing kept taken away.
4
+ */
5
+ import { clearEntryTargets, declareEntryTarget } from '../../ai-assistant/server/service/data-entry/entry-target'
6
+ import { profileAdditionOf, profileEntryIssues, PROFILE_ENTRY_FIELDS } from '../server/ai-entry/employee-profile-entry'
7
+ import { employeeProfileEntryTarget, ORGANIZATION_EMPLOYEE_PROFILE } from '../server/ai-entry/organization-entry-targets'
8
+ import { addedProfileOf } from '../server/service/employee-profile/employee-profile-rules'
9
+
10
+ import en from '../translations/en.json'
11
+ import es from '../translations/es.json'
12
+ import ko from '../translations/ko.json'
13
+
14
+ const row = (values: Record<string, unknown>, index = 0) => ({ index, values, sources: [], issues: [] })
15
+
16
+ describe("the assistant's 입력안 of one's own profile", () => {
17
+ afterEach(() => clearEntryTargets())
18
+
19
+ it('★ is accepted by the assistant as declared - one call of addToMyEmployeeProfile a section', () => {
20
+ const target = employeeProfileEntryTarget()
21
+ expect(() => declareEntryTarget(target as any)).not.toThrow()
22
+ expect(target.key).toBe(ORGANIZATION_EMPLOYEE_PROFILE)
23
+ expect(target.mutation).toMatch(/addToMyEmployeeProfile\(profile: \$profile\)/)
24
+ expect(target.group?.by).toBe('section')
25
+ expect(target.description).toMatch(/OWN/)
26
+ })
27
+
28
+ it('★ the rows of a section become what the door adds - only the section fields', () => {
29
+ expect(profileAdditionOf([row({ section: 'summary', text: ' MES 12년 ' })])).toEqual({ summary: 'MES 12년' })
30
+ expect(profileAdditionOf([row({ section: 'skill', text: 'TypeScript' }), row({ section: 'skill', text: 'PLC' }, 1)])).toEqual({ skills: ['TypeScript', 'PLC'] })
31
+ expect(profileAdditionOf([row({ section: 'project', name: '미라텍 MES', client: '미라텍', startedOn: '2024-01', role: 'PM', school: '섞임' })])).toEqual({
32
+ projects: [{ name: '미라텍 MES', client: '미라텍', startedOn: '2024-01', endedOn: '', role: 'PM', duties: '', technologies: '' }]
33
+ })
34
+ expect(profileAdditionOf([row({ section: 'education', school: '한양대', degree: '학사' })])).toEqual({
35
+ educations: [{ school: '한양대', major: '', degree: '학사', startedOn: '', endedOn: '' }]
36
+ })
37
+ })
38
+
39
+ it('a row without what its section needs is kept out, and so is a second summary', () => {
40
+ const issues = profileEntryIssues([
41
+ row({ section: 'project', client: '미라텍' }),
42
+ row({ section: 'certificate', name: 'PMP' }, 1),
43
+ row({ section: 'summary', text: 'a' }, 2),
44
+ row({ section: 'summary', text: 'b' }, 3)
45
+ ])
46
+ expect(issues[0]).toEqual([{ field: 'name', code: 'required', blocking: true }])
47
+ expect(issues[1]).toEqual([])
48
+ expect(issues[2]).toEqual([])
49
+ expect(issues[3].map(issue => issue.code)).toEqual(['one-summary'])
50
+ })
51
+
52
+ it('★ adding takes nothing away: a summary given replaces, skills and rows are added unless already there', () => {
53
+ const held = { summary: '옛 요약', skills: ['TypeScript'], projects: [{ name: 'A', client: 'X' }], educations: [], certificates: [], careers: [] }
54
+ const added = addedProfileOf(held, { summary: '새 요약', skills: ['typescript', 'PLC'], projects: [{ name: ' a ', client: 'x' }, { name: 'B' }] })
55
+ expect(added).toEqual({ summary: '새 요약', skills: ['TypeScript', 'PLC'], educations: [], certificates: [], careers: [], projects: [{ name: 'A', client: 'X' }, { name: 'B' }] })
56
+ expect(addedProfileOf(held, { skills: ['Go'] }).summary).toBe('옛 요약')
57
+ expect(addedProfileOf(null, { certificates: [{ name: 'PMP' }] }).certificates).toEqual([{ name: 'PMP' }])
58
+ })
59
+
60
+ it('every word the card shows is in the languages the application runs', () => {
61
+ const keys = [
62
+ employeeProfileEntryTarget().labelKey,
63
+ ...PROFILE_ENTRY_FIELDS.map(field => field.labelKey!),
64
+ ...PROFILE_ENTRY_FIELDS.find(field => field.name === 'section')!.values!.map(value => `text.organization-entry-profile-section-${value}`),
65
+ 'text.organization-entry-profile-one-summary'
66
+ ]
67
+ for (const [language, words] of Object.entries({ ko, en, es } as Record<string, Record<string, string>>)) {
68
+ const missing = keys.filter(key => !(key in words) && !['label.department'].includes(key))
69
+ expect([language, missing]).toEqual([language, []])
70
+ }
71
+ })
72
+ })
@@ -1,5 +1,5 @@
1
1
  import { employeesWithProfiles, isActiveEmployee, profileDocumentOf, PROFILE_ROW_FIELDS } from '../server/service/employee-profile/employee-profile-rules'
2
- import { PROFILE_SECTIONS, profileFormOf, profileInputOf, skillsOf } from '../client/pages/employee-profile/employee-profile-form'
2
+ import { addSkills, blankRow, completenessOf, headlineOf, periodLabelOf, PROFILE_SECTIONS, profileFormOf, profileInputOf } from '../client/pages/employee-profile/employee-profile-form'
3
3
 
4
4
  /** The employee's profile is saved whole, as the screen holds it (ADR-0169 decision 2). */
5
5
  describe("an employee's profile as it is saved", () => {
@@ -34,13 +34,40 @@ describe("an employee's profile as it is saved", () => {
34
34
  expect(PROFILE_SECTIONS.map(section => section.key).sort()).toEqual(Object.keys(PROFILE_ROW_FIELDS).sort())
35
35
  })
36
36
 
37
- it('the skills are written in one line and sent as a list', () => {
38
- expect(skillsOf(' Java, Spring ,,\nKotlin ')).toEqual(['Java', 'Spring', 'Kotlin'])
37
+ it('skills are added as typed - split at commas and line ends, no repeat whatever the case', () => {
38
+ expect(addSkills(['Java'], ' java, Spring ,,\nKotlin, spring ')).toEqual(['Java', 'Spring', 'Kotlin'])
39
39
  const form = profileFormOf({ summary: 'S', skills: ['A', 'B'], projects: [{ name: 'P', other: 'x' }] })
40
- expect(form.skills).toBe('A, B')
41
- expect(form.projects).toEqual([{ name: 'P', client: '', role: '', startedOn: '', endedOn: '', technologies: '', duties: '' }])
40
+ expect(form.skills).toEqual(['A', 'B'])
42
41
  expect(profileInputOf(form).skills).toEqual(['A', 'B'])
43
- expect(profileFormOf(null)).toEqual({ summary: '', skills: '', educations: [], certificates: [], careers: [], projects: [] })
42
+ expect(profileFormOf(null)).toEqual({ summary: '', skills: [], educations: [], certificates: [], careers: [], projects: [] })
43
+ })
44
+
45
+ it('★ a job or project begun and not ended is going on; going on is never sent, and sends no end', () => {
46
+ const form = profileFormOf({ projects: [{ name: 'P', startedOn: '2024-01', endedOn: null, other: 'x' }], careers: [{ company: 'A', startedOn: '2020-01', endedOn: '2023-12' }] })
47
+ expect(form.projects).toEqual([{ name: 'P', client: '', role: '', startedOn: '2024-01', endedOn: '', technologies: '', duties: '', ongoing: 'true' }])
48
+ expect(form.careers[0].ongoing).toBe('')
49
+ const sent = profileInputOf({ ...form, careers: [{ ...form.careers[0], ongoing: 'true' }] })
50
+ expect(sent.careers).toEqual([{ company: 'A', department: '', position: '', startedOn: '2020-01', endedOn: '', duties: '' }])
51
+ expect(sent.projects[0]).not.toHaveProperty('ongoing')
52
+ const projects = PROFILE_SECTIONS.find(section => section.key === 'projects')!
53
+ expect(blankRow(projects)).toHaveProperty('ongoing', '')
54
+ expect(blankRow(PROFILE_SECTIONS.find(section => section.key === 'certificates')!)).toEqual({ name: '', issuer: '', acquiredOn: '' })
55
+ })
56
+
57
+ it("a row's headline and period in one line each", () => {
58
+ const projects = PROFILE_SECTIONS.find(section => section.key === 'projects')!
59
+ expect(headlineOf(projects, { name: '미라텍 MES', client: '', role: 'PM' })).toBe('미라텍 MES · PM')
60
+ expect(periodLabelOf({ startedOn: '2024-01', endedOn: '', ongoing: 'true' }, '현재')).toBe('2024.01 ~ 현재')
61
+ expect(periodLabelOf({ startedOn: '2020-01', endedOn: '2023-12' }, '현재')).toBe('2020.01 ~ 2023.12')
62
+ expect(periodLabelOf({ name: 'x', acquiredOn: '2010-05-01' }, '현재')).toBe('2010.05.01')
63
+ expect(periodLabelOf({ startedOn: '', endedOn: '' }, '현재')).toBe('')
64
+ })
65
+
66
+ it('★ complete when the summary, skills, education and projects are written - jobs and certificates are not asked', () => {
67
+ const empty = profileFormOf(null)
68
+ expect(completenessOf(empty)).toEqual({ percent: 0, missing: ['summary', 'skills', 'educations', 'projects'] })
69
+ expect(completenessOf({ ...empty, summary: ' ', skills: ['A'] })).toEqual({ percent: 25, missing: ['summary', 'educations', 'projects'] })
70
+ expect(completenessOf({ ...empty, summary: 'S', skills: ['A'], educations: [{}], projects: [{}] }).percent).toBe(100)
44
71
  })
45
72
  })
46
73
 
@@ -132,15 +132,55 @@
132
132
  "label.employee-profile-role": "role",
133
133
  "label.employee-profile-technologies": "technologies",
134
134
  "label.employee-profile-updated-at": "profile updated",
135
- "text.employee-profile-skills-hint": "separate them with commas",
136
135
  "text.employee-profile-not-linked-title": "this account is not linked to an employee record",
137
136
  "text.employee-profile-not-linked-description": "each employee writes their own profile. A $t(term.domain) administrator can link this account to an employee in the employee list.",
138
137
  "text.employee-profile-select-rows-first": "select the employees to download first",
139
138
  "text.employee-profile-file": "employee-profiles",
140
139
  "text.employee-profile-empty": "no active employees",
141
- "text.employee-profile-no-rows": "nothing written yet",
142
140
  "task.organization.own-profile.name": "My profile",
143
141
  "task.organization.own-profile.description": "Reads and writes one's own profile - summary, skills, education, certificates, career and projects.",
144
142
  "task.organization.profiles-reading.name": "Reading employee profiles",
145
- "task.organization.profiles-reading.description": "Reads every active employee's profile and downloads them as Excel. Contact, address, accounts and pay are not among them."
143
+ "task.organization.profiles-reading.description": "Reads every active employee's profile and downloads them as Excel. Contact, address, accounts and pay are not among them.",
144
+ "text.employee-profile-doc-title": "Staff Profile",
145
+ "text.employee-profile-doc-roster": "Staff Roster",
146
+ "text.employee-profile-doc-ongoing": "present",
147
+ "text.employee-profile-doc-none": "None",
148
+ "text.employee-profile-doc-written-on": "Written on",
149
+ "text.employee-profile-doc-number": "No.",
150
+ "text.employee-profile-doc-years": "{years} yr {months} mo",
151
+ "label.employee-profile-career-length": "total experience",
152
+ "button.employee-profile-download-pdf": "download as PDF",
153
+ "text.employee-profile-completeness": "Profile completeness",
154
+ "text.employee-profile-to-fill": "To fill",
155
+ "text.employee-profile-ready": "Ready to attach to a quotation",
156
+ "text.employee-profile-unsaved": "There are unsaved changes",
157
+ "text.employee-profile-summary-example": "e.g. Ten years designing and building manufacturing MES - process modelling and equipment integration, PM for the last three.",
158
+ "text.employee-profile-skill-placeholder": "Type a skill and press Enter",
159
+ "text.employee-profile-why-educations": "Start from the latest degree.",
160
+ "text.employee-profile-why-certificates": "Certificates a bid looks at.",
161
+ "text.employee-profile-why-careers": "Jobs before this company - they add to the total experience.",
162
+ "text.employee-profile-why-projects": "The first thing read on a profile attached to a quotation - the role and what was done.",
163
+ "label.employee-profile-ongoing": "ongoing",
164
+ "text.employee-profile-new-entry": "New entry",
165
+ "text.employee-profile-doc-expertise": "Core Expertise",
166
+ "text.employee-profile-doc-projects-done": "Projects delivered",
167
+ "text.employee-profile-doc-certificates-held": "Certifications",
168
+ "text.employee-profile-doc-project-count": "{n}",
169
+ "text.employee-profile-doc-certificate-count": "{n}",
170
+ "text.employee-profile-doc-people": "{n} people",
171
+ "text.employee-profile-doc-average-career": "Average experience",
172
+ "label.employee-profile-document-language": "document language",
173
+ "button.employee-profile-preview": "preview",
174
+ "title.employee-profile-preview": "staff profile preview",
175
+ "label.organization-entry-employee-profile": "my profile",
176
+ "label.organization-entry-profile-section": "part",
177
+ "label.organization-entry-profile-text": "text",
178
+ "label.organization-entry-profile-name": "name",
179
+ "text.organization-entry-profile-section-summary": "summary",
180
+ "text.organization-entry-profile-section-skill": "skill",
181
+ "text.organization-entry-profile-section-education": "education",
182
+ "text.organization-entry-profile-section-certificate": "certificate",
183
+ "text.organization-entry-profile-section-career": "career",
184
+ "text.organization-entry-profile-section-project": "project",
185
+ "text.organization-entry-profile-one-summary": "A profile has one summary"
146
186
  }
@@ -132,15 +132,55 @@
132
132
  "label.employee-profile-role": "rol",
133
133
  "label.employee-profile-technologies": "tecnologías",
134
134
  "label.employee-profile-updated-at": "perfil actualizado",
135
- "text.employee-profile-skills-hint": "sepárelas con comas",
136
135
  "text.employee-profile-not-linked-title": "esta cuenta no está vinculada a un registro de empleado",
137
136
  "text.employee-profile-not-linked-description": "cada empleado escribe su propio perfil. Un administrador de $t(term.domain) puede vincular esta cuenta a un empleado en la lista de empleados.",
138
137
  "text.employee-profile-select-rows-first": "seleccione primero los empleados que desea descargar",
139
138
  "text.employee-profile-file": "perfiles-empleados",
140
139
  "text.employee-profile-empty": "no hay empleados en activo",
141
- "text.employee-profile-no-rows": "todavía no hay nada escrito",
142
140
  "task.organization.own-profile.name": "Mi perfil",
143
141
  "task.organization.own-profile.description": "Lee y escribe el propio perfil: resumen, habilidades, formación, certificaciones, experiencia y proyectos.",
144
142
  "task.organization.profiles-reading.name": "Lectura de perfiles de empleados",
145
- "task.organization.profiles-reading.description": "Lee el perfil de cada empleado en activo y los descarga en Excel. No incluye contacto, dirección, cuentas ni nómina."
143
+ "task.organization.profiles-reading.description": "Lee el perfil de cada empleado en activo y los descarga en Excel. No incluye contacto, dirección, cuentas ni nómina.",
144
+ "text.employee-profile-doc-title": "Perfil profesional",
145
+ "text.employee-profile-doc-roster": "Relación de personal",
146
+ "text.employee-profile-doc-ongoing": "actualidad",
147
+ "text.employee-profile-doc-none": "Ninguno",
148
+ "text.employee-profile-doc-written-on": "Fecha",
149
+ "text.employee-profile-doc-number": "N.º",
150
+ "text.employee-profile-doc-years": "{years} años {months} meses",
151
+ "label.employee-profile-career-length": "experiencia total",
152
+ "button.employee-profile-download-pdf": "descargar PDF",
153
+ "text.employee-profile-completeness": "Perfil completado",
154
+ "text.employee-profile-to-fill": "Por completar",
155
+ "text.employee-profile-ready": "Listo para adjuntar a una oferta",
156
+ "text.employee-profile-unsaved": "Hay cambios sin guardar",
157
+ "text.employee-profile-summary-example": "p. ej. Diez años diseñando e implantando MES de fabricación; modelado de procesos e integración de equipos, PM los últimos tres.",
158
+ "text.employee-profile-skill-placeholder": "Escriba una habilidad y pulse Intro",
159
+ "text.employee-profile-why-educations": "Empiece por la titulación más reciente.",
160
+ "text.employee-profile-why-certificates": "Las certificaciones que mira una licitación.",
161
+ "text.employee-profile-why-careers": "Empleos anteriores a esta empresa; suman a la experiencia total.",
162
+ "text.employee-profile-why-projects": "Lo primero que se lee en un perfil adjunto a una oferta: el rol y lo que se hizo.",
163
+ "label.employee-profile-ongoing": "en curso",
164
+ "text.employee-profile-new-entry": "Nueva entrada",
165
+ "text.employee-profile-doc-expertise": "Competencias clave",
166
+ "text.employee-profile-doc-projects-done": "Proyectos realizados",
167
+ "text.employee-profile-doc-certificates-held": "Certificaciones",
168
+ "text.employee-profile-doc-project-count": "{n}",
169
+ "text.employee-profile-doc-certificate-count": "{n}",
170
+ "text.employee-profile-doc-people": "{n} personas",
171
+ "text.employee-profile-doc-average-career": "Experiencia media",
172
+ "label.employee-profile-document-language": "idioma del documento",
173
+ "button.employee-profile-preview": "vista previa",
174
+ "title.employee-profile-preview": "vista previa del perfil",
175
+ "label.organization-entry-employee-profile": "mi perfil",
176
+ "label.organization-entry-profile-section": "parte",
177
+ "label.organization-entry-profile-text": "texto",
178
+ "label.organization-entry-profile-name": "nombre",
179
+ "text.organization-entry-profile-section-summary": "resumen",
180
+ "text.organization-entry-profile-section-skill": "habilidad",
181
+ "text.organization-entry-profile-section-education": "formación",
182
+ "text.organization-entry-profile-section-certificate": "certificación",
183
+ "text.organization-entry-profile-section-career": "experiencia",
184
+ "text.organization-entry-profile-section-project": "proyecto",
185
+ "text.organization-entry-profile-one-summary": "Un perfil tiene un solo resumen"
146
186
  }