@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,649 @@
1
+ <template>
2
+ <div class="flex flex-col h-full">
3
+ <!-- Toolbar -->
4
+ <div class="flex items-center gap-2 py-3 border-b border-default flex-wrap">
5
+ <UButton
6
+ icon="i-ph-caret-left"
7
+ size="sm"
8
+ variant="ghost"
9
+ :disabled="rangePreset === 'custom'"
10
+ @click="shift(-1)"
11
+ />
12
+ <UButton
13
+ :label="$t('calendar.today')"
14
+ size="sm"
15
+ variant="outline"
16
+ :disabled="rangePreset === 'custom'"
17
+ @click="todayClick"
18
+ />
19
+ <UButton
20
+ icon="i-ph-caret-right"
21
+ size="sm"
22
+ variant="ghost"
23
+ :disabled="rangePreset === 'custom'"
24
+ @click="shift(1)"
25
+ />
26
+ <span class="ml-2 text-base font-semibold">{{ rangeLabel }}</span>
27
+ <div class="flex-1" />
28
+ <div class="flex items-center gap-1 bg-elevated rounded-lg p-0.5">
29
+ <button
30
+ v-for="opt in granularityOptions"
31
+ :key="opt.value"
32
+ class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors cursor-pointer"
33
+ :class="granularity === opt.value
34
+ ? 'bg-default text-default shadow-sm'
35
+ : 'text-muted hover:text-default'"
36
+ @click="granularity = opt.value"
37
+ >{{ opt.label }}</button>
38
+ </div>
39
+ <UPopover :ui="{ content: 'w-72' }">
40
+ <UButton
41
+ size="sm"
42
+ variant="outline"
43
+ color="neutral"
44
+ icon="i-ph-calendar-light"
45
+ trailing-icon="i-ph-caret-down-light"
46
+ >
47
+ {{ rangePresetLabel }}
48
+ </UButton>
49
+ <template #content>
50
+ <div class="p-3 space-y-3">
51
+ <div>
52
+ <div class="text-[10px] font-semibold uppercase tracking-wider text-muted mb-1.5">
53
+ {{ $t('calendar.custom_range') }}
54
+ </div>
55
+ <div class="grid grid-cols-2 gap-1">
56
+ <button
57
+ v-for="opt in rangePresetOptions"
58
+ :key="opt.value"
59
+ type="button"
60
+ class="px-2.5 py-1.5 text-xs font-medium rounded-md text-left transition-colors cursor-pointer"
61
+ :class="rangePreset === opt.value
62
+ ? 'bg-primary/10 text-primary'
63
+ : 'text-default hover:bg-elevated'"
64
+ @click="rangePreset = opt.value"
65
+ >{{ opt.label }}</button>
66
+ </div>
67
+ </div>
68
+ <div
69
+ v-if="rangePreset === 'custom'"
70
+ class="space-y-2 pt-1 border-t border-default"
71
+ >
72
+ <div>
73
+ <label class="block text-[10px] font-semibold uppercase tracking-wider text-muted mb-1">From</label>
74
+ <UInput
75
+ v-model="customFrom"
76
+ type="date"
77
+ size="sm"
78
+ class="w-full"
79
+ />
80
+ </div>
81
+ <div>
82
+ <label class="block text-[10px] font-semibold uppercase tracking-wider text-muted mb-1">To</label>
83
+ <UInput
84
+ v-model="customTo"
85
+ type="date"
86
+ size="sm"
87
+ class="w-full"
88
+ />
89
+ </div>
90
+ <div
91
+ v-if="rangeWarning"
92
+ class="flex items-start gap-1.5 text-xs text-warning"
93
+ >
94
+ <UIcon
95
+ name="i-ph-warning-light"
96
+ class="size-4 shrink-0 mt-0.5"
97
+ />
98
+ <span>{{ rangeWarning }}</span>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ </template>
103
+ </UPopover>
104
+ <UInput
105
+ v-model="searchQuery"
106
+ icon="i-ph-magnifying-glass"
107
+ :placeholder="$t('common.search')"
108
+ size="sm"
109
+ class="w-56"
110
+ />
111
+ <USelect
112
+ v-model="groupBy"
113
+ :items="groupOptions"
114
+ value-key="value"
115
+ size="sm"
116
+ class="w-44"
117
+ />
118
+ </div>
119
+
120
+ <!-- Legend -->
121
+ <div class="flex items-center gap-3 text-xs text-muted px-4 py-2 border-b border-default">
122
+ <span
123
+ v-for="(meta, key) in TYPE_META"
124
+ :key="key"
125
+ class="flex items-center gap-1.5"
126
+ >
127
+ <span
128
+ class="size-2.5 rounded-sm"
129
+ :class="meta.dot"
130
+ />
131
+ {{ meta.label }}
132
+ </span>
133
+ </div>
134
+
135
+ <!-- Calendar grid -->
136
+ <div class="flex-1 overflow-auto">
137
+ <div
138
+ class="grid"
139
+ :style="{ gridTemplateColumns: gridCols }"
140
+ >
141
+ <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">
142
+ <span class="text-xs uppercase tracking-wider text-muted font-medium">
143
+ {{ activeEmployees.length }} members
144
+ </span>
145
+ </div>
146
+ <div
147
+ v-for="b in buckets"
148
+ :key="b.startIso"
149
+ class="sticky top-0 z-10 bg-default border-b border-r border-default text-center py-1.5 last:border-r-0"
150
+ :class="[b.isWeekend && 'bg-elevated/40', b.containsToday && 'bg-primary/10', b.isHoliday && 'bg-error/10']"
151
+ >
152
+ <div
153
+ class="text-sm font-medium"
154
+ :class="b.containsToday && 'text-primary'"
155
+ >{{ b.primary }}</div>
156
+ <div class="text-[10px] uppercase text-muted">{{ b.secondary }}</div>
157
+ <div
158
+ v-if="b.holidayName"
159
+ class="text-[9px] text-error truncate px-0.5"
160
+ >{{ b.holidayName }}</div>
161
+ </div>
162
+
163
+ <template
164
+ v-for="grp in groups"
165
+ :key="grp.key"
166
+ >
167
+ <template v-if="groupBy !== 'none'">
168
+ <div class="sticky left-0 z-10 bg-elevated/40 border-b border-r border-default px-4 py-2">
169
+ <span class="text-xs font-semibold uppercase tracking-wider text-muted">
170
+ {{ grp.label }} ({{ grp.members.length }})
171
+ </span>
172
+ </div>
173
+ <div
174
+ class="bg-elevated/40 border-b border-default"
175
+ :style="{ gridColumn: `2 / span ${buckets.length}` }"
176
+ />
177
+ </template>
178
+
179
+ <template
180
+ v-for="emp in grp.members"
181
+ :key="emp.id"
182
+ >
183
+ <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">
184
+ <UAvatar
185
+ icon="i-ph-user-light"
186
+ size="sm"
187
+ class="bg-primary/10 text-primary shrink-0"
188
+ />
189
+ <div class="min-w-0">
190
+ <div class="text-sm font-medium truncate">
191
+ {{ emp.display_name }}
192
+ </div>
193
+ <div class="text-xs text-muted truncate">
194
+ {{ emp.job_title }}
195
+ </div>
196
+ </div>
197
+ </div>
198
+ <div
199
+ class="relative border-b border-default"
200
+ :style="{ gridColumn: `2 / span ${buckets.length}` }"
201
+ >
202
+ <div
203
+ class="grid h-full"
204
+ :style="{ gridTemplateColumns: `repeat(${buckets.length}, 1fr)` }"
205
+ >
206
+ <div
207
+ v-for="b in buckets"
208
+ :key="b.startIso"
209
+ class="border-r border-default last:border-r-0 min-h-[44px]"
210
+ :class="[b.isWeekend && 'bg-elevated/40', b.containsToday && 'bg-primary/5', b.isHoliday && 'bg-error/5']"
211
+ />
212
+ </div>
213
+ <button
214
+ v-for="seg in pillsByPerson[String(emp.id)] || []"
215
+ :key="seg.id"
216
+ type="button"
217
+ class="absolute inset-y-1 flex items-center gap-1 px-2 rounded-md text-xs font-medium cursor-pointer transition-all hover:ring-1 hover:ring-current focus:outline-none focus-visible:ring-2 focus-visible:ring-current overflow-hidden whitespace-nowrap"
218
+ :class="[
219
+ TYPE_META[seg.type]?.pill ?? 'bg-primary/15 text-primary',
220
+ seg.pending && 'ring-1 ring-dashed ring-current opacity-80'
221
+ ]"
222
+ :style="{
223
+ left: `calc(${seg.leftPct}% + 2px)`,
224
+ width: `calc(${seg.widthPct}% - 4px)`,
225
+ minWidth: '76px'
226
+ }"
227
+ :title="`${seg.label}${seg.pending ? ' (pending)' : ''} · ${seg.from} → ${seg.to}`"
228
+ @click="openLeave(seg.id)"
229
+ >
230
+ <UIcon
231
+ :name="TYPE_META[seg.type]?.icon ?? 'i-ph-calendar-light'"
232
+ class="size-3.5 shrink-0"
233
+ />
234
+ <span class="truncate">{{ seg.label }}</span>
235
+ <span
236
+ v-if="granularity !== 'day'"
237
+ class="ml-auto opacity-70 shrink-0"
238
+ >{{ seg.durationLabel }}</span>
239
+ </button>
240
+ </div>
241
+ </template>
242
+ </template>
243
+
244
+ <div
245
+ v-if="activeEmployees.length === 0 || buckets.length === 0"
246
+ class="col-span-full py-16 text-center text-sm text-muted"
247
+ >
248
+ {{ buckets.length === 0 ? $t('calendar.custom_range') : $t('common.no_data') }}
249
+ </div>
250
+ </div>
251
+ </div>
252
+
253
+ <UModal v-model:open="modalOpen">
254
+ <template #content>
255
+ <div
256
+ v-if="selectedLeave"
257
+ class="p-6 space-y-5"
258
+ >
259
+ <div class="flex items-start justify-between gap-3">
260
+ <div class="flex items-center gap-3">
261
+ <div
262
+ class="size-10 rounded-lg flex items-center justify-center"
263
+ :class="TYPE_META[selectedType]?.pill ?? 'bg-primary/15 text-primary'"
264
+ >
265
+ <UIcon
266
+ :name="TYPE_META[selectedType]?.icon ?? 'i-ph-calendar-light'"
267
+ class="size-5"
268
+ />
269
+ </div>
270
+ <div>
271
+ <div class="text-base font-semibold">
272
+ {{ TYPE_META[selectedType]?.label ?? selectedType }} leave
273
+ </div>
274
+ <div class="text-sm text-muted">
275
+ {{ selectedLeave.person_name ?? (selectedLeave.person_id as any)?.display_name ?? '—' }}
276
+ </div>
277
+ </div>
278
+ </div>
279
+ <UBadge
280
+ :label="$t(`hr.leave.status.${selectedLeave.status.toLowerCase()}`)"
281
+ :color="selectedLeave.status?.toLowerCase() === 'approved' ? 'success' : selectedLeave.status?.toLowerCase() === 'pending' ? 'warning' : 'error'"
282
+ variant="subtle"
283
+ size="md"
284
+ />
285
+ </div>
286
+ <div class="grid grid-cols-2 gap-4 text-sm">
287
+ <div>
288
+ <div class="text-xs uppercase tracking-wider text-muted mb-1">From</div>
289
+ <div class="font-medium">{{ selectedLeave.start_date || selectedLeave.from_date }}</div>
290
+ </div>
291
+ <div>
292
+ <div class="text-xs uppercase tracking-wider text-muted mb-1">To</div>
293
+ <div class="font-medium">{{ selectedLeave.end_date || selectedLeave.to_date }}</div>
294
+ </div>
295
+ <div>
296
+ <div class="text-xs uppercase tracking-wider text-muted mb-1">Days</div>
297
+ <div class="font-medium">{{ selectedLeave.days_count ?? selectedLeave.days }}</div>
298
+ </div>
299
+ <div>
300
+ <div class="text-xs uppercase tracking-wider text-muted mb-1">Type</div>
301
+ <div class="font-medium capitalize">{{ leaveTypeName(selectedLeave) }}</div>
302
+ </div>
303
+ </div>
304
+ <div v-if="selectedLeave.reason">
305
+ <div class="text-xs uppercase tracking-wider text-muted mb-1">Reason</div>
306
+ <div class="text-sm">{{ selectedLeave.reason }}</div>
307
+ </div>
308
+ <div
309
+ v-if="canApprove && selectedLeave.status?.toLowerCase() === 'pending'"
310
+ class="flex items-center gap-2 pt-2 border-t border-default"
311
+ >
312
+ <UButton
313
+ :label="$t('hr.leave.actions.approve')"
314
+ icon="i-ph-check-light"
315
+ color="success"
316
+ size="sm"
317
+ @click="updateSelected('Approved')"
318
+ />
319
+ <UButton
320
+ :label="$t('hr.leave.actions.reject')"
321
+ icon="i-ph-x-light"
322
+ color="error"
323
+ variant="outline"
324
+ size="sm"
325
+ @click="updateSelected('Rejected')"
326
+ />
327
+ </div>
328
+ </div>
329
+ </template>
330
+ </UModal>
331
+ </div>
332
+ </template>
333
+
334
+ <script lang="ts" setup>
335
+ type Granularity = 'day' | 'week' | 'month'
336
+ type RangePreset = '1w' | '1m' | '3m' | '6m' | '1y' | 'custom'
337
+
338
+ const { t } = useI18n()
339
+ const { leaveRequests, setStatus } = useLeave()
340
+ const { can } = usePermissionRegistry()
341
+ const canApprove = computed(() => can('hr', 'leave.approve') || can('hr', 'leave.approve.dept'))
342
+ const { holidays } = useHolidays()
343
+ const { employees } = usePeople()
344
+
345
+ const granularity = useState<Granularity>('cal.granularity', () => 'day')
346
+ const rangePreset = useState<RangePreset>('cal.rangePreset', () => '1m')
347
+ const cursor = useState('cal.cursor', () => new Date())
348
+ const groupBy = useState<'none' | 'department'>('cal.groupBy', () => 'department')
349
+ const searchQuery = ref('')
350
+ const customFrom = ref('')
351
+ const customTo = ref('')
352
+ const selectedLeaveId = ref<string | null>(null)
353
+ const modalOpen = ref(false)
354
+
355
+ const PRESET_GRANULARITY: Record<RangePreset, Granularity> = {
356
+ '1w': 'day', '1m': 'day', '3m': 'week', '6m': 'week', '1y': 'month', custom: 'day',
357
+ }
358
+
359
+ function presetRange(preset: Exclude<RangePreset, 'custom'>, anchor: Date): { start: Date, end: Date } {
360
+ if (preset === '1w') {
361
+ const dow = (anchor.getDay() + 6) % 7
362
+ const s = new Date(anchor.getFullYear(), anchor.getMonth(), anchor.getDate() - dow)
363
+ const e = new Date(s); e.setDate(s.getDate() + 6)
364
+ return { start: s, end: e }
365
+ }
366
+ const s = new Date(anchor.getFullYear(), anchor.getMonth(), 1)
367
+ const monthsMap = { '1m': 1, '3m': 3, '6m': 6, '1y': 12 } as const
368
+ const months = monthsMap[preset as keyof typeof monthsMap]
369
+ const e = new Date(s.getFullYear(), s.getMonth() + months, 0)
370
+ return { start: s, end: e }
371
+ }
372
+
373
+ function toIso(d: Date): string {
374
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
375
+ }
376
+ function isSameDay(a: Date, b: Date): boolean {
377
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
378
+ }
379
+ function dayDiff(a: Date, b: Date): number {
380
+ return Math.round((b.getTime() - a.getTime()) / 86400000)
381
+ }
382
+
383
+ watch(rangePreset, (p, prev) => {
384
+ granularity.value = PRESET_GRANULARITY[p]
385
+ if (p === 'custom' && prev && prev !== 'custom') {
386
+ const r = presetRange(prev, cursor.value)
387
+ customFrom.value = toIso(r.start)
388
+ customTo.value = toIso(r.end)
389
+ }
390
+ })
391
+
392
+ const granularityOptions = computed(() => [
393
+ { value: 'day' as Granularity, label: t('calendar.modes.day') },
394
+ { value: 'week' as Granularity, label: t('calendar.modes.week') },
395
+ { value: 'month' as Granularity, label: t('calendar.modes.month') },
396
+ ])
397
+
398
+ const rangePresetOptions: { value: RangePreset, label: string }[] = [
399
+ { value: '1w', label: 'This week' },
400
+ { value: '1m', label: 'This month' },
401
+ { value: '3m', label: '3 months' },
402
+ { value: '6m', label: '6 months' },
403
+ { value: '1y', label: 'This year' },
404
+ { value: 'custom', label: 'Custom range' },
405
+ ]
406
+
407
+ const groupOptions = computed(() => [
408
+ { value: 'none', label: 'No grouping' },
409
+ { value: 'department', label: 'Group by Department' },
410
+ ])
411
+
412
+ const TYPE_META: Record<string, { label: string, icon: string, dot: string, pill: string }> = {
413
+ annual: { label: 'Annual', icon: 'i-ph-island-light', dot: 'bg-warning', pill: 'bg-warning/15 text-warning' },
414
+ sick: { label: 'Sick', icon: 'i-ph-thermometer-light', dot: 'bg-error', pill: 'bg-error/15 text-error' },
415
+ remote: { label: 'Remote', icon: 'i-ph-laptop-light', dot: 'bg-info', pill: 'bg-info/15 text-info' },
416
+ personal: { label: 'Personal', icon: 'i-ph-user-light', dot: 'bg-primary', pill: 'bg-primary/15 text-primary' },
417
+ }
418
+ const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
419
+ const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
420
+
421
+ // Build a set of holiday ISO dates for quick lookup
422
+ const holidaySet = computed(() => {
423
+ const set = new Map<string, string>() // iso -> name
424
+ for (const h of holidays.value) {
425
+ if (h.date) set.set(toIso(new Date(h.date)), h.name)
426
+ }
427
+ return set
428
+ })
429
+
430
+ const activeEmployees = computed(() => {
431
+ const list = employees.value.filter((e: Person) => e.stage === 'active' || e.stage === 'probation')
432
+ const q = searchQuery.value.trim().toLowerCase()
433
+ return q ? list.filter((e: Person) => (e.display_name ?? '').toLowerCase().includes(q)) : list
434
+ })
435
+
436
+ const groups = computed(() => {
437
+ if (groupBy.value === 'none') {
438
+ return [{ key: 'all', label: 'All', members: activeEmployees.value }]
439
+ }
440
+ const byDept = new Map<string, Person[]>()
441
+ for (const emp of activeEmployees.value) {
442
+ const dept = (emp as any).department_id?.name ?? (emp as any).department ?? 'Unassigned'
443
+ if (!byDept.has(dept)) byDept.set(dept, [])
444
+ byDept.get(dept)!.push(emp)
445
+ }
446
+ return [...byDept.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([dept, members]) => ({ key: dept, label: dept, members }))
447
+ })
448
+
449
+ const range = computed<{ start: Date, end: Date } | null>(() => {
450
+ if (rangePreset.value === 'custom') {
451
+ if (!customFrom.value || !customTo.value) return null
452
+ const s = new Date(customFrom.value), e = new Date(customTo.value)
453
+ return e < s ? null : { start: s, end: e }
454
+ }
455
+ return presetRange(rangePreset.value, cursor.value)
456
+ })
457
+
458
+ const rangeWarning = computed<string | null>(() => {
459
+ if (rangePreset.value !== 'custom' || !range.value) return null
460
+ const days = dayDiff(range.value.start, range.value.end) + 1
461
+ if (granularity.value === 'week' && days < 7) {
462
+ return t('calendar.range_warning.week', days)
463
+ }
464
+ if (granularity.value === 'month' && days < 28) {
465
+ return t('calendar.range_warning.month', days)
466
+ }
467
+ return null
468
+ })
469
+
470
+ interface Bucket {
471
+ startIso: string
472
+ endIso: string
473
+ days: number
474
+ primary: string
475
+ secondary: string
476
+ isWeekend: boolean
477
+ containsToday: boolean
478
+ isHoliday: boolean
479
+ holidayName?: string
480
+ }
481
+
482
+ const buckets = computed<Bucket[]>(() => {
483
+ const r = range.value
484
+ if (!r) return []
485
+ const today = new Date()
486
+ const list: Bucket[] = []
487
+ if (granularity.value === 'day') {
488
+ const d = new Date(r.start)
489
+ while (d <= r.end) {
490
+ const dow = d.getDay()
491
+ const iso = toIso(d)
492
+ const hName = holidaySet.value.get(iso)
493
+ list.push({
494
+ startIso: iso, endIso: iso, days: 1,
495
+ primary: String(d.getDate()), secondary: WEEKDAYS[dow]!,
496
+ isWeekend: dow === 0 || dow === 6,
497
+ containsToday: isSameDay(d, today),
498
+ isHoliday: !!hName,
499
+ holidayName: hName,
500
+ })
501
+ d.setDate(d.getDate() + 1)
502
+ }
503
+ }
504
+ else if (granularity.value === 'week') {
505
+ const d = new Date(r.start); const dow = (d.getDay() + 6) % 7; d.setDate(d.getDate() - dow)
506
+ while (d <= r.end) {
507
+ const wkEnd = new Date(d); wkEnd.setDate(d.getDate() + 6)
508
+ const s = d < r.start ? new Date(r.start) : new Date(d)
509
+ const e = wkEnd > r.end ? new Date(r.end) : new Date(wkEnd)
510
+ list.push({
511
+ startIso: toIso(s), endIso: toIso(e), days: dayDiff(s, e) + 1,
512
+ primary: `${MONTHS[s.getMonth()]} ${s.getDate()}`, secondary: `– ${e.getDate()}`,
513
+ isWeekend: false, containsToday: today >= s && today <= e, isHoliday: false,
514
+ })
515
+ d.setDate(d.getDate() + 7)
516
+ }
517
+ }
518
+ else {
519
+ const d = new Date(r.start.getFullYear(), r.start.getMonth(), 1)
520
+ while (d <= r.end) {
521
+ const mEnd = new Date(d.getFullYear(), d.getMonth() + 1, 0)
522
+ const s = d < r.start ? new Date(r.start) : new Date(d)
523
+ const e = mEnd > r.end ? new Date(r.end) : new Date(mEnd)
524
+ list.push({
525
+ startIso: toIso(s), endIso: toIso(e), days: dayDiff(s, e) + 1,
526
+ primary: MONTHS[d.getMonth()]!, secondary: String(d.getFullYear()),
527
+ isWeekend: false, containsToday: today >= s && today <= e, isHoliday: false,
528
+ })
529
+ d.setMonth(d.getMonth() + 1)
530
+ }
531
+ }
532
+ return list
533
+ })
534
+
535
+ const rangeLabel = computed(() => {
536
+ const r = range.value
537
+ if (!r) return 'Pick a date range'
538
+ const opts: Intl.DateTimeFormatOptions = { month: 'short', day: 'numeric', year: 'numeric' }
539
+ return `${r.start.toLocaleDateString('en-US', opts)} – ${r.end.toLocaleDateString('en-US', opts)}`
540
+ })
541
+
542
+ const rangePresetLabel = computed(() =>
543
+ rangePresetOptions.find(o => o.value === rangePreset.value)?.label ?? 'Range'
544
+ )
545
+
546
+ const gridCols = computed(() => {
547
+ const n = buckets.value.length
548
+ if (n === 0) return '220px'
549
+ let min: number
550
+ if (granularity.value === 'day') min = n > 60 ? 24 : 36
551
+ else if (granularity.value === 'week') min = 60
552
+ else min = 80
553
+ return `220px repeat(${n}, minmax(${min}px, 1fr))`
554
+ })
555
+
556
+ interface Pill {
557
+ id: string
558
+ type: string
559
+ label: string
560
+ durationLabel: string
561
+ from: string
562
+ to: string
563
+ pending: boolean
564
+ leftPct: number
565
+ widthPct: number
566
+ }
567
+
568
+ function bucketIndexForDate(iso: string, bs: Bucket[]): number {
569
+ for (let i = 0; i < bs.length; i++) {
570
+ const b = bs[i]!
571
+ if (iso >= b.startIso && iso <= b.endIso) return i
572
+ }
573
+ return -1
574
+ }
575
+
576
+ function leaveTypeName(req: LeaveRequest): string {
577
+ if (req.leave_type) return req.leave_type
578
+ const lt = req.leave_type_id as any
579
+ if (lt && typeof lt === 'object') return lt.name ?? lt.label ?? 'personal'
580
+ return 'personal'
581
+ }
582
+
583
+ const pillsByPerson = computed<Record<string, Pill[]>>(() => {
584
+ const out: Record<string, Pill[]> = {}
585
+ const bs = buckets.value
586
+ if (bs.length === 0) return out
587
+ const rangeStartIso = bs[0]!.startIso
588
+ const rangeEndIso = bs[bs.length - 1]!.endIso
589
+ for (const lr of leaveRequests.value) {
590
+ if (lr.status?.toLowerCase() === 'rejected') continue
591
+ const rawStart = lr.start_date || lr.from_date || ''
592
+ const rawEnd = lr.end_date || lr.to_date || ''
593
+ const startDate = rawStart ? toIso(new Date(rawStart)) : ''
594
+ const endDate = rawEnd ? toIso(new Date(rawEnd)) : ''
595
+ if (endDate < rangeStartIso || startDate > rangeEndIso) continue
596
+ const fromIso = startDate < rangeStartIso ? rangeStartIso : startDate
597
+ const toIso2 = endDate > rangeEndIso ? rangeEndIso : endDate
598
+ const startBi = bucketIndexForDate(fromIso, bs)
599
+ const endBi = bucketIndexForDate(toIso2, bs)
600
+ if (startBi < 0 || endBi < 0) continue
601
+ const startBucket = bs[startBi]!
602
+ const endBucket = bs[endBi]!
603
+ const startCol = startBi + dayDiff(new Date(startBucket.startIso), new Date(fromIso)) / startBucket.days
604
+ const endCol = endBi + (dayDiff(new Date(endBucket.startIso), new Date(toIso2)) + 1) / endBucket.days
605
+ const typeName = leaveTypeName(lr).toLowerCase()
606
+ const type = typeName in TYPE_META ? typeName : 'personal'
607
+ const days = lr.days_count ?? lr.days ?? 0
608
+ const pill: Pill = {
609
+ id: String(lr.id), type,
610
+ label: TYPE_META[type]?.label ?? type,
611
+ durationLabel: days === 1 ? '1d' : `${days}d`,
612
+ from: startDate, to: endDate,
613
+ pending: lr.status?.toLowerCase() === 'pending',
614
+ leftPct: startCol / bs.length * 100,
615
+ widthPct: (endCol - startCol) / bs.length * 100,
616
+ }
617
+ const pid = String((lr.person_id as any)?.id ?? lr.person_id)
618
+ if (!out[pid]) out[pid] = []
619
+ out[pid]!.push(pill)
620
+ }
621
+ return out
622
+ })
623
+
624
+ const selectedLeave = computed(() =>
625
+ selectedLeaveId.value ? leaveRequests.value.find(l => String(l.id) === selectedLeaveId.value) ?? null : null
626
+ )
627
+ const selectedType = computed<string>(() => {
628
+ const lt = selectedLeave.value ? leaveTypeName(selectedLeave.value).toLowerCase() : 'personal'
629
+ return lt in TYPE_META ? lt : 'personal'
630
+ })
631
+
632
+ function openLeave(id: string) { selectedLeaveId.value = id; modalOpen.value = true }
633
+ function updateSelected(status: LeaveRequest['status']) {
634
+ if (!selectedLeaveId.value) return
635
+ setStatus(selectedLeaveId.value, status)
636
+ modalOpen.value = false
637
+ }
638
+ function shift(delta: number) {
639
+ if (rangePreset.value === 'custom') return
640
+ const c = new Date(cursor.value)
641
+ if (rangePreset.value === '1w') c.setDate(c.getDate() + delta * 7)
642
+ else if (rangePreset.value === '1m') c.setMonth(c.getMonth() + delta)
643
+ else if (rangePreset.value === '3m') c.setMonth(c.getMonth() + delta * 3)
644
+ else if (rangePreset.value === '6m') c.setMonth(c.getMonth() + delta * 6)
645
+ else if (rangePreset.value === '1y') c.setFullYear(c.getFullYear() + delta)
646
+ cursor.value = c
647
+ }
648
+ function todayClick() { cursor.value = new Date() }
649
+ </script>