@things-factory/organization 10.1.222 → 10.1.226
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-form.d.ts +31 -0
- package/dist-client/pages/employee-profile/employee-profile-form.js +86 -0
- package/dist-client/pages/employee-profile/employee-profile-form.js.map +1 -0
- package/dist-client/pages/employee-profile/employee-profile-list-page.d.ts +38 -0
- package/dist-client/pages/employee-profile/employee-profile-list-page.js +164 -0
- package/dist-client/pages/employee-profile/employee-profile-list-page.js.map +1 -0
- package/dist-client/pages/employee-profile/employee-profile-workbook.d.ts +33 -0
- package/dist-client/pages/employee-profile/employee-profile-workbook.js +87 -0
- package/dist-client/pages/employee-profile/employee-profile-workbook.js.map +1 -0
- package/dist-client/pages/employee-profile/my-employee-profile-page.d.ts +38 -0
- package/dist-client/pages/employee-profile/my-employee-profile-page.js +282 -0
- package/dist-client/pages/employee-profile/my-employee-profile-page.js.map +1 -0
- package/dist-client/route.d.ts +1 -1
- package/dist-client/route.js +7 -0
- package/dist-client/route.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/role-tasks.js +4 -1
- package/dist-server/role-tasks.js.map +1 -1
- package/dist-server/service/employee-profile/employee-profile-mutation.d.ts +5 -0
- package/dist-server/service/employee-profile/employee-profile-mutation.js +45 -0
- package/dist-server/service/employee-profile/employee-profile-mutation.js.map +1 -0
- package/dist-server/service/employee-profile/employee-profile-query.d.ts +6 -0
- package/dist-server/service/employee-profile/employee-profile-query.js +58 -0
- package/dist-server/service/employee-profile/employee-profile-query.js.map +1 -0
- package/dist-server/service/employee-profile/employee-profile-rows.d.ts +29 -0
- package/dist-server/service/employee-profile/employee-profile-rows.js +122 -0
- package/dist-server/service/employee-profile/employee-profile-rows.js.map +1 -0
- package/dist-server/service/employee-profile/employee-profile-rules.d.ts +55 -0
- package/dist-server/service/employee-profile/employee-profile-rules.js +80 -0
- package/dist-server/service/employee-profile/employee-profile-rules.js.map +1 -0
- package/dist-server/service/employee-profile/employee-profile-type.d.ts +24 -0
- package/dist-server/service/employee-profile/employee-profile-type.js +81 -0
- package/dist-server/service/employee-profile/employee-profile-type.js.map +1 -0
- package/dist-server/service/employee-profile/employee-profile.d.ts +24 -0
- package/dist-server/service/employee-profile/employee-profile.js +112 -0
- package/dist-server/service/employee-profile/employee-profile.js.map +1 -0
- package/dist-server/service/employee-profile/index.d.ts +5 -0
- package/dist-server/service/employee-profile/index.js +9 -0
- package/dist-server/service/employee-profile/index.js.map +1 -0
- package/dist-server/service/index.d.ts +3 -2
- package/dist-server/service/index.js +6 -2
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +7 -6
- package/tests/employee-profile-db.test.ts +186 -0
- package/tests/employee-profile-rules.test.ts +76 -0
- package/tests/employee-profile-workbook.test.ts +89 -0
- package/things-factory.config.js +3 -0
- package/translations/en.json +38 -1
- package/translations/es.json +38 -1
- package/translations/ja.json +38 -1
- package/translations/ko.json +38 -1
- package/translations/ms.json +38 -1
- package/translations/zh.json +38 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/organization",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.226",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"browser": "dist-client/index.js",
|
|
6
6
|
"things-factory": true,
|
|
@@ -36,10 +36,11 @@
|
|
|
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.
|
|
40
|
-
"@things-factory/code-ui": "^10.1.
|
|
41
|
-
"@things-factory/contact": "^10.1.
|
|
42
|
-
"@things-factory/shell": "^10.1.208"
|
|
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"
|
|
43
44
|
},
|
|
44
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "abc5cf99effa2de579a1cbfda74ddc5eb41dba50"
|
|
45
46
|
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The profile's doors, through GraphQL on sqlite (ADR-0169 decisions 3 · 4): the employee writes their own and nobody
|
|
3
|
+
* else's, an account not linked to an employee has none, and the list gives every active employee with theirs.
|
|
4
|
+
*
|
|
5
|
+
* The real resolvers and types are built into a schema; @privilege and @transaction are the framework's, and the context
|
|
6
|
+
* below stands in for what they set up. What each door asks is read from its source.
|
|
7
|
+
*/
|
|
8
|
+
import 'reflect-metadata'
|
|
9
|
+
|
|
10
|
+
const { existsSync, readFileSync } = require('fs')
|
|
11
|
+
const { join } = require('path')
|
|
12
|
+
const { DataSource } = require('typeorm')
|
|
13
|
+
const { buildSchema } = require('type-graphql')
|
|
14
|
+
const { graphql } = require('graphql')
|
|
15
|
+
|
|
16
|
+
const shell = require('@things-factory/shell')
|
|
17
|
+
const authBase = require('@things-factory/auth-base')
|
|
18
|
+
const contact = require('@things-factory/contact')
|
|
19
|
+
const bizBase = require('@things-factory/biz-base')
|
|
20
|
+
|
|
21
|
+
function loadCompiled(modulePath: string): any {
|
|
22
|
+
const resolved = join(__dirname, '..', 'dist-server', `${modulePath}.js`)
|
|
23
|
+
if (!existsSync(resolved)) {
|
|
24
|
+
throw new Error(`organization build output is missing: ${resolved}\nBuild it first: npm run build:server`)
|
|
25
|
+
}
|
|
26
|
+
return require(resolved)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let dataSource: any
|
|
30
|
+
let organization: any
|
|
31
|
+
let schema: any
|
|
32
|
+
let domain: any
|
|
33
|
+
|
|
34
|
+
const ROWS = `
|
|
35
|
+
summary skills updatedAt employeeId
|
|
36
|
+
educations { school major degree startedOn endedOn }
|
|
37
|
+
certificates { name issuer acquiredOn }
|
|
38
|
+
careers { company department position startedOn endedOn duties }
|
|
39
|
+
projects { name client startedOn endedOn role duties technologies }
|
|
40
|
+
`
|
|
41
|
+
const MINE = `query { myEmployeeProfile { ${ROWS} } }`
|
|
42
|
+
const SAVE = `mutation ($profile: EmployeeProfileInput!) { saveMyEmployeeProfile(profile: $profile) { ${ROWS} } }`
|
|
43
|
+
const LIST = `query { employeeProfiles { employeeId name departmentName jobPosition jobResponsibility hiredOn profile { summary skills } } }`
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
organization = loadCompiled('service/index')
|
|
47
|
+
const { resolvers } = loadCompiled('service/employee-profile/index')
|
|
48
|
+
dataSource = new DataSource({
|
|
49
|
+
type: 'sqlite',
|
|
50
|
+
database: ':memory:',
|
|
51
|
+
synchronize: true,
|
|
52
|
+
dropSchema: true,
|
|
53
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
54
|
+
entities: [...shell.entities, ...authBase.entities, ...bizBase.entities, ...contact.entities, ...organization.entities],
|
|
55
|
+
logging: false
|
|
56
|
+
})
|
|
57
|
+
await dataSource.initialize()
|
|
58
|
+
shell.addDataSource('default', dataSource)
|
|
59
|
+
shell.addDataSource('tx', dataSource)
|
|
60
|
+
schema = await buildSchema({ resolvers, validate: false })
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
beforeEach(async () => {
|
|
64
|
+
await dataSource.synchronize(true)
|
|
65
|
+
domain = await dataSource.getRepository(shell.Domain).save({ name: 'profile', subdomain: 'profile' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
afterAll(async () => {
|
|
69
|
+
if (!dataSource) return
|
|
70
|
+
shell.removeDataSource('default')
|
|
71
|
+
shell.removeDataSource('tx')
|
|
72
|
+
await dataSource.destroy()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
async function person(name: string, employee: Record<string, unknown> | null = {}) {
|
|
76
|
+
const user = await dataSource.getRepository(authBase.User).save({ name, email: `${name}@profile.test` })
|
|
77
|
+
if (employee) {
|
|
78
|
+
const department = (await dataSource.getRepository(organization.Department).findOneBy({ name: '개발팀' })) ?? (await dataSource.getRepository(organization.Department).save({ domain, controlNo: 'D-1', name: '개발팀' }))
|
|
79
|
+
await dataSource.getRepository(organization.Employee).save({ domain, controlNo: name, name, user, department, active: true, ...employee })
|
|
80
|
+
}
|
|
81
|
+
return user
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function ask(user: any, source: string, variableValues?: any) {
|
|
85
|
+
const result = await graphql({ schema, source, variableValues, contextValue: { state: { domain, user, tx: dataSource.manager }, t: (key: string) => key } })
|
|
86
|
+
return result
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
describe("an employee's own profile", () => {
|
|
90
|
+
it('★ an account not linked to an employee has none, and cannot write one', async () => {
|
|
91
|
+
const stranger = await person('stranger', null)
|
|
92
|
+
expect((await ask(stranger, MINE)).data.myEmployeeProfile).toBeNull()
|
|
93
|
+
const refused = await ask(stranger, SAVE, { profile: { summary: 'x' } })
|
|
94
|
+
expect(refused.errors?.[0]?.extensions?.requires).toBe('no-employee')
|
|
95
|
+
expect(await dataSource.getRepository(organization.EmployeeProfile).count()).toBe(0)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('★ is written whole and read back; writing again replaces it, one profile per employee', async () => {
|
|
99
|
+
const kim = await person('kim')
|
|
100
|
+
expect((await ask(kim, MINE)).data.myEmployeeProfile).toBeNull()
|
|
101
|
+
|
|
102
|
+
const first = await ask(kim, SAVE, {
|
|
103
|
+
profile: {
|
|
104
|
+
summary: ' MES 10년 ',
|
|
105
|
+
skills: ['TypeScript', 'TypeScript', 'SQL'],
|
|
106
|
+
educations: [{ school: '서울대', major: '컴퓨터공학', degree: '학사', startedOn: '2005-03', endedOn: '2009-02' }],
|
|
107
|
+
projects: [{ name: '미라텍 MES', client: '미라텍', startedOn: '2024-01', role: 'PM' }, { name: '' }]
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
expect(first.errors).toBeUndefined()
|
|
111
|
+
const mine = (await ask(kim, MINE)).data.myEmployeeProfile
|
|
112
|
+
expect(mine).toMatchObject({
|
|
113
|
+
summary: 'MES 10년',
|
|
114
|
+
skills: ['TypeScript', 'SQL'],
|
|
115
|
+
educations: [{ school: '서울대', major: '컴퓨터공학', degree: '학사', startedOn: '2005-03', endedOn: '2009-02' }],
|
|
116
|
+
certificates: [],
|
|
117
|
+
projects: [{ name: '미라텍 MES', client: '미라텍', startedOn: '2024-01', endedOn: null, role: 'PM', duties: null, technologies: null }]
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
await ask(kim, SAVE, { profile: { summary: '바꿈', certificates: [{ name: '정보처리기사' }] } })
|
|
121
|
+
const again = (await ask(kim, MINE)).data.myEmployeeProfile
|
|
122
|
+
expect(again).toMatchObject({ summary: '바꿈', skills: [], educations: [], certificates: [{ name: '정보처리기사' }], projects: [] })
|
|
123
|
+
expect(await dataSource.getRepository(organization.EmployeeProfile).count()).toBe(1)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it("★ one employee's save never touches another's profile", async () => {
|
|
127
|
+
const kim = await person('kim')
|
|
128
|
+
const park = await person('park')
|
|
129
|
+
await ask(kim, SAVE, { profile: { summary: 'kim' } })
|
|
130
|
+
await ask(park, SAVE, { profile: { summary: 'park' } })
|
|
131
|
+
expect((await ask(kim, MINE)).data.myEmployeeProfile.summary).toBe('kim')
|
|
132
|
+
expect((await ask(park, MINE)).data.myEmployeeProfile.summary).toBe('park')
|
|
133
|
+
expect(await dataSource.getRepository(organization.EmployeeProfile).count()).toBe(2)
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('the profiles of every active employee', () => {
|
|
138
|
+
it('★ lists each active employee with the basics and their profile, empty when none was written', async () => {
|
|
139
|
+
const kim = await person('kim', { jobPosition: '과장', jobResponsibility: '팀장', hiredOn: '2020-03-02' })
|
|
140
|
+
await person('lee')
|
|
141
|
+
await person('gone', { retiredOn: '2020-01-01' })
|
|
142
|
+
await person('off', { active: false })
|
|
143
|
+
await ask(kim, SAVE, { profile: { summary: 'S', skills: ['A'] } })
|
|
144
|
+
|
|
145
|
+
const list = await ask(kim, LIST)
|
|
146
|
+
expect(list.errors).toBeUndefined()
|
|
147
|
+
expect(list.data.employeeProfiles.map((one: any) => [one.name, one.departmentName, one.profile?.summary ?? null])).toEqual([
|
|
148
|
+
['kim', '개발팀', 'S'],
|
|
149
|
+
['lee', '개발팀', null]
|
|
150
|
+
])
|
|
151
|
+
expect(list.data.employeeProfiles[0]).toMatchObject({ jobPosition: '과장', jobResponsibility: '팀장', hiredOn: '2020-03-02', profile: { skills: ['A'] } })
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('★ reaching a profile does not reach the employee record behind it', () => {
|
|
155
|
+
const type = schema.getType('EmployeeProfile').getFields()
|
|
156
|
+
expect(Object.keys(type)).not.toContain('employee')
|
|
157
|
+
expect(Object.keys(type)).not.toContain('creator')
|
|
158
|
+
expect(Object.keys(schema.getType('EmployeeWithProfile').getFields()).sort()).toEqual(
|
|
159
|
+
['controlNo', 'departmentName', 'employeeId', 'hiredOn', 'jobPosition', 'jobResponsibility', 'name', 'profile'].sort()
|
|
160
|
+
)
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
describe('what each door asks', () => {
|
|
165
|
+
const source = (file: string) => readFileSync(join(__dirname, '..', 'server', 'service', 'employee-profile', file), 'utf8')
|
|
166
|
+
const asks = (text: string, door: string) => {
|
|
167
|
+
const before = text.slice(0, text.indexOf(`async ${door}(`))
|
|
168
|
+
return before.slice(before.lastIndexOf('@Directive(\'@privilege')).match(/category: "([a-z-]+)", privilege: "(\w+)", domainOwnerGranted: (\w+)/)?.slice(1)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
it('★ one\'s own under employee-self, everyone\'s under employee-profile - and no door writes another\'s', () => {
|
|
172
|
+
expect(asks(source('employee-profile-query.ts'), 'myEmployeeProfile')).toEqual(['employee-self', 'query', 'true'])
|
|
173
|
+
expect(asks(source('employee-profile-query.ts'), 'employeeProfiles')).toEqual(['employee-profile', 'query', 'true'])
|
|
174
|
+
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\)/)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('★ the tasks grant them: own-profile to write one\'s own, profiles-reading to read everyone\'s', () => {
|
|
182
|
+
const { ROLE_TASKS } = loadCompiled('role-tasks')
|
|
183
|
+
expect(ROLE_TASKS.find((task: any) => task.id === 'organization.own-profile').grants).toEqual([{ category: 'employee-self', axes: ['query', 'mutation'] }])
|
|
184
|
+
expect(ROLE_TASKS.find((task: any) => task.id === 'organization.profiles-reading').grants).toEqual([{ category: 'employee-profile', axes: ['query'] }])
|
|
185
|
+
})
|
|
186
|
+
})
|
|
@@ -0,0 +1,76 @@
|
|
|
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'
|
|
3
|
+
|
|
4
|
+
/** The employee's profile is saved whole, as the screen holds it (ADR-0169 decision 2). */
|
|
5
|
+
describe("an employee's profile as it is saved", () => {
|
|
6
|
+
it('★ blank rows the screen added and did not fill go; the rest are trimmed and keep only their own fields', () => {
|
|
7
|
+
const saved = profileDocumentOf({
|
|
8
|
+
summary: ' 백엔드 10년 ',
|
|
9
|
+
skills: [' TypeScript ', 'PostgreSQL', '', 'TypeScript'],
|
|
10
|
+
educations: [{ school: ' 서울대 ', major: '컴퓨터공학', degree: '', startedOn: '2005-03', endedOn: '2009-02', salary: 1 }, { school: ' ', major: '' }],
|
|
11
|
+
certificates: [{}],
|
|
12
|
+
careers: null,
|
|
13
|
+
projects: [{ name: 'MES 구축', client: '미라텍', technologies: 'Node' }]
|
|
14
|
+
})
|
|
15
|
+
expect(saved).toEqual({
|
|
16
|
+
summary: '백엔드 10년',
|
|
17
|
+
skills: ['TypeScript', 'PostgreSQL'],
|
|
18
|
+
educations: [{ school: '서울대', major: '컴퓨터공학', startedOn: '2005-03', endedOn: '2009-02' }],
|
|
19
|
+
certificates: [],
|
|
20
|
+
careers: [],
|
|
21
|
+
projects: [{ name: 'MES 구축', client: '미라텍', technologies: 'Node' }]
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('an empty document empties the profile', () => {
|
|
26
|
+
expect(profileDocumentOf({})).toEqual({ summary: null, skills: [], educations: [], certificates: [], careers: [], projects: [] })
|
|
27
|
+
expect(profileDocumentOf(null)).toEqual({ summary: null, skills: [], educations: [], certificates: [], careers: [], projects: [] })
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('★ the screen edits exactly the fields the server keeps, section by section', () => {
|
|
31
|
+
for (const section of PROFILE_SECTIONS) {
|
|
32
|
+
expect([section.key, section.fields.map(field => field.name).sort()]).toEqual([section.key, [...PROFILE_ROW_FIELDS[section.key]].sort()])
|
|
33
|
+
}
|
|
34
|
+
expect(PROFILE_SECTIONS.map(section => section.key).sort()).toEqual(Object.keys(PROFILE_ROW_FIELDS).sort())
|
|
35
|
+
})
|
|
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'])
|
|
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: '' }])
|
|
42
|
+
expect(profileInputOf(form).skills).toEqual(['A', 'B'])
|
|
43
|
+
expect(profileFormOf(null)).toEqual({ summary: '', skills: '', educations: [], certificates: [], careers: [], projects: [] })
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/** Who the profile list shows: everyone still working here, with or without a profile (ADR-0169 decision 4). */
|
|
48
|
+
describe('the active employees and their profiles', () => {
|
|
49
|
+
const today = '2026-10-27'
|
|
50
|
+
|
|
51
|
+
it('★ an employee marked inactive, or whose retire day has come, is not listed', () => {
|
|
52
|
+
expect(isActiveEmployee({ id: '1' }, today)).toBe(true)
|
|
53
|
+
expect(isActiveEmployee({ id: '1', active: true, retiredOn: null }, today)).toBe(true)
|
|
54
|
+
expect(isActiveEmployee({ id: '1', active: null }, today)).toBe(true)
|
|
55
|
+
expect(isActiveEmployee({ id: '1', active: false }, today)).toBe(false)
|
|
56
|
+
expect(isActiveEmployee({ id: '1', retiredOn: '2026-10-27' }, today)).toBe(false)
|
|
57
|
+
expect(isActiveEmployee({ id: '1', retiredOn: '2026-10-28' }, today)).toBe(true)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('★ each active employee once, by name, with the profile written or none', () => {
|
|
61
|
+
const rows = employeesWithProfiles(
|
|
62
|
+
[
|
|
63
|
+
{ id: 'b', name: '박지민', controlNo: '2', department: { name: '개발' }, jobPosition: '과장', hiredOn: '2020-01-01' },
|
|
64
|
+
{ id: 'a', name: '김하늘', controlNo: '1', department: null },
|
|
65
|
+
{ id: 'c', name: '최퇴사', retiredOn: '2025-01-01' }
|
|
66
|
+
],
|
|
67
|
+
[{ employeeId: 'b', summary: 'S' }, { employeeId: 'c', summary: 'gone' }],
|
|
68
|
+
today
|
|
69
|
+
)
|
|
70
|
+
expect(rows.map(row => [row.name, row.departmentName, row.profile?.summary ?? null])).toEqual([
|
|
71
|
+
['김하늘', undefined, null],
|
|
72
|
+
['박지민', '개발', 'S']
|
|
73
|
+
])
|
|
74
|
+
expect(rows[1]).toMatchObject({ employeeId: 'b', controlNo: '2', jobPosition: '과장', hiredOn: '2020-01-01' })
|
|
75
|
+
})
|
|
76
|
+
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import * as XLSX from 'xlsx'
|
|
2
|
+
|
|
3
|
+
import { periodOf, profileFileName, profileSheetRows, profileWorkbook, sheetNamesOf, type ProfileSheetWords } from '../client/pages/employee-profile/employee-profile-workbook'
|
|
4
|
+
|
|
5
|
+
/** The profiles a quotation attaches: one workbook, a sheet a person (ADR-0169 decision 5). */
|
|
6
|
+
const WORDS = new Proxy({}, { get: (_target, key) => `<${String(key)}>` }) as ProfileSheetWords
|
|
7
|
+
|
|
8
|
+
const KIM = {
|
|
9
|
+
name: '김하늘',
|
|
10
|
+
departmentName: '개발팀',
|
|
11
|
+
jobPosition: '과장',
|
|
12
|
+
jobResponsibility: '팀장',
|
|
13
|
+
hiredOn: '2020-03-02',
|
|
14
|
+
profile: {
|
|
15
|
+
summary: 'MES 10년',
|
|
16
|
+
skills: ['TypeScript', 'PostgreSQL'],
|
|
17
|
+
educations: [{ school: '서울대', major: '컴퓨터공학', degree: '학사', startedOn: '2005-03', endedOn: '2009-02' }],
|
|
18
|
+
certificates: [{ name: '정보처리기사', issuer: '한국산업인력공단', acquiredOn: '2010-05-01' }],
|
|
19
|
+
careers: [{ company: 'A사', department: '연구소', position: '대리', startedOn: '2009-03', endedOn: '2019-12', duties: 'ERP' }],
|
|
20
|
+
projects: [{ name: '미라텍 MES', client: '미라텍', startedOn: '2024-01', role: 'PM', duties: '설계', technologies: 'Node' }]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('the employee profile workbook', () => {
|
|
25
|
+
it('★ a sheet is the basics, then each section as a titled table', () => {
|
|
26
|
+
const rows = profileSheetRows(KIM, WORDS)
|
|
27
|
+
expect(rows.slice(0, 11)).toEqual([
|
|
28
|
+
['김하늘'],
|
|
29
|
+
[],
|
|
30
|
+
['<name>', '김하늘'],
|
|
31
|
+
['<department>', '개발팀'],
|
|
32
|
+
['<jobPosition>', '과장'],
|
|
33
|
+
['<jobResponsibility>', '팀장'],
|
|
34
|
+
['<hiredOn>', '2020-03-02'],
|
|
35
|
+
['<summary>', 'MES 10년'],
|
|
36
|
+
[],
|
|
37
|
+
['<skills>'],
|
|
38
|
+
['TypeScript, PostgreSQL']
|
|
39
|
+
])
|
|
40
|
+
const after = (title: string) => rows.slice(rows.findIndex(row => row[0] === title))
|
|
41
|
+
expect(after('<educations>').slice(0, 3)).toEqual([['<educations>'], ['<school>', '<major>', '<degree>', '<period>'], ['서울대', '컴퓨터공학', '학사', '2005-03 ~ 2009-02']])
|
|
42
|
+
expect(after('<certificates>').slice(0, 3)).toEqual([['<certificates>'], ['<certificate>', '<issuer>', '<acquiredOn>'], ['정보처리기사', '한국산업인력공단', '2010-05-01']])
|
|
43
|
+
expect(after('<careers>').slice(0, 3)).toEqual([
|
|
44
|
+
['<careers>'],
|
|
45
|
+
['<company>', '<department>', '<position>', '<period>', '<duties>'],
|
|
46
|
+
['A사', '연구소', '대리', '2009-03 ~ 2019-12', 'ERP']
|
|
47
|
+
])
|
|
48
|
+
expect(after('<projects>').slice(0, 3)).toEqual([
|
|
49
|
+
['<projects>'],
|
|
50
|
+
['<project>', '<client>', '<period>', '<role>', '<duties>', '<technologies>'],
|
|
51
|
+
['미라텍 MES', '미라텍', '2024-01 ~', 'PM', '설계', 'Node']
|
|
52
|
+
])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('an employee with no profile still has a sheet - the basics and the empty tables', () => {
|
|
56
|
+
const rows = profileSheetRows({ name: '박지민', profile: null }, WORDS)
|
|
57
|
+
expect(rows[2]).toEqual(['<name>', '박지민'])
|
|
58
|
+
expect(rows.filter(row => row[0] === '<projects>')).toHaveLength(1)
|
|
59
|
+
expect(rows[rows.length - 1]).toEqual(['<project>', '<client>', '<period>', '<role>', '<duties>', '<technologies>'])
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('a period with one end open', () => {
|
|
63
|
+
expect(periodOf('2024-01', '')).toBe('2024-01 ~')
|
|
64
|
+
expect(periodOf('', '2024-01')).toBe('~ 2024-01')
|
|
65
|
+
expect(periodOf()).toBe('')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('★ one sheet a person, named by the person, in the order picked - read back from the written file', () => {
|
|
69
|
+
const book = profileWorkbook([KIM, { name: '박지민' }], WORDS)
|
|
70
|
+
const read = XLSX.read(XLSX.write(book, { type: 'buffer', bookType: 'xlsx' }), { type: 'buffer' })
|
|
71
|
+
expect(read.SheetNames).toEqual(['김하늘', '박지민'])
|
|
72
|
+
const kim = XLSX.utils.sheet_to_json<string[]>(read.Sheets['김하늘'], { header: 1, blankrows: true, defval: '' })
|
|
73
|
+
expect(kim[0][0]).toBe('김하늘')
|
|
74
|
+
expect(kim.some(row => row[0] === '미라텍 MES' && row[2] === '2024-01 ~')).toBe(true)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('★ sheet names Excel takes: at most 31 characters, none of \\ / ? * [ ] :, no two alike', () => {
|
|
78
|
+
const names = sheetNamesOf(['홍길동', '홍길동', 'HONG', 'hong', 'a/b:c*d?[e]', '', null, 'x'.repeat(40), 'x'.repeat(40)])
|
|
79
|
+
expect(names.slice(0, 7)).toEqual(['홍길동', '홍길동 (2)', 'HONG', 'hong (2)', 'a b c d e', '#6', '#7'])
|
|
80
|
+
expect(names[7]).toBe('x'.repeat(31))
|
|
81
|
+
expect(names[8]).toBe('x'.repeat(27) + ' (2)')
|
|
82
|
+
expect(new Set(names.map(name => name.toLowerCase())).size).toBe(names.length)
|
|
83
|
+
expect(names.every(name => name.length <= 31 && !/[\\/?*[\]:]/.test(name))).toBe(true)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('the file is named by the day it was taken', () => {
|
|
87
|
+
expect(profileFileName('인력프로필', new Date(2026, 9, 7))).toBe('인력프로필-20261007.xlsx')
|
|
88
|
+
})
|
|
89
|
+
})
|
package/things-factory.config.js
CHANGED
|
@@ -7,6 +7,9 @@ export default {
|
|
|
7
7
|
{ tagname: 'employee-list-page', page: 'employee-list' },
|
|
8
8
|
{ tagname: 'department-list-page', page: 'department-list' },
|
|
9
9
|
{ tagname: 'approval-line-list-page', page: 'approval-line-list' },
|
|
10
|
+
/* A person's own career profile, and every one of them to download for a quotation (ADR-0169). */
|
|
11
|
+
{ tagname: 'my-employee-profile-page', page: 'my-employee-profile' },
|
|
12
|
+
{ tagname: 'employee-profile-list-page', page: 'employee-profile-list' },
|
|
10
13
|
{ tagname: 'common-approval-line-templates-page', page: 'common-approval-line-templates-page' },
|
|
11
14
|
{ tagname: 'my-approval-line-templates-page', page: 'my-approval-line-templates-page' }
|
|
12
15
|
],
|
package/translations/en.json
CHANGED
|
@@ -105,5 +105,42 @@
|
|
|
105
105
|
"task.organization.people-keeping.name": "Keeping employees and departments",
|
|
106
106
|
"task.organization.people-keeping.description": "Reads and changes employee and department records. Pay accounts are not among them.",
|
|
107
107
|
"task.organization.pay-accounts.name": "Keeping employees' pay accounts",
|
|
108
|
-
"task.organization.pay-accounts.description": "Reads and changes the account each employee is paid into - bank and number."
|
|
108
|
+
"task.organization.pay-accounts.description": "Reads and changes the account each employee is paid into - bank and number.",
|
|
109
|
+
"title.my-employee-profile": "my profile",
|
|
110
|
+
"title.employee-profile-list": "employee profiles",
|
|
111
|
+
"button.employee-profile-download": "download as Excel",
|
|
112
|
+
"label.employee-profile-summary": "summary",
|
|
113
|
+
"label.employee-profile-skills": "skills",
|
|
114
|
+
"label.employee-profile-educations": "education",
|
|
115
|
+
"label.employee-profile-certificates": "certificates",
|
|
116
|
+
"label.employee-profile-careers": "career",
|
|
117
|
+
"label.employee-profile-projects": "projects",
|
|
118
|
+
"label.employee-profile-school": "school",
|
|
119
|
+
"label.employee-profile-major": "major",
|
|
120
|
+
"label.employee-profile-degree": "degree",
|
|
121
|
+
"label.employee-profile-started-on": "from",
|
|
122
|
+
"label.employee-profile-ended-on": "to",
|
|
123
|
+
"label.employee-profile-period": "period",
|
|
124
|
+
"label.employee-profile-certificate": "certificate",
|
|
125
|
+
"label.employee-profile-issuer": "issued by",
|
|
126
|
+
"label.employee-profile-acquired-on": "acquired on",
|
|
127
|
+
"label.employee-profile-company": "company",
|
|
128
|
+
"label.employee-profile-position": "position",
|
|
129
|
+
"label.employee-profile-duties": "duties",
|
|
130
|
+
"label.employee-profile-project": "project",
|
|
131
|
+
"label.employee-profile-client": "client",
|
|
132
|
+
"label.employee-profile-role": "role",
|
|
133
|
+
"label.employee-profile-technologies": "technologies",
|
|
134
|
+
"label.employee-profile-updated-at": "profile updated",
|
|
135
|
+
"text.employee-profile-skills-hint": "separate them with commas",
|
|
136
|
+
"text.employee-profile-not-linked-title": "this account is not linked to an employee record",
|
|
137
|
+
"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
|
+
"text.employee-profile-select-rows-first": "select the employees to download first",
|
|
139
|
+
"text.employee-profile-file": "employee-profiles",
|
|
140
|
+
"text.employee-profile-empty": "no active employees",
|
|
141
|
+
"text.employee-profile-no-rows": "nothing written yet",
|
|
142
|
+
"task.organization.own-profile.name": "My profile",
|
|
143
|
+
"task.organization.own-profile.description": "Reads and writes one's own profile - summary, skills, education, certificates, career and projects.",
|
|
144
|
+
"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."
|
|
109
146
|
}
|
package/translations/es.json
CHANGED
|
@@ -105,5 +105,42 @@
|
|
|
105
105
|
"task.organization.people-keeping.name": "Mantener empleados y departamentos",
|
|
106
106
|
"task.organization.people-keeping.description": "Lee y modifica los registros de empleados y departamentos. Las cuentas de nómina no se incluyen.",
|
|
107
107
|
"task.organization.pay-accounts.name": "Cuentas de nómina de los empleados",
|
|
108
|
-
"task.organization.pay-accounts.description": "Lee y modifica la cuenta en la que cobra cada empleado: banco y número."
|
|
108
|
+
"task.organization.pay-accounts.description": "Lee y modifica la cuenta en la que cobra cada empleado: banco y número.",
|
|
109
|
+
"title.my-employee-profile": "mi perfil",
|
|
110
|
+
"title.employee-profile-list": "perfiles de empleados",
|
|
111
|
+
"button.employee-profile-download": "descargar en Excel",
|
|
112
|
+
"label.employee-profile-summary": "resumen",
|
|
113
|
+
"label.employee-profile-skills": "habilidades",
|
|
114
|
+
"label.employee-profile-educations": "formación",
|
|
115
|
+
"label.employee-profile-certificates": "certificaciones",
|
|
116
|
+
"label.employee-profile-careers": "experiencia",
|
|
117
|
+
"label.employee-profile-projects": "proyectos",
|
|
118
|
+
"label.employee-profile-school": "centro",
|
|
119
|
+
"label.employee-profile-major": "especialidad",
|
|
120
|
+
"label.employee-profile-degree": "título",
|
|
121
|
+
"label.employee-profile-started-on": "desde",
|
|
122
|
+
"label.employee-profile-ended-on": "hasta",
|
|
123
|
+
"label.employee-profile-period": "periodo",
|
|
124
|
+
"label.employee-profile-certificate": "certificación",
|
|
125
|
+
"label.employee-profile-issuer": "emitida por",
|
|
126
|
+
"label.employee-profile-acquired-on": "obtenida el",
|
|
127
|
+
"label.employee-profile-company": "empresa",
|
|
128
|
+
"label.employee-profile-position": "puesto",
|
|
129
|
+
"label.employee-profile-duties": "funciones",
|
|
130
|
+
"label.employee-profile-project": "proyecto",
|
|
131
|
+
"label.employee-profile-client": "cliente",
|
|
132
|
+
"label.employee-profile-role": "rol",
|
|
133
|
+
"label.employee-profile-technologies": "tecnologías",
|
|
134
|
+
"label.employee-profile-updated-at": "perfil actualizado",
|
|
135
|
+
"text.employee-profile-skills-hint": "sepárelas con comas",
|
|
136
|
+
"text.employee-profile-not-linked-title": "esta cuenta no está vinculada a un registro de empleado",
|
|
137
|
+
"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
|
+
"text.employee-profile-select-rows-first": "seleccione primero los empleados que desea descargar",
|
|
139
|
+
"text.employee-profile-file": "perfiles-empleados",
|
|
140
|
+
"text.employee-profile-empty": "no hay empleados en activo",
|
|
141
|
+
"text.employee-profile-no-rows": "todavía no hay nada escrito",
|
|
142
|
+
"task.organization.own-profile.name": "Mi perfil",
|
|
143
|
+
"task.organization.own-profile.description": "Lee y escribe el propio perfil: resumen, habilidades, formación, certificaciones, experiencia y proyectos.",
|
|
144
|
+
"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."
|
|
109
146
|
}
|
package/translations/ja.json
CHANGED
|
@@ -105,5 +105,42 @@
|
|
|
105
105
|
"task.organization.people-keeping.name": "社員・部署の管理",
|
|
106
106
|
"task.organization.people-keeping.description": "社員と部署の記録を読み、変更する。給与口座は含まない。",
|
|
107
107
|
"task.organization.pay-accounts.name": "社員の給与口座",
|
|
108
|
-
"task.organization.pay-accounts.description": "社員が給与を受け取る口座(銀行・番号)を読み、変更する。"
|
|
108
|
+
"task.organization.pay-accounts.description": "社員が給与を受け取る口座(銀行・番号)を読み、変更する。",
|
|
109
|
+
"title.my-employee-profile": "自分のプロフィール",
|
|
110
|
+
"title.employee-profile-list": "人材プロフィール",
|
|
111
|
+
"button.employee-profile-download": "Excelでダウンロード",
|
|
112
|
+
"label.employee-profile-summary": "概要",
|
|
113
|
+
"label.employee-profile-skills": "保有スキル",
|
|
114
|
+
"label.employee-profile-educations": "学歴",
|
|
115
|
+
"label.employee-profile-certificates": "資格",
|
|
116
|
+
"label.employee-profile-careers": "職歴",
|
|
117
|
+
"label.employee-profile-projects": "プロジェクト履歴",
|
|
118
|
+
"label.employee-profile-school": "学校",
|
|
119
|
+
"label.employee-profile-major": "専攻",
|
|
120
|
+
"label.employee-profile-degree": "学位",
|
|
121
|
+
"label.employee-profile-started-on": "開始",
|
|
122
|
+
"label.employee-profile-ended-on": "終了",
|
|
123
|
+
"label.employee-profile-period": "期間",
|
|
124
|
+
"label.employee-profile-certificate": "資格名",
|
|
125
|
+
"label.employee-profile-issuer": "発行機関",
|
|
126
|
+
"label.employee-profile-acquired-on": "取得日",
|
|
127
|
+
"label.employee-profile-company": "会社",
|
|
128
|
+
"label.employee-profile-position": "役職",
|
|
129
|
+
"label.employee-profile-duties": "業務",
|
|
130
|
+
"label.employee-profile-project": "プロジェクト",
|
|
131
|
+
"label.employee-profile-client": "発注元",
|
|
132
|
+
"label.employee-profile-role": "役割",
|
|
133
|
+
"label.employee-profile-technologies": "使用技術",
|
|
134
|
+
"label.employee-profile-updated-at": "プロフィール更新日",
|
|
135
|
+
"text.employee-profile-skills-hint": "カンマで区切って入力します",
|
|
136
|
+
"text.employee-profile-not-linked-title": "社員記録と連携していないアカウントです",
|
|
137
|
+
"text.employee-profile-not-linked-description": "プロフィールは社員本人が書きます。$t(term.domain)管理者が社員一覧でこのアカウントを社員と連携できます。",
|
|
138
|
+
"text.employee-profile-select-rows-first": "ダウンロードする社員を先に選んでください",
|
|
139
|
+
"text.employee-profile-file": "人材プロフィール",
|
|
140
|
+
"text.employee-profile-empty": "在職中の社員がいません",
|
|
141
|
+
"text.employee-profile-no-rows": "まだ記入がありません",
|
|
142
|
+
"task.organization.own-profile.name": "自分のプロフィール",
|
|
143
|
+
"task.organization.own-profile.description": "自分のプロフィール(概要・保有スキル・学歴・資格・職歴・プロジェクト履歴)を読み書きする。",
|
|
144
|
+
"task.organization.profiles-reading.name": "人材プロフィールの閲覧",
|
|
145
|
+
"task.organization.profiles-reading.description": "在職中の全社員のプロフィールを読み、Excelでダウンロードする。連絡先・住所・口座・給与は含まない。"
|
|
109
146
|
}
|
package/translations/ko.json
CHANGED
|
@@ -105,5 +105,42 @@
|
|
|
105
105
|
"task.organization.people-keeping.name": "직원·부서 관리",
|
|
106
106
|
"task.organization.people-keeping.description": "직원과 부서 기록을 읽고 바꾼다. 급여 계좌는 빠진다.",
|
|
107
107
|
"task.organization.pay-accounts.name": "직원 급여 계좌",
|
|
108
|
-
"task.organization.pay-accounts.description": "직원이 급여를 받는 계좌(은행·번호)를 읽고 바꾼다."
|
|
108
|
+
"task.organization.pay-accounts.description": "직원이 급여를 받는 계좌(은행·번호)를 읽고 바꾼다.",
|
|
109
|
+
"title.my-employee-profile": "내 프로필",
|
|
110
|
+
"title.employee-profile-list": "인력 프로필",
|
|
111
|
+
"button.employee-profile-download": "엑셀로 내려받기",
|
|
112
|
+
"label.employee-profile-summary": "요약",
|
|
113
|
+
"label.employee-profile-skills": "보유 기술",
|
|
114
|
+
"label.employee-profile-educations": "학력",
|
|
115
|
+
"label.employee-profile-certificates": "자격",
|
|
116
|
+
"label.employee-profile-careers": "경력",
|
|
117
|
+
"label.employee-profile-projects": "프로젝트 이력",
|
|
118
|
+
"label.employee-profile-school": "학교",
|
|
119
|
+
"label.employee-profile-major": "전공",
|
|
120
|
+
"label.employee-profile-degree": "학위",
|
|
121
|
+
"label.employee-profile-started-on": "시작",
|
|
122
|
+
"label.employee-profile-ended-on": "끝",
|
|
123
|
+
"label.employee-profile-period": "기간",
|
|
124
|
+
"label.employee-profile-certificate": "자격명",
|
|
125
|
+
"label.employee-profile-issuer": "발급 기관",
|
|
126
|
+
"label.employee-profile-acquired-on": "취득일",
|
|
127
|
+
"label.employee-profile-company": "회사",
|
|
128
|
+
"label.employee-profile-position": "직위",
|
|
129
|
+
"label.employee-profile-duties": "업무",
|
|
130
|
+
"label.employee-profile-project": "프로젝트",
|
|
131
|
+
"label.employee-profile-client": "발주처",
|
|
132
|
+
"label.employee-profile-role": "역할",
|
|
133
|
+
"label.employee-profile-technologies": "사용 기술",
|
|
134
|
+
"label.employee-profile-updated-at": "프로필 갱신일",
|
|
135
|
+
"text.employee-profile-skills-hint": "쉼표로 나눠 적습니다",
|
|
136
|
+
"text.employee-profile-not-linked-title": "직원 기록과 이어지지 않은 계정입니다",
|
|
137
|
+
"text.employee-profile-not-linked-description": "프로필은 직원이 자기 것을 씁니다. $t(term.domain) 관리자가 직원 목록에서 이 계정을 직원과 이어 줄 수 있습니다.",
|
|
138
|
+
"text.employee-profile-select-rows-first": "내려받을 직원을 먼저 고르십시오",
|
|
139
|
+
"text.employee-profile-file": "인력프로필",
|
|
140
|
+
"text.employee-profile-empty": "재직 중인 직원이 없습니다",
|
|
141
|
+
"text.employee-profile-no-rows": "아직 적은 것이 없습니다",
|
|
142
|
+
"task.organization.own-profile.name": "내 프로필",
|
|
143
|
+
"task.organization.own-profile.description": "자기 프로필(요약 · 보유 기술 · 학력 · 자격 · 경력 · 프로젝트 이력)을 읽고 쓴다.",
|
|
144
|
+
"task.organization.profiles-reading.name": "인력 프로필 열람",
|
|
145
|
+
"task.organization.profiles-reading.description": "재직 중인 모든 직원의 프로필을 읽고 엑셀로 내려받는다. 연락처 · 주소 · 계좌 · 급여는 빠진다."
|
|
109
146
|
}
|
package/translations/ms.json
CHANGED
|
@@ -105,5 +105,42 @@
|
|
|
105
105
|
"task.organization.people-keeping.name": "Menyelenggara pekerja dan jabatan",
|
|
106
106
|
"task.organization.people-keeping.description": "Membaca dan mengubah rekod pekerja dan jabatan. Akaun gaji tidak termasuk.",
|
|
107
107
|
"task.organization.pay-accounts.name": "Akaun gaji pekerja",
|
|
108
|
-
"task.organization.pay-accounts.description": "Membaca dan mengubah akaun gaji setiap pekerja: bank dan nombor."
|
|
108
|
+
"task.organization.pay-accounts.description": "Membaca dan mengubah akaun gaji setiap pekerja: bank dan nombor.",
|
|
109
|
+
"title.my-employee-profile": "profil saya",
|
|
110
|
+
"title.employee-profile-list": "profil pekerja",
|
|
111
|
+
"button.employee-profile-download": "muat turun sebagai Excel",
|
|
112
|
+
"label.employee-profile-summary": "ringkasan",
|
|
113
|
+
"label.employee-profile-skills": "kemahiran",
|
|
114
|
+
"label.employee-profile-educations": "pendidikan",
|
|
115
|
+
"label.employee-profile-certificates": "sijil",
|
|
116
|
+
"label.employee-profile-careers": "kerjaya",
|
|
117
|
+
"label.employee-profile-projects": "projek",
|
|
118
|
+
"label.employee-profile-school": "sekolah",
|
|
119
|
+
"label.employee-profile-major": "bidang",
|
|
120
|
+
"label.employee-profile-degree": "ijazah",
|
|
121
|
+
"label.employee-profile-started-on": "dari",
|
|
122
|
+
"label.employee-profile-ended-on": "hingga",
|
|
123
|
+
"label.employee-profile-period": "tempoh",
|
|
124
|
+
"label.employee-profile-certificate": "sijil",
|
|
125
|
+
"label.employee-profile-issuer": "dikeluarkan oleh",
|
|
126
|
+
"label.employee-profile-acquired-on": "diperoleh pada",
|
|
127
|
+
"label.employee-profile-company": "syarikat",
|
|
128
|
+
"label.employee-profile-position": "jawatan",
|
|
129
|
+
"label.employee-profile-duties": "tugas",
|
|
130
|
+
"label.employee-profile-project": "projek",
|
|
131
|
+
"label.employee-profile-client": "pelanggan",
|
|
132
|
+
"label.employee-profile-role": "peranan",
|
|
133
|
+
"label.employee-profile-technologies": "teknologi",
|
|
134
|
+
"label.employee-profile-updated-at": "profil dikemas kini",
|
|
135
|
+
"text.employee-profile-skills-hint": "pisahkan dengan koma",
|
|
136
|
+
"text.employee-profile-not-linked-title": "akaun ini tidak dipautkan kepada rekod pekerja",
|
|
137
|
+
"text.employee-profile-not-linked-description": "setiap pekerja menulis profil sendiri. Pentadbir $t(term.domain) boleh memautkan akaun ini kepada pekerja dalam senarai pekerja.",
|
|
138
|
+
"text.employee-profile-select-rows-first": "pilih pekerja yang hendak dimuat turun dahulu",
|
|
139
|
+
"text.employee-profile-file": "profil-pekerja",
|
|
140
|
+
"text.employee-profile-empty": "tiada pekerja aktif",
|
|
141
|
+
"text.employee-profile-no-rows": "belum ada yang ditulis",
|
|
142
|
+
"task.organization.own-profile.name": "Profil saya",
|
|
143
|
+
"task.organization.own-profile.description": "Membaca dan menulis profil sendiri: ringkasan, kemahiran, pendidikan, sijil, kerjaya dan projek.",
|
|
144
|
+
"task.organization.profiles-reading.name": "Membaca profil pekerja",
|
|
145
|
+
"task.organization.profiles-reading.description": "Membaca profil setiap pekerja aktif dan memuat turunnya sebagai Excel. Hubungan, alamat, akaun dan gaji tidak termasuk."
|
|
109
146
|
}
|