@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,259 @@
1
+ <template>
2
+ <div class="flex flex-col h-full">
3
+ <!-- Toolbar -->
4
+ <div class="flex items-center gap-2 px-4 py-3 border-b border-default">
5
+ <UButton
6
+ icon="i-ph-caret-left"
7
+ size="sm"
8
+ variant="ghost"
9
+ @click="prevMonth"
10
+ />
11
+ <UButton
12
+ :label="$t('calendar.today')"
13
+ size="sm"
14
+ variant="outline"
15
+ @click="goToday"
16
+ />
17
+ <UButton
18
+ icon="i-ph-caret-right"
19
+ size="sm"
20
+ variant="ghost"
21
+ @click="nextMonth"
22
+ />
23
+ <span class="ml-2 text-base font-semibold">{{ rangeLabel }}</span>
24
+ <div class="flex-1" />
25
+ <UInput
26
+ v-model="searchQuery"
27
+ icon="i-ph-magnifying-glass"
28
+ :placeholder="$t('common.search')"
29
+ size="sm"
30
+ class="w-56"
31
+ />
32
+ </div>
33
+
34
+ <!-- Legend -->
35
+ <div class="flex items-center gap-4 text-xs text-muted px-4 py-2 border-b border-default">
36
+ <span class="flex items-center gap-1.5">
37
+ <span class="size-2.5 rounded-sm bg-success/60" />
38
+ {{ $t('hr.leave.status.approved') }}
39
+ </span>
40
+ <span class="flex items-center gap-1.5">
41
+ <span class="size-2.5 rounded-sm border border-dashed border-warning bg-warning/20" />
42
+ {{ $t('hr.leave.status.pending') }}
43
+ </span>
44
+ <span class="flex items-center gap-1.5">
45
+ <span class="size-2.5 rounded-sm bg-error/30" />
46
+ {{ $t('holidays.title') }}
47
+ </span>
48
+ </div>
49
+
50
+ <!-- Gantt grid -->
51
+ <div class="flex-1 overflow-auto">
52
+ <div
53
+ class="grid"
54
+ :style="gridStyle"
55
+ >
56
+ <!-- Header row -->
57
+ <div class="sticky left-0 top-0 z-20 bg-default border-b border-r border-default px-4 py-2.5 flex items-center">
58
+ <span class="text-xs uppercase tracking-wider text-muted font-medium">
59
+ {{ activeEmployees.length }} members
60
+ </span>
61
+ </div>
62
+ <div
63
+ v-for="day in days"
64
+ :key="day.iso"
65
+ class="sticky top-0 z-10 bg-default border-b border-r border-default text-center py-1.5 last:border-r-0 min-w-[32px]"
66
+ :class="[
67
+ day.isWeekend && 'bg-elevated/40',
68
+ day.isToday && 'bg-primary/10',
69
+ day.isHoliday && !day.isWeekend && 'bg-error/10',
70
+ ]"
71
+ :title="day.holidayName"
72
+ >
73
+ <div
74
+ class="text-xs font-medium leading-tight"
75
+ :class="day.isToday ? 'text-primary' : day.isHoliday ? 'text-error' : ''"
76
+ >{{ day.d }}</div>
77
+ <div class="text-[9px] text-muted leading-tight">{{ day.dow }}</div>
78
+ </div>
79
+
80
+ <!-- Employee rows -->
81
+ <template
82
+ v-for="emp in activeEmployees"
83
+ :key="emp.id"
84
+ >
85
+ <div class="sticky left-0 z-10 bg-default border-b border-r border-default px-4 py-3 flex items-center gap-3 min-w-0 min-w-[220px]">
86
+ <UAvatar
87
+ icon="i-ph-user-light"
88
+ size="sm"
89
+ class="bg-primary/10 text-primary shrink-0"
90
+ />
91
+ <div class="min-w-0">
92
+ <div class="text-sm font-medium truncate">{{ emp.display_name }}</div>
93
+ <div class="text-xs text-muted truncate">{{ emp.job_title }}</div>
94
+ </div>
95
+ </div>
96
+ <div
97
+ class="relative border-b border-default"
98
+ :style="{ gridColumn: `2 / span ${days.length}` }"
99
+ >
100
+ <!-- Day cells -->
101
+ <div
102
+ class="grid h-full absolute inset-0"
103
+ :style="{ gridTemplateColumns: `repeat(${days.length}, 1fr)` }"
104
+ >
105
+ <div
106
+ v-for="day in days"
107
+ :key="day.iso"
108
+ class="border-r border-default last:border-r-0 min-h-[44px]"
109
+ :class="[
110
+ day.isWeekend && 'bg-elevated/40',
111
+ day.isToday && 'bg-primary/5',
112
+ day.isHoliday && !day.isWeekend && 'bg-error/5',
113
+ ]"
114
+ />
115
+ </div>
116
+ <!-- Leave pills -->
117
+ <div
118
+ v-for="bar in barsByPerson[String(emp.id)] || []"
119
+ :key="bar.id"
120
+ class="absolute inset-y-1.5 flex items-center px-2 rounded text-xs font-medium overflow-hidden whitespace-nowrap cursor-pointer"
121
+ :class="[
122
+ bar.status?.toLowerCase() === 'approved' ? 'bg-success/20 text-success border border-success/30' : 'bg-warning/20 text-warning border border-dashed border-warning/50 opacity-80',
123
+ ]"
124
+ :style="{ left: `${bar.leftPct}%`, width: `${bar.widthPct}%`, minWidth: '40px' }"
125
+ :title="`${bar.leaveType} · ${bar.start} → ${bar.end} (${bar.days}d)`"
126
+ >
127
+ <span class="truncate">{{ bar.leaveType }}</span>
128
+ </div>
129
+ </div>
130
+ </template>
131
+
132
+ <div
133
+ v-if="activeEmployees.length === 0"
134
+ class="col-span-full py-16 text-center text-sm text-muted"
135
+ >
136
+ {{ $t('common.no_data') }}
137
+ </div>
138
+ </div>
139
+ </div>
140
+ </div>
141
+ </template>
142
+
143
+ <script lang="ts" setup>
144
+ const { t } = useI18n()
145
+ const { leaveRequests } = useLeave()
146
+ const { holidays } = useHolidays()
147
+ const { employees } = usePeople()
148
+
149
+ const cursor = useState('timeline.cursor', () => new Date())
150
+ const searchQuery = ref('')
151
+
152
+ const WEEKDAYS_SHORT = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
153
+
154
+ function toIso(d: Date): string {
155
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
156
+ }
157
+ function dayDiff(a: Date, b: Date): number {
158
+ return Math.round((b.getTime() - a.getTime()) / 86400000)
159
+ }
160
+
161
+ const monthStart = computed(() => new Date(cursor.value.getFullYear(), cursor.value.getMonth(), 1))
162
+ const monthEnd = computed(() => new Date(cursor.value.getFullYear(), cursor.value.getMonth() + 1, 0))
163
+
164
+ const holidaySet = computed(() => {
165
+ const m = new Map<string, string>()
166
+ for (const h of holidays.value) {
167
+ if (h.date) m.set(toIso(new Date(h.date)), h.name)
168
+ }
169
+ return m
170
+ })
171
+
172
+ interface DayBucket { iso: string, d: number, dow: string, isWeekend: boolean, isToday: boolean, isHoliday: boolean, holidayName?: string }
173
+
174
+ const days = computed<DayBucket[]>(() => {
175
+ const today = toIso(new Date())
176
+ const list: DayBucket[] = []
177
+ const d = new Date(monthStart.value)
178
+ while (d <= monthEnd.value) {
179
+ const iso = toIso(d)
180
+ const dow = d.getDay()
181
+ list.push({
182
+ iso, d: d.getDate(), dow: WEEKDAYS_SHORT[dow]!,
183
+ isWeekend: dow === 0 || dow === 6,
184
+ isToday: iso === today,
185
+ isHoliday: holidaySet.value.has(iso),
186
+ holidayName: holidaySet.value.get(iso),
187
+ })
188
+ d.setDate(d.getDate() + 1)
189
+ }
190
+ return list
191
+ })
192
+
193
+ const rangeLabel = computed(() => {
194
+ const opts: Intl.DateTimeFormatOptions = { month: 'long', year: 'numeric' }
195
+ return monthStart.value.toLocaleDateString('en-US', opts)
196
+ })
197
+
198
+ const gridStyle = computed(() => ({
199
+ gridTemplateColumns: `minmax(220px, 220px) repeat(${days.value.length}, minmax(32px, 1fr))`,
200
+ }))
201
+
202
+ const activeEmployees = computed(() => {
203
+ const list = employees.value.filter((e: Person) => e.stage === 'active' || e.stage === 'probation')
204
+ const q = searchQuery.value.trim().toLowerCase()
205
+ return q ? list.filter((e: Person) => (e.display_name ?? '').toLowerCase().includes(q)) : list
206
+ })
207
+
208
+ interface Bar { id: string, leaveType: string, start: string, end: string, days: number, status: string, leftPct: number, widthPct: number }
209
+
210
+ const barsByPerson = computed<Record<string, Bar[]>>(() => {
211
+ const out: Record<string, Bar[]> = {}
212
+ const startIso = toIso(monthStart.value)
213
+ const endIso = toIso(monthEnd.value)
214
+ const totalDays = days.value.length
215
+
216
+ for (const lr of leaveRequests.value) {
217
+ const st = lr.status?.toLowerCase()
218
+ if (st === 'rejected' || st === 'cancelled') continue
219
+ const lrStart = (lr.start_date || lr.from_date) ? toIso(new Date(lr.start_date || lr.from_date)) : ''
220
+ const lrEnd = (lr.end_date || lr.to_date) ? toIso(new Date(lr.end_date || lr.to_date)) : ''
221
+ if (lrEnd < startIso || lrStart > endIso) continue
222
+
223
+ const clampedStart = lrStart < startIso ? startIso : lrStart
224
+ const clampedEnd = lrEnd > endIso ? endIso : lrEnd
225
+
226
+ const startD = new Date(monthStart.value)
227
+ const offsetDays = dayDiff(startD, new Date(clampedStart))
228
+ const spanDays = dayDiff(new Date(clampedStart), new Date(clampedEnd)) + 1
229
+
230
+ const leftPct = (offsetDays / totalDays) * 100
231
+ const widthPct = (spanDays / totalDays) * 100
232
+
233
+ let typeName = lr.leave_type ?? ''
234
+ if (!typeName && lr.leave_type_id && typeof lr.leave_type_id === 'object') {
235
+ typeName = (lr.leave_type_id as any).name ?? (lr.leave_type_id as any).label ?? ''
236
+ }
237
+
238
+ const bar: Bar = {
239
+ id: String(lr.id),
240
+ leaveType: typeName || 'Leave',
241
+ start: lrStart,
242
+ end: lrEnd,
243
+ days: lr.days_count ?? lr.days ?? spanDays,
244
+ status: lr.status,
245
+ leftPct,
246
+ widthPct,
247
+ }
248
+
249
+ const pid = String((lr.person_id as any)?.id ?? lr.person_id)
250
+ if (!out[pid]) out[pid] = []
251
+ out[pid]!.push(bar)
252
+ }
253
+ return out
254
+ })
255
+
256
+ function prevMonth() { cursor.value = new Date(cursor.value.getFullYear(), cursor.value.getMonth() - 1, 1) }
257
+ function nextMonth() { cursor.value = new Date(cursor.value.getFullYear(), cursor.value.getMonth() + 1, 1) }
258
+ function goToday() { cursor.value = new Date() }
259
+ </script>
@@ -0,0 +1,303 @@
1
+ <script lang="ts" setup>
2
+ import HrInfoSection from "../shared/section.vue"
3
+
4
+ const props = defineProps<{ person: Person }>()
5
+
6
+ const { t, te } = useI18n()
7
+ const toast = useToast()
8
+ const api = useHrApi()
9
+ const { onUpdated } = useEmployeeDetail()
10
+ const {
11
+ refreshKey, fetchForPerson, listForPerson, get, updateCase, updateTask,
12
+ returnAssets, deprovision, getSettlement, finalize, cancel,
13
+ } = useOffboarding()
14
+
15
+ const offcase = ref<Offboarding | null>(null)
16
+ const allCases = ref<Offboarding[]>([])
17
+ const settlement = ref<OffboardingSettlement | null>(null)
18
+ const loading = ref(false)
19
+ const busy = ref(false)
20
+ const showFinalize = ref(false)
21
+ const payoutAmount = ref<number | null>(null)
22
+ const detailCase = ref<Offboarding | null>(null)
23
+ const detailLoading = ref(false)
24
+
25
+ const history = computed(() => allCases.value.filter(c => c.id !== offcase.value?.id))
26
+
27
+ const TASK_ICON: Record<string, string> = {
28
+ knowledge_transfer: "i-ph-handshake-light",
29
+ asset_return: "i-ph-package-light",
30
+ access_revoke: "i-ph-lock-key-light",
31
+ leave_settlement: "i-ph-umbrella-light",
32
+ exit_interview: "i-ph-microphone-light",
33
+ finance_clearance: "i-ph-money-light",
34
+ manager_signoff: "i-ph-seal-check-light",
35
+ exit_documents: "i-ph-file-doc-light",
36
+ }
37
+ const STATUS_COLOR: Record<string, string> = { initiated: "info", in_progress: "warning", completed: "success", cancelled: "error" }
38
+
39
+ const readonly = computed(() => ["completed", "cancelled"].includes(offcase.value?.status ?? ""))
40
+ const sortedTasks = computed(() => [...(offcase.value?.tasks ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0)))
41
+
42
+ function taskLabel(tk: OffboardingTask) {
43
+ const key = `hr.offboarding.tasks.${tk.key}`
44
+ return te(key) ? t(key) : tk.label
45
+ }
46
+
47
+ async function load() {
48
+ loading.value = true
49
+ try {
50
+ const [current, cases] = await Promise.all([
51
+ fetchForPerson(props.person.id),
52
+ listForPerson(props.person.id),
53
+ ])
54
+ offcase.value = current
55
+ allCases.value = cases
56
+ payoutAmount.value = offcase.value?.leave_payout_amount ?? null
57
+ }
58
+ finally { loading.value = false }
59
+ }
60
+
61
+ async function openDetail(id: number) {
62
+ detailLoading.value = true
63
+ try { detailCase.value = await get(id) }
64
+ finally { detailLoading.value = false }
65
+ }
66
+ async function reloadCase() {
67
+ if (offcase.value) offcase.value = await fetchForPerson(props.person.id)
68
+ }
69
+
70
+ async function setTaskStatus(tk: OffboardingTask, status: string) {
71
+ if (readonly.value) return
72
+ await updateTask(offcase.value!.id, tk.id, { status: status as any })
73
+ await reloadCase()
74
+ }
75
+
76
+ async function onReturnAssets() {
77
+ busy.value = true
78
+ try {
79
+ const r = await returnAssets(offcase.value!.id)
80
+ toast.add({ title: t("hr.offboarding.toast.assets_returned", { count: r?.returned ?? 0 }), color: "success" })
81
+ await reloadCase()
82
+ }
83
+ catch (e: any) { toast.add({ title: e?.message ?? t("hr.toast.error"), color: "error" }) }
84
+ finally { busy.value = false }
85
+ }
86
+
87
+ async function onDeprovision() {
88
+ busy.value = true
89
+ try {
90
+ await deprovision(offcase.value!.id)
91
+ toast.add({ title: t("hr.offboarding.toast.deprovisioned"), color: "success" })
92
+ await reloadCase()
93
+ }
94
+ catch (e: any) { toast.add({ title: e?.message ?? t("hr.toast.error"), color: "error" }) }
95
+ finally { busy.value = false }
96
+ }
97
+
98
+ async function onLoadSettlement() {
99
+ busy.value = true
100
+ try { settlement.value = await getSettlement(offcase.value!.id) }
101
+ catch (e: any) { toast.add({ title: e?.message ?? t("hr.toast.error"), color: "error" }) }
102
+ finally { busy.value = false }
103
+ }
104
+
105
+ async function onSavePayout() {
106
+ busy.value = true
107
+ try {
108
+ await updateCase(offcase.value!.id, {
109
+ leave_payout_days: settlement.value?.totalRemainingDays ?? null,
110
+ leave_payout_amount: payoutAmount.value ?? null,
111
+ })
112
+ toast.add({ title: t("hr.toast.saved"), color: "success" })
113
+ await reloadCase()
114
+ }
115
+ catch (e: any) { toast.add({ title: e?.message ?? t("hr.toast.error"), color: "error" }) }
116
+ finally { busy.value = false }
117
+ }
118
+
119
+ async function onFinalize() {
120
+ busy.value = true
121
+ try {
122
+ await finalize(offcase.value!.id)
123
+ showFinalize.value = false
124
+ toast.add({ title: t("hr.offboarding.toast.finalized"), color: "success" })
125
+ await reloadCase()
126
+ const updated = await api.getPerson(String(props.person.id))
127
+ if (updated) onUpdated(updated as Person)
128
+ }
129
+ catch (e: any) { toast.add({ title: e?.message ?? t("hr.toast.error"), color: "error" }) }
130
+ finally { busy.value = false }
131
+ }
132
+
133
+ async function onCancel() {
134
+ busy.value = true
135
+ try {
136
+ await cancel(offcase.value!.id)
137
+ toast.add({ title: t("hr.offboarding.toast.cancelled"), color: "success" })
138
+ await reloadCase()
139
+ }
140
+ catch (e: any) { toast.add({ title: e?.message ?? t("hr.toast.error"), color: "error" }) }
141
+ finally { busy.value = false }
142
+ }
143
+
144
+ onMounted(load)
145
+ watch(refreshKey, load)
146
+ </script>
147
+
148
+ <template>
149
+ <div class="p-6 max-w-4xl space-y-6">
150
+ <div v-if="loading" class="px-4 py-8 text-sm text-muted">{{ t('common.loading') }}</div>
151
+
152
+ <!-- No case -->
153
+ <div v-else-if="!offcase" class="px-4 py-16 text-center text-muted">
154
+ <UIcon name="i-ph-sign-out-light" class="size-10 mb-3 opacity-40" />
155
+ <p class="text-sm">{{ t('hr.offboarding.no_case') }}</p>
156
+ </div>
157
+
158
+ <template v-else>
159
+ <!-- Summary -->
160
+ <HrInfoSection :title="t('hr.offboarding.title')" icon="i-ph-sign-out-light">
161
+ <template #actions>
162
+ <UBadge :color="(STATUS_COLOR[offcase.status] ?? 'neutral') as any" variant="subtle" size="md">
163
+ {{ t(`hr.offboarding.status.${offcase.status}`) }}
164
+ </UBadge>
165
+ </template>
166
+ <div class="p-4 grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
167
+ <div><div class="text-xs uppercase tracking-wider text-muted mb-1">{{ t('hr.offboarding.fields.exit_type') }}</div>{{ t(`hr.offboarding.exit_type.${offcase.exit_type}`) }}</div>
168
+ <div><div class="text-xs uppercase tracking-wider text-muted mb-1">{{ t('hr.offboarding.fields.notice_date') }}</div>{{ offcase.notice_date?.slice(0,10) ?? '—' }}</div>
169
+ <div><div class="text-xs uppercase tracking-wider text-muted mb-1">{{ t('hr.offboarding.fields.last_working_day') }}</div>{{ offcase.last_working_day?.slice(0,10) ?? '—' }}</div>
170
+ <div><div class="text-xs uppercase tracking-wider text-muted mb-1">{{ t('hr.offboarding.fields.rehire_eligible') }}</div>{{ offcase.rehire_eligible ? '✓' : '—' }}</div>
171
+ <div v-if="offcase.reason" class="col-span-2 md:col-span-4"><div class="text-xs uppercase tracking-wider text-muted mb-1">{{ t('hr.offboarding.fields.reason') }}</div>{{ offcase.reason }}</div>
172
+ </div>
173
+ </HrInfoSection>
174
+
175
+ <!-- Checklist -->
176
+ <HrInfoSection :title="t('hr.offboarding.sections.checklist')" icon="i-ph-list-checks-light">
177
+ <ul class="divide-y divide-default">
178
+ <li v-for="tk in sortedTasks" :key="tk.id" class="flex items-center gap-3 px-4 py-3">
179
+ <UIcon :name="TASK_ICON[tk.key] ?? 'i-ph-circle-light'" class="size-5 shrink-0" :class="tk.status === 'done' ? 'text-success' : 'text-muted'" />
180
+ <div class="flex-1 min-w-0">
181
+ <div class="text-sm font-medium text-default">{{ taskLabel(tk) }}</div>
182
+ </div>
183
+ <UButton
184
+ v-if="tk.key === 'asset_return' && !readonly"
185
+ size="xs" variant="soft" icon="i-ph-package-light"
186
+ :label="t('hr.offboarding.actions.return_all')" :loading="busy" @click="onReturnAssets"
187
+ />
188
+ <UButton
189
+ v-if="tk.key === 'access_revoke' && !readonly"
190
+ size="xs" variant="soft" icon="i-ph-lock-key-light"
191
+ :label="t('hr.offboarding.actions.deprovision')" :loading="busy" @click="onDeprovision"
192
+ />
193
+ <UBadge v-if="tk.status === 'done'" color="success" variant="subtle" size="sm">{{ t('hr.offboarding.task_status.done') }}</UBadge>
194
+ <UBadge v-else-if="tk.status === 'na'" color="neutral" variant="subtle" size="sm">{{ t('hr.offboarding.task_status.na') }}</UBadge>
195
+ <div v-if="!readonly" class="flex items-center gap-1">
196
+ <UButton v-if="tk.status !== 'done'" size="xs" color="success" variant="ghost" icon="i-ph-check-light" @click="setTaskStatus(tk, 'done')" />
197
+ <UButton v-if="tk.status === 'done'" size="xs" color="neutral" variant="ghost" icon="i-ph-arrow-counter-clockwise-light" @click="setTaskStatus(tk, 'pending')" />
198
+ <UButton v-if="tk.status !== 'na'" size="xs" color="neutral" variant="ghost" icon="i-ph-minus-circle-light" @click="setTaskStatus(tk, 'na')" />
199
+ </div>
200
+ </li>
201
+ </ul>
202
+ </HrInfoSection>
203
+
204
+ <!-- Settlement -->
205
+ <HrInfoSection :title="t('hr.offboarding.sections.settlement')" icon="i-ph-umbrella-light">
206
+ <div class="p-4 space-y-3">
207
+ <UButton v-if="!settlement" size="sm" variant="soft" icon="i-ph-calculator-light" :label="t('hr.offboarding.actions.compute_settlement')" :loading="busy" @click="onLoadSettlement" />
208
+ <template v-else>
209
+ <div class="text-sm">
210
+ <span class="text-muted">{{ t('hr.offboarding.settlement.remaining_days') }}: </span>
211
+ <span class="font-semibold text-default">{{ settlement.totalRemainingDays }}</span>
212
+ </div>
213
+ <ul class="text-xs text-muted space-y-1">
214
+ <li v-for="(l, i) in settlement.lines" :key="i">{{ l.leave_type }}: {{ l.remaining }} ({{ l.allocated }} − {{ l.used }})</li>
215
+ </ul>
216
+ <div v-if="!readonly" class="flex items-end gap-3 pt-2">
217
+ <UFormField :label="t('hr.offboarding.settlement.payout_amount')">
218
+ <UInput v-model.number="payoutAmount" type="number" class="w-48" />
219
+ </UFormField>
220
+ <UButton size="sm" :label="t('common.save')" :loading="busy" @click="onSavePayout" />
221
+ </div>
222
+ </template>
223
+ </div>
224
+ </HrInfoSection>
225
+
226
+ <!-- Actions -->
227
+ <div v-if="!readonly" class="flex items-center justify-end gap-2">
228
+ <UButton color="neutral" variant="outline" icon="i-ph-x-light" :label="t('hr.offboarding.actions.cancel')" :loading="busy" @click="onCancel" />
229
+ <UButton color="primary" icon="i-ph-flag-checkered-light" :label="t('hr.offboarding.actions.finalize')" :disabled="busy" @click="showFinalize = true" />
230
+ </div>
231
+ </template>
232
+
233
+ <!-- History -->
234
+ <HrInfoSection v-if="history.length" :title="t('hr.offboarding.sections.history')" icon="i-ph-clock-counter-clockwise-light">
235
+ <ul class="divide-y divide-default">
236
+ <li
237
+ v-for="c in history" :key="c.id"
238
+ class="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-elevated/50 transition-colors"
239
+ @click="openDetail(c.id)"
240
+ >
241
+ <UIcon name="i-ph-sign-out-light" class="size-5 shrink-0 text-muted" />
242
+ <div class="flex-1 min-w-0">
243
+ <div class="text-sm font-medium text-default">{{ t(`hr.offboarding.exit_type.${c.exit_type}`) }}</div>
244
+ <div class="text-xs text-muted">{{ t('hr.offboarding.fields.last_working_day') }}: {{ c.last_working_day?.slice(0,10) ?? '—' }}</div>
245
+ </div>
246
+ <UBadge :color="(STATUS_COLOR[c.status] ?? 'neutral') as any" variant="subtle" size="sm">
247
+ {{ t(`hr.offboarding.status.${c.status}`) }}
248
+ </UBadge>
249
+ <UIcon name="i-ph-caret-right-light" class="size-4 shrink-0 text-muted" />
250
+ </li>
251
+ </ul>
252
+ </HrInfoSection>
253
+
254
+ <!-- History detail (read-only) -->
255
+ <UModal
256
+ :open="!!detailCase"
257
+ :title="detailCase ? t(`hr.offboarding.exit_type.${detailCase.exit_type}`) : ''"
258
+ :ui="{ content: 'sm:max-w-2xl' }"
259
+ @update:open="(v: boolean) => { if (!v) detailCase = null }"
260
+ >
261
+ <template #body>
262
+ <div v-if="detailCase" class="space-y-5">
263
+ <div class="flex items-center gap-2">
264
+ <UBadge :color="(STATUS_COLOR[detailCase.status] ?? 'neutral') as any" variant="subtle" size="md">
265
+ {{ t(`hr.offboarding.status.${detailCase.status}`) }}
266
+ </UBadge>
267
+ </div>
268
+ <div class="grid grid-cols-2 gap-4 text-sm">
269
+ <div><div class="text-xs uppercase tracking-wider text-muted mb-1">{{ t('hr.offboarding.fields.notice_date') }}</div>{{ detailCase.notice_date?.slice(0,10) ?? '—' }}</div>
270
+ <div><div class="text-xs uppercase tracking-wider text-muted mb-1">{{ t('hr.offboarding.fields.last_working_day') }}</div>{{ detailCase.last_working_day?.slice(0,10) ?? '—' }}</div>
271
+ <div><div class="text-xs uppercase tracking-wider text-muted mb-1">{{ t('hr.offboarding.fields.rehire_eligible') }}</div>{{ detailCase.rehire_eligible ? '✓' : '—' }}</div>
272
+ <div v-if="detailCase.leave_payout_days != null"><div class="text-xs uppercase tracking-wider text-muted mb-1">{{ t('hr.offboarding.settlement.remaining_days') }}</div>{{ detailCase.leave_payout_days }}</div>
273
+ <div v-if="detailCase.reason" class="col-span-2"><div class="text-xs uppercase tracking-wider text-muted mb-1">{{ t('hr.offboarding.fields.reason') }}</div>{{ detailCase.reason }}</div>
274
+ </div>
275
+ <div>
276
+ <div class="text-xs uppercase tracking-wider text-muted mb-2">{{ t('hr.offboarding.sections.checklist') }}</div>
277
+ <ul class="divide-y divide-default border border-default rounded-lg">
278
+ <li v-for="tk in [...(detailCase.tasks ?? [])].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0))" :key="tk.id" class="flex items-center gap-3 px-3 py-2">
279
+ <UIcon :name="TASK_ICON[tk.key] ?? 'i-ph-circle-light'" class="size-4 shrink-0" :class="tk.status === 'done' ? 'text-success' : 'text-muted'" />
280
+ <span class="flex-1 text-sm">{{ taskLabel(tk) }}</span>
281
+ <UBadge v-if="tk.status === 'done'" color="success" variant="subtle" size="sm">{{ t('hr.offboarding.task_status.done') }}</UBadge>
282
+ <UBadge v-else-if="tk.status === 'na'" color="neutral" variant="subtle" size="sm">{{ t('hr.offboarding.task_status.na') }}</UBadge>
283
+ </li>
284
+ </ul>
285
+ </div>
286
+ </div>
287
+ </template>
288
+ </UModal>
289
+
290
+ <!-- Finalize confirm -->
291
+ <UModal :open="showFinalize" :title="t('hr.offboarding.actions.finalize')" @update:open="showFinalize = $event">
292
+ <template #body>
293
+ <p class="text-sm text-muted">{{ t('hr.offboarding.finalize_confirm', { name: person.display_name }) }}</p>
294
+ </template>
295
+ <template #footer>
296
+ <div class="flex justify-end gap-2">
297
+ <UButton color="neutral" variant="outline" :label="t('common.cancel')" @click="showFinalize = false" />
298
+ <UButton color="primary" :label="t('hr.offboarding.actions.finalize')" :loading="busy" @click="onFinalize" />
299
+ </div>
300
+ </template>
301
+ </UModal>
302
+ </div>
303
+ </template>
@@ -0,0 +1,65 @@
1
+ <script lang="ts" setup>
2
+ import HrInfoSection from "../../shared/section.vue"
3
+ import HrTabTimeline from "./timeline.vue"
4
+
5
+ const props = defineProps<{ personId: string, readonly?: boolean }>()
6
+
7
+ const api = useHrApi()
8
+
9
+ const notes = ref<HrNote[]>([])
10
+ const loading = ref(true)
11
+ const draft = ref("")
12
+ const adding = ref(false)
13
+
14
+ onMounted(async () => {
15
+ try {
16
+ notes.value = await api.listNotes(props.personId)
17
+ }
18
+ finally {
19
+ loading.value = false
20
+ }
21
+ })
22
+
23
+ async function addNote() {
24
+ const body = draft.value.trim()
25
+ if (!body) return
26
+ adding.value = true
27
+ try {
28
+ const note = await api.createNote(props.personId, body)
29
+ notes.value.unshift(note)
30
+ draft.value = ""
31
+ }
32
+ finally {
33
+ adding.value = false
34
+ }
35
+ }
36
+ </script>
37
+
38
+ <template>
39
+ <div class="p-6 max-w-4xl space-y-6">
40
+ <HrInfoSection
41
+ v-if="!readonly"
42
+ title="Add Note"
43
+ icon="i-ph-note-pencil-light"
44
+ >
45
+ <div class="p-4 space-y-2">
46
+ <UTextarea
47
+ v-model="draft"
48
+ placeholder="Write a note about this person…"
49
+ :rows="3"
50
+ />
51
+ <div class="flex justify-end">
52
+ <UButton
53
+ :disabled="!draft.trim()"
54
+ :loading="adding"
55
+ icon="i-ph-paper-plane-tilt-light"
56
+ @click="addNote"
57
+ >
58
+ Post note
59
+ </UButton>
60
+ </div>
61
+ </div>
62
+ </HrInfoSection>
63
+ <HrTabTimeline :person-id="personId" :notes="notes" :loading="loading" />
64
+ </div>
65
+ </template>