@codihaus/odp-app-hr 0.1.0

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 (192) hide show
  1. package/app/components/hr/contact/tab.vue +238 -0
  2. package/app/components/hr/department/card.vue +141 -0
  3. package/app/components/hr/department/form-modal.vue +90 -0
  4. package/app/components/hr/employees/assets/tab.vue +432 -0
  5. package/app/components/hr/employees/compensation.vue +136 -0
  6. package/app/components/hr/employees/contract.vue +77 -0
  7. package/app/components/hr/employees/insurance.vue +164 -0
  8. package/app/components/hr/employees/leave/tab.vue +180 -0
  9. package/app/components/hr/employees/provisioning/form-modal.vue +219 -0
  10. package/app/components/hr/employees/provisioning/tab.vue +187 -0
  11. package/app/components/hr/employees/tab.vue +38 -0
  12. package/app/components/hr/leave/calendar-tab.vue +649 -0
  13. package/app/components/hr/leave/override-modal.vue +62 -0
  14. package/app/components/hr/leave/request-modal.vue +185 -0
  15. package/app/components/hr/leave/requests-tab.vue +289 -0
  16. package/app/components/hr/leave/timeline-tab.vue +259 -0
  17. package/app/components/hr/offboarding/tab.vue +303 -0
  18. package/app/components/hr/person/activity/tab.vue +65 -0
  19. package/app/components/hr/person/activity/timeline.vue +119 -0
  20. package/app/components/hr/person/detail.vue +303 -0
  21. package/app/components/hr/person/document/tab-documents.vue +120 -0
  22. package/app/components/hr/person/document/template-edit-drawer.vue +215 -0
  23. package/app/components/hr/person/document/template-preview-card.vue +39 -0
  24. package/app/components/hr/person/document/trigger-modal.vue +121 -0
  25. package/app/components/hr/person/employee-form-modal.vue +78 -0
  26. package/app/components/hr/person/form-modal.vue +78 -0
  27. package/app/components/hr/person/list-row.vue +40 -0
  28. package/app/components/hr/person/profile/tab.vue +231 -0
  29. package/app/components/hr/settings/automation.vue +113 -0
  30. package/app/components/hr/settings/documents.vue +200 -0
  31. package/app/components/hr/settings/general.vue +87 -0
  32. package/app/components/hr/settings/holidays.vue +171 -0
  33. package/app/components/hr/settings/integrations.vue +185 -0
  34. package/app/components/hr/settings/policies.vue +83 -0
  35. package/app/components/hr/settings/policy/benefit-override-modal.vue +59 -0
  36. package/app/components/hr/settings/policy/editor-eligibility.vue +27 -0
  37. package/app/components/hr/settings/policy/editor-leave-base.vue +37 -0
  38. package/app/components/hr/settings/policy/editor-tenure-bonus.vue +61 -0
  39. package/app/components/hr/settings/recruitment.vue +128 -0
  40. package/app/components/hr/settings/taxonomies.vue +170 -0
  41. package/app/components/hr/shared/row.vue +21 -0
  42. package/app/components/hr/shared/section.vue +20 -0
  43. package/app/components/hr/shared/source-badge.vue +42 -0
  44. package/app/components/hr/shared/stage-badge.vue +24 -0
  45. package/app/components/hr/shared/workflow-timeline.vue +27 -0
  46. package/app/components/hr/talents/app-sidebar.vue +54 -0
  47. package/app/components/hr/talents/application-form-modal.vue +114 -0
  48. package/app/components/hr/talents/pipeline-picker.vue +56 -0
  49. package/app/components/hr/talents/step-detail.vue +133 -0
  50. package/app/components/hr/talents/step-stepper.vue +85 -0
  51. package/app/components/hr/talents/tab.vue +263 -0
  52. package/app/composables/use-departments.ts +59 -0
  53. package/app/composables/use-employee-detail.ts +24 -0
  54. package/app/composables/use-holidays.ts +48 -0
  55. package/app/composables/use-hr-api.ts +210 -0
  56. package/app/composables/use-hr-field-registry.ts +76 -0
  57. package/app/composables/use-hr-policies.ts +66 -0
  58. package/app/composables/use-hr-settings.ts +118 -0
  59. package/app/composables/use-leave.ts +71 -0
  60. package/app/composables/use-offboarding.ts +49 -0
  61. package/app/composables/use-people.ts +149 -0
  62. package/app/composables/use-providers.ts +44 -0
  63. package/app/composables/use-recruitment-workflow.ts +173 -0
  64. package/app/composables/use-templates.ts +44 -0
  65. package/app/composables/use-triggers.ts +26 -0
  66. package/app/config/column-renderers.ts +4 -0
  67. package/app/config/form-layouts.ts +193 -0
  68. package/app/data/hr-schema.ts +2608 -0
  69. package/app/lib/policy-engine.ts +116 -0
  70. package/app/pages/hr/departments.vue +114 -0
  71. package/app/pages/hr/employees/[id]/activity.vue +10 -0
  72. package/app/pages/hr/employees/[id]/assets.vue +14 -0
  73. package/app/pages/hr/employees/[id]/employment.vue +14 -0
  74. package/app/pages/hr/employees/[id]/index.vue +9 -0
  75. package/app/pages/hr/employees/[id]/offboarding.vue +7 -0
  76. package/app/pages/hr/employees/[id]/profile.vue +11 -0
  77. package/app/pages/hr/employees/[id]/provisioning.vue +17 -0
  78. package/app/pages/hr/employees/[id].vue +313 -0
  79. package/app/pages/hr/employees/index.vue +291 -0
  80. package/app/pages/hr/index.vue +3 -0
  81. package/app/pages/hr/leave.vue +79 -0
  82. package/app/pages/hr/settings.vue +43 -0
  83. package/app/pages/hr/setup.vue +3 -0
  84. package/app/pages/hr/talents/[id]/interview/[stepId].vue +231 -0
  85. package/app/pages/hr/talents/[id].vue +52 -0
  86. package/app/pages/hr/talents/index.vue +224 -0
  87. package/app/pages/hr.vue +129 -0
  88. package/app/plugins/hr-contacts-sync.client.ts +3 -0
  89. package/app/plugins/hr-extensions.ts +36 -0
  90. package/app/plugins/hr-setup.ts +5 -0
  91. package/app/plugins/navigations.ts +22 -0
  92. package/app/utils/hr-permissions.ts +27 -0
  93. package/app/utils/hr-policy-seed-step.ts +110 -0
  94. package/i18n/locales/en.json +726 -0
  95. package/i18n/locales/vi.json +688 -0
  96. package/nuxt.config.ts +19 -0
  97. package/package.json +27 -0
  98. package/server/api/hr/departments/[id].delete.ts +12 -0
  99. package/server/api/hr/departments/[id].patch.ts +14 -0
  100. package/server/api/hr/departments/index.get.ts +11 -0
  101. package/server/api/hr/departments/index.post.ts +13 -0
  102. package/server/api/hr/documents/templates/[id]/preview.post.ts +16 -0
  103. package/server/api/hr/documents/templates/[id].delete.ts +14 -0
  104. package/server/api/hr/documents/templates/[id].patch.ts +16 -0
  105. package/server/api/hr/documents/templates/index.get.ts +15 -0
  106. package/server/api/hr/documents/templates/index.post.ts +15 -0
  107. package/server/api/hr/documents/triggers/[id].patch.ts +16 -0
  108. package/server/api/hr/documents/triggers/index.get.ts +13 -0
  109. package/server/api/hr/fields/[collection].get.ts +14 -0
  110. package/server/api/hr/holidays/[id].delete.ts +14 -0
  111. package/server/api/hr/holidays/[id].patch.ts +16 -0
  112. package/server/api/hr/holidays/copy.post.ts +15 -0
  113. package/server/api/hr/holidays/index.get.ts +15 -0
  114. package/server/api/hr/holidays/index.post.ts +15 -0
  115. package/server/api/hr/leave/requests/[id].patch.ts +22 -0
  116. package/server/api/hr/leave/requests.get.ts +15 -0
  117. package/server/api/hr/leave/types.get.ts +13 -0
  118. package/server/api/hr/offboarding/[id]/cancel.post.ts +8 -0
  119. package/server/api/hr/offboarding/[id]/deprovision.post.ts +8 -0
  120. package/server/api/hr/offboarding/[id]/finalize.post.ts +8 -0
  121. package/server/api/hr/offboarding/[id]/return-assets.post.ts +8 -0
  122. package/server/api/hr/offboarding/[id]/settlement.get.ts +8 -0
  123. package/server/api/hr/offboarding/[id]/tasks/[taskId].patch.ts +10 -0
  124. package/server/api/hr/offboarding/[id].get.ts +8 -0
  125. package/server/api/hr/offboarding/[id].patch.ts +9 -0
  126. package/server/api/hr/offboarding/index.get.ts +7 -0
  127. package/server/api/hr/people/[id]/applications/[appId]/interviews/[iid].delete.ts +16 -0
  128. package/server/api/hr/people/[id]/applications/[appId]/interviews/[iid].patch.ts +18 -0
  129. package/server/api/hr/people/[id]/applications/[appId]/interviews/index.get.ts +15 -0
  130. package/server/api/hr/people/[id]/applications/[appId]/interviews/index.post.ts +17 -0
  131. package/server/api/hr/people/[id]/applications/[appId].patch.ts +17 -0
  132. package/server/api/hr/people/[id]/applications/index.get.ts +14 -0
  133. package/server/api/hr/people/[id]/applications/index.post.ts +16 -0
  134. package/server/api/hr/people/[id]/assets/[aid].delete.ts +13 -0
  135. package/server/api/hr/people/[id]/assets/[aid].patch.ts +15 -0
  136. package/server/api/hr/people/[id]/assets/index.get.ts +12 -0
  137. package/server/api/hr/people/[id]/assets/index.post.ts +14 -0
  138. package/server/api/hr/people/[id]/compensations.get.ts +14 -0
  139. package/server/api/hr/people/[id]/compensations.patch.ts +16 -0
  140. package/server/api/hr/people/[id]/contracts.get.ts +14 -0
  141. package/server/api/hr/people/[id]/contracts.patch.ts +16 -0
  142. package/server/api/hr/people/[id]/documents/[did].delete.ts +15 -0
  143. package/server/api/hr/people/[id]/documents/index.get.ts +14 -0
  144. package/server/api/hr/people/[id]/documents/index.post.ts +16 -0
  145. package/server/api/hr/people/[id]/insurances.get.ts +14 -0
  146. package/server/api/hr/people/[id]/insurances.patch.ts +16 -0
  147. package/server/api/hr/people/[id]/leave-balances/[bid].patch.ts +17 -0
  148. package/server/api/hr/people/[id]/leave-balances/index.get.ts +14 -0
  149. package/server/api/hr/people/[id]/leave-requests/index.get.ts +14 -0
  150. package/server/api/hr/people/[id]/leave-requests/index.post.ts +16 -0
  151. package/server/api/hr/people/[id]/link-user.post.ts +16 -0
  152. package/server/api/hr/people/[id]/notes/[nid].delete.ts +15 -0
  153. package/server/api/hr/people/[id]/notes/index.get.ts +14 -0
  154. package/server/api/hr/people/[id]/notes/index.post.ts +16 -0
  155. package/server/api/hr/people/[id]/offboarding/cases.get.ts +12 -0
  156. package/server/api/hr/people/[id]/offboarding.get.ts +12 -0
  157. package/server/api/hr/people/[id]/offboarding.post.ts +14 -0
  158. package/server/api/hr/people/[id]/provisioning/[logId]/retry.post.ts +7 -0
  159. package/server/api/hr/people/[id]/provisioning/index.get.ts +6 -0
  160. package/server/api/hr/people/[id]/provisioning/index.post.ts +7 -0
  161. package/server/api/hr/people/[id]/transition.post.ts +19 -0
  162. package/server/api/hr/people/[id]/transitions.get.ts +14 -0
  163. package/server/api/hr/people/[id].delete.ts +15 -0
  164. package/server/api/hr/people/[id].get.ts +14 -0
  165. package/server/api/hr/people/[id].patch.ts +17 -0
  166. package/server/api/hr/people/index.get.ts +15 -0
  167. package/server/api/hr/people/index.post.ts +19 -0
  168. package/server/api/hr/policies/[id].patch.ts +16 -0
  169. package/server/api/hr/policies/index.get.ts +13 -0
  170. package/server/api/hr/providers/[id]/test.post.ts +6 -0
  171. package/server/api/hr/providers/[id].delete.ts +6 -0
  172. package/server/api/hr/providers/[id].patch.ts +7 -0
  173. package/server/api/hr/providers/index.get.ts +5 -0
  174. package/server/api/hr/providers/index.post.ts +6 -0
  175. package/server/api/hr/settings/employment-types/[id].delete.ts +14 -0
  176. package/server/api/hr/settings/employment-types/[id].patch.ts +16 -0
  177. package/server/api/hr/settings/employment-types/index.get.ts +13 -0
  178. package/server/api/hr/settings/employment-types/index.post.ts +15 -0
  179. package/server/api/hr/settings/index.get.ts +13 -0
  180. package/server/api/hr/settings/index.patch.ts +15 -0
  181. package/server/api/hr/settings/leave-types/[id].delete.ts +14 -0
  182. package/server/api/hr/settings/leave-types/[id].patch.ts +16 -0
  183. package/server/api/hr/settings/leave-types/index.get.ts +13 -0
  184. package/server/api/hr/settings/leave-types/index.post.ts +15 -0
  185. package/shared/types/form-layout.ts +30 -0
  186. package/shared/types/index.ts +2 -0
  187. package/shared/types/integration.ts +41 -0
  188. package/shared/types/leave.ts +53 -0
  189. package/shared/types/offboarding.ts +46 -0
  190. package/shared/types/person.ts +54 -0
  191. package/shared/types/settings.ts +16 -0
  192. package/shared/utils/template-render.ts +155 -0
@@ -0,0 +1,14 @@
1
+ import { customEndpoint } from "@odp/sdk"
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ assertPermission(event, { module: 'hr', action: 'admin' })
5
+ const id = getRouterParam(event, 'id')
6
+ const userApi = event.context.userApi
7
+
8
+ const result = await userApi.request(customEndpoint({
9
+ path: `/hr/settings/leave-types/${id}`,
10
+ method: "DELETE",
11
+ }))
12
+
13
+ return result
14
+ })
@@ -0,0 +1,16 @@
1
+ import { customEndpoint } from "@odp/sdk"
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ assertPermission(event, { module: 'hr', action: 'admin' })
5
+ const id = getRouterParam(event, 'id')
6
+ const body = await readBody(event)
7
+ const userApi = event.context.userApi
8
+
9
+ const result = await userApi.request(customEndpoint({
10
+ path: `/hr/settings/leave-types/${id}`,
11
+ method: "PATCH",
12
+ body: JSON.stringify(body),
13
+ }))
14
+
15
+ return result
16
+ })
@@ -0,0 +1,13 @@
1
+ import { customEndpoint } from "@odp/sdk"
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ assertPermission(event, { module: 'hr', action: 'read' })
5
+ const userApi = event.context.userApi
6
+
7
+ const result = await userApi.request(customEndpoint({
8
+ path: "/hr/settings/leave-types",
9
+ method: "GET",
10
+ }))
11
+
12
+ return result
13
+ })
@@ -0,0 +1,15 @@
1
+ import { customEndpoint } from "@odp/sdk"
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ assertPermission(event, { module: 'hr', action: 'admin' })
5
+ const body = await readBody(event)
6
+ const userApi = event.context.userApi
7
+
8
+ const result = await userApi.request(customEndpoint({
9
+ path: "/hr/settings/leave-types",
10
+ method: "POST",
11
+ body: JSON.stringify(body),
12
+ }))
13
+
14
+ return result
15
+ })
@@ -0,0 +1,30 @@
1
+ export interface HrFormField {
2
+ key: string
3
+ label?: string
4
+ type?: string
5
+ colspan?: number
6
+ options?: Array<{ value: string, label: string }>
7
+ required?: boolean
8
+ placeholder?: string
9
+ }
10
+
11
+ export interface HrFormSection {
12
+ title?: string
13
+ columns?: 1 | 2
14
+ fields: HrFormField[]
15
+ }
16
+
17
+ export type HrFormLayout = HrFormSection[]
18
+
19
+ export interface HrInfoRow {
20
+ label: string
21
+ key: string
22
+ format?: 'date' | 'currency' | 'phone' | 'badge'
23
+ }
24
+
25
+ export interface HrInfoSection {
26
+ title?: string
27
+ rows: HrInfoRow[]
28
+ }
29
+
30
+ export type HrInfoLayout = HrInfoSection[]
@@ -0,0 +1,2 @@
1
+ // Nuxt auto-imports from individual files in shared/types/
2
+ // Do not re-export here to avoid duplicated import warnings
@@ -0,0 +1,41 @@
1
+ export type ProviderType = 'authentik' | 'google_workspace' | 'microsoft_365' | 'webhook'
2
+
3
+ export interface HrProvider {
4
+ id: string
5
+ name: string
6
+ type: ProviderType
7
+ config: Record<string, unknown>
8
+ enabled: boolean
9
+ default_group?: string
10
+ actions: {
11
+ on_hire: boolean
12
+ on_resign: boolean
13
+ on_reactivate: boolean
14
+ }
15
+ date_created?: string
16
+ date_updated?: string
17
+ }
18
+
19
+ export interface HrProvisioningLog {
20
+ id: string
21
+ person_id: string
22
+ provider_id: string
23
+ action: 'create' | 'disable' | 'enable' | 'retry'
24
+ status: 'success' | 'failed' | 'skipped'
25
+ request_payload?: Record<string, unknown>
26
+ response_data?: Record<string, unknown>
27
+ error_message?: string | null
28
+ date_created: string
29
+ }
30
+
31
+ export interface ProvisioningStatus {
32
+ provider_id: string
33
+ provider_name: string
34
+ provider_type: ProviderType
35
+ status: 'active' | 'disabled' | 'not_created' | 'error'
36
+ username: string | null
37
+ last_action: string | null
38
+ last_action_date: string | null
39
+ last_error: string | null
40
+ last_log_id: string | null
41
+ }
@@ -0,0 +1,53 @@
1
+ export interface LeaveType {
2
+ id: number
3
+ name: string
4
+ key: string
5
+ color?: string
6
+ archived?: boolean
7
+ }
8
+
9
+ export interface LeaveBalance {
10
+ id: number
11
+ person_id: number | string
12
+ leave_type_id: LeaveType | number
13
+ policy_total: number
14
+ override_total?: number | null
15
+ override_reason?: string | null
16
+ used: number
17
+ // Computed helpers (front-end only)
18
+ total?: number
19
+ remaining?: number
20
+ source?: 'policy' | 'override'
21
+ type?: string
22
+ policy_value?: number
23
+ override_note?: string
24
+ }
25
+
26
+ export interface LeaveRequest {
27
+ id: string | number
28
+ person_id: string | number
29
+ person_name?: string
30
+ leave_type_id: LeaveType | number
31
+ leave_type?: string
32
+ start_date: string
33
+ end_date: string
34
+ from_date?: string
35
+ to_date?: string
36
+ days_count?: number
37
+ days?: number
38
+ reason?: string
39
+ status: 'Pending' | 'Approved' | 'Rejected' | 'Cancelled'
40
+ reject_reason?: string
41
+ reviewed_by?: string
42
+ reviewed_at?: string
43
+ date_created?: string
44
+ }
45
+
46
+ export interface CompanyHoliday {
47
+ id: number
48
+ name: string
49
+ date: string
50
+ year?: number
51
+ recurring?: boolean
52
+ sort?: number
53
+ }
@@ -0,0 +1,46 @@
1
+ export type OffboardingStatus = 'initiated' | 'in_progress' | 'completed' | 'cancelled'
2
+ export type OffboardingExitType = 'voluntary' | 'involuntary' | 'mutual' | 'contract_end' | 'retirement'
3
+ export type OffboardingTaskStatus = 'pending' | 'done' | 'na'
4
+
5
+ export interface OffboardingTask {
6
+ id: number
7
+ offboarding_id: number
8
+ key: string
9
+ label: string
10
+ category: string
11
+ status: OffboardingTaskStatus
12
+ assignee?: string | null
13
+ completed_by?: string | null
14
+ completed_at?: string | null
15
+ notes?: string | null
16
+ sort?: number
17
+ }
18
+
19
+ export interface Offboarding {
20
+ id: number
21
+ person_id: number
22
+ status: OffboardingStatus
23
+ exit_type: OffboardingExitType
24
+ reason?: string | null
25
+ notice_date?: string | null
26
+ last_working_day?: string | null
27
+ rehire_eligible?: boolean
28
+ exit_interview_notes?: string | null
29
+ exit_interview_rating?: number | null
30
+ leave_payout_days?: number | null
31
+ leave_payout_amount?: number | null
32
+ final_settlement_notes?: string | null
33
+ workflow_instance_id?: string | null
34
+ initiated_by?: string | null
35
+ completed_by?: string | null
36
+ completed_at?: string | null
37
+ date_created?: string
38
+ date_updated?: string
39
+ tasks?: OffboardingTask[]
40
+ }
41
+
42
+ export interface OffboardingSettlement {
43
+ year: number
44
+ lines: { leave_type: string, allocated: number, used: number, remaining: number }[]
45
+ totalRemainingDays: number
46
+ }
@@ -0,0 +1,54 @@
1
+ export const LIFECYCLE_STAGES = ['talent', 'interviewing', 'offer', 'probation', 'active', 'resigned'] as const
2
+
3
+ export type LifecycleStage = typeof LIFECYCLE_STAGES[number]
4
+
5
+ export interface StageTransition {
6
+ id: string
7
+ person_id: string
8
+ from_stage: LifecycleStage | null
9
+ to_stage: LifecycleStage
10
+ reason?: string
11
+ performed_by?: string
12
+ date_created: string
13
+ }
14
+
15
+ export interface HrNote {
16
+ id: string
17
+ person_id: string
18
+ body: string
19
+ author?: string | null
20
+ date_created: string
21
+ }
22
+
23
+ export interface Person {
24
+ id: string
25
+ stage: LifecycleStage
26
+ employee_id?: string
27
+
28
+ // Identity
29
+ first_name: string
30
+ last_name: string
31
+ display_name: string
32
+ email?: string
33
+ phone?: string
34
+ avatar?: string | null
35
+ date_of_birth?: string
36
+ gender?: string
37
+ nationality?: string
38
+ id_number?: string
39
+ address?: string
40
+
41
+ // Recruitment
42
+ desired_position?: string
43
+ source?: string
44
+ department_id?: number | null
45
+
46
+ // Employment (filled after hire)
47
+ employment_type_id?: number | null
48
+ job_title?: string
49
+ start_date?: string
50
+ end_date?: string
51
+
52
+ date_created: string
53
+ date_updated?: string
54
+ }
@@ -0,0 +1,16 @@
1
+ export interface HrSettings {
2
+ id: number
3
+ company_name?: string
4
+ currency?: string // default 'VND'
5
+ fiscal_year_start?: number // 1-12 (month)
6
+ probation_months_default?: number
7
+ leave_year_start?: number // 1-12
8
+
9
+ // Stage label overrides (optional, defaults to LifecycleStage enum)
10
+ label_talent?: string
11
+ label_interviewing?: string
12
+ label_offer?: string
13
+ label_probation?: string
14
+ label_active?: string
15
+ label_resigned?: string
16
+ }
@@ -0,0 +1,155 @@
1
+ import MarkdownIt from "markdown-it"
2
+
3
+ export type Context = Record<string, unknown>
4
+
5
+ type Node
6
+ = | { kind: "text", value: string }
7
+ | { kind: "interp", path: string, format?: string }
8
+ | { kind: "each", path: string, body: Node[] }
9
+ | { kind: "if", path: string, body: Node[] }
10
+
11
+ const md = new MarkdownIt({ html: false, linkify: true, breaks: true })
12
+
13
+ interface Token {
14
+ kind: "text" | "open_each" | "close_each" | "open_if" | "close_if" | "interp"
15
+ raw: string
16
+ path?: string
17
+ format?: string
18
+ value?: string
19
+ }
20
+
21
+ const DIRECTIVE_RE = /\{\{\s*([^}]+?)\s*\}\}/g
22
+
23
+ function tokenize(body: string): Token[] {
24
+ const tokens: Token[] = []
25
+ let lastIdx = 0
26
+ for (const match of body.matchAll(DIRECTIVE_RE)) {
27
+ const matchIdx = match.index!
28
+ if (matchIdx > lastIdx) {
29
+ const text = body.slice(lastIdx, matchIdx)
30
+ tokens.push({ kind: "text", raw: text, value: text })
31
+ }
32
+ const inner = match[1]!.trim()
33
+ if (inner.startsWith("#each ")) {
34
+ tokens.push({ kind: "open_each", raw: match[0], path: inner.slice(6).trim() })
35
+ } else if (inner === "/each") {
36
+ tokens.push({ kind: "close_each", raw: match[0] })
37
+ } else if (inner.startsWith("#if ")) {
38
+ tokens.push({ kind: "open_if", raw: match[0], path: inner.slice(4).trim() })
39
+ } else if (inner === "/if") {
40
+ tokens.push({ kind: "close_if", raw: match[0] })
41
+ } else {
42
+ const parts = inner.split("|").map(s => s.trim())
43
+ tokens.push({ kind: "interp", raw: match[0], path: parts[0]!, format: parts[1] })
44
+ }
45
+ lastIdx = matchIdx + match[0].length
46
+ }
47
+ if (lastIdx < body.length) {
48
+ const text = body.slice(lastIdx)
49
+ tokens.push({ kind: "text", raw: text, value: text })
50
+ }
51
+ return tokens
52
+ }
53
+
54
+ function parse(tokens: Token[]): Node[] {
55
+ const stack: Node[][] = [[]]
56
+ const meta: Array<{ kind: "each" | "if", path: string }> = []
57
+
58
+ for (const t of tokens) {
59
+ const top = stack[stack.length - 1]!
60
+ if (t.kind === "text") {
61
+ top.push({ kind: "text", value: t.value! })
62
+ } else if (t.kind === "interp") {
63
+ top.push({ kind: "interp", path: t.path!, format: t.format })
64
+ } else if (t.kind === "open_each") {
65
+ stack.push([])
66
+ meta.push({ kind: "each", path: t.path! })
67
+ } else if (t.kind === "open_if") {
68
+ stack.push([])
69
+ meta.push({ kind: "if", path: t.path! })
70
+ } else if (t.kind === "close_each" || t.kind === "close_if") {
71
+ const inner = stack.pop()!
72
+ const m = meta.pop()!
73
+ const parent = stack[stack.length - 1]!
74
+ parent.push({ kind: m.kind, path: m.path, body: inner } as Node)
75
+ }
76
+ }
77
+
78
+ if (stack.length !== 1) throw new Error("Template: unbalanced block directive")
79
+ return stack[0]!
80
+ }
81
+
82
+ function getPath(ctx: unknown, path: string): unknown {
83
+ if (path === "today") return new Date().toISOString().slice(0, 10)
84
+ const parts = path.split(".")
85
+ let cur: unknown = ctx
86
+ for (const part of parts) {
87
+ if (cur == null) return undefined
88
+ const m = part.match(/^([^[]+)\[(\d+)\]$/)
89
+ if (m) {
90
+ cur = (cur as Record<string, unknown>)[m[1]!]
91
+ if (Array.isArray(cur)) cur = cur[parseInt(m[2]!, 10)]
92
+ else return undefined
93
+ } else {
94
+ cur = (cur as Record<string, unknown>)[part]
95
+ }
96
+ }
97
+ return cur
98
+ }
99
+
100
+ function formatValue(value: unknown, format?: string): string {
101
+ if (value == null) return ""
102
+ if (format === "long" && typeof value === "string" && /^\d{4}-\d{2}-\d{2}/.test(value)) {
103
+ const d = new Date(value)
104
+ if (!Number.isNaN(d.getTime())) {
105
+ return d.toLocaleDateString("en-GB", { day: "numeric", month: "long", year: "numeric" })
106
+ }
107
+ }
108
+ if (typeof value === "object") return JSON.stringify(value)
109
+ return String(value)
110
+ }
111
+
112
+ function isTruthy(value: unknown): boolean {
113
+ if (value == null) return false
114
+ if (Array.isArray(value)) return value.length > 0
115
+ if (typeof value === "string") return value.length > 0
116
+ if (typeof value === "number") return value !== 0
117
+ if (typeof value === "object") return Object.keys(value as object).length > 0
118
+ return Boolean(value)
119
+ }
120
+
121
+ function renderNodes(nodes: Node[], ctx: Context): string {
122
+ let out = ""
123
+ for (const n of nodes) {
124
+ if (n.kind === "text") out += n.value
125
+ else if (n.kind === "interp") out += formatValue(getPath(ctx, n.path), n.format)
126
+ else if (n.kind === "if") { if (isTruthy(getPath(ctx, n.path))) out += renderNodes(n.body, ctx) } else if (n.kind === "each") {
127
+ const list = getPath(ctx, n.path)
128
+ if (Array.isArray(list)) {
129
+ for (const item of list) out += renderNodes(n.body, item as Context)
130
+ }
131
+ }
132
+ }
133
+ return out
134
+ }
135
+
136
+ export function renderTemplateMarkdown(body: string, ctx: Context): string {
137
+ return renderNodes(parse(tokenize(body)), ctx)
138
+ }
139
+
140
+ export function renderTemplateHtml(body: string, ctx: Context): string {
141
+ return md.render(renderTemplateMarkdown(body, ctx))
142
+ }
143
+
144
+ export const PLACEHOLDER_HINTS_PERSON: string[] = [
145
+ "full_name", "email", "employee_id", "department", "job_title",
146
+ "employment_type", "start_date", "manager", "work_location",
147
+ "contract.type", "contract.start_date", "contract.end_date", "contract.probation_end",
148
+ "compensation.base_salary", "compensation.currency", "compensation.pay_frequency",
149
+ "insurance.social_insurance_number", "insurance.tax_code",
150
+ "emergency_contact.name", "emergency_contact.phone",
151
+ "resignation.last_day", "resignation.reason",
152
+ "today",
153
+ "#each assets", "/each", "#each interviews", "/each",
154
+ "#if resignation.last_day", "/if",
155
+ ]