@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,432 @@
1
+ <script lang="ts" setup>
2
+ const { $api } = useNuxtApp()
3
+ const props = defineProps<{ person: Person, readonly?: boolean }>()
4
+
5
+ const { t } = useI18n()
6
+ const toast = useToast()
7
+
8
+ type Asset = {
9
+ id: number
10
+ type: string
11
+ name: string
12
+ serial_number?: string | null
13
+ value?: number | null
14
+ images?: string[] | null
15
+ issued_at: string
16
+ returned_at?: string | null
17
+ status: 'issued' | 'returned'
18
+ notes?: string | null
19
+ }
20
+
21
+ const assets = useState<Asset[]>(`hr:assets:${props.person.id}`, () => [])
22
+ const loading = ref(false)
23
+ const showIssueModal = ref(false)
24
+
25
+ const activeAssets = computed(() => assets.value.filter(a => !a.returned_at))
26
+ const returnedAssets = computed(() => assets.value.filter(a => !!a.returned_at))
27
+
28
+ const issueForm = ref<Record<string, any>>({
29
+ type: 'Laptop',
30
+ name: '',
31
+ serial_number: '',
32
+ value: null,
33
+ images: null,
34
+ issued_at: new Date().toISOString().slice(0, 10),
35
+ notes: '',
36
+ })
37
+
38
+ const ISSUE_FIELD_NAMES = ['type', 'name', 'serial_number', 'value', 'images', 'issued_at', 'notes']
39
+ const { pickFields, fetchAll: fetchFields, isReady } = useHrFieldRegistry(['hr_assets'])
40
+
41
+ const issueFields = computed(() => pickFields('hr_assets', ISSUE_FIELD_NAMES))
42
+ const issueSaving = ref(false)
43
+
44
+ async function fetchAssets() {
45
+ loading.value = true
46
+ try {
47
+ const res = await $api<any>(`/hr/people/${props.person.id}/assets`)
48
+ assets.value = Array.isArray(res) ? res : (res?.data ?? [])
49
+ }
50
+ catch { /* noop */ }
51
+ finally {
52
+ loading.value = false }
53
+ }
54
+
55
+ async function issueAsset() {
56
+ const f = issueForm.value
57
+ if (!f.name?.trim()) return
58
+ issueSaving.value = true
59
+ try {
60
+ const body: any = { type: f.type, name: f.name, issued_at: f.issued_at }
61
+ if (f.serial_number?.trim()) body.serial_number = f.serial_number
62
+ if (f.value && Number(f.value) > 0) body.value = Number(f.value)
63
+ if (f.images?.length) body.images = f.images
64
+ if (f.notes?.trim()) body.notes = f.notes
65
+
66
+ await $api(`/hr/people/${props.person.id}/assets`, { method: 'POST', body })
67
+ await fetchAssets()
68
+ showIssueModal.value = false
69
+ issueForm.value = {
70
+ type: 'Laptop', name: '', serial_number: '', value: null,
71
+ images: null, issued_at: new Date().toISOString().slice(0, 10), notes: '',
72
+ }
73
+ toast.add({ title: t('assets.toast.issued'), color: 'success' })
74
+ }
75
+ catch (err: any) {
76
+ toast.add({ title: err?.message ?? t('hr.toast.error'), color: 'error' })
77
+ }
78
+ finally {
79
+ issueSaving.value = false
80
+ }
81
+ }
82
+
83
+ async function markReturned(asset: Asset) {
84
+ const today = new Date().toISOString().slice(0, 10)
85
+ try {
86
+ await $api(`/hr/people/${props.person.id}/assets/${asset.id}`, {
87
+ method: 'PATCH',
88
+ body: { returned_at: today, status: 'returned' },
89
+ })
90
+ await fetchAssets()
91
+ toast.add({ title: t('assets.toast.returned'), color: 'success' })
92
+ }
93
+ catch (err: any) {
94
+ toast.add({ title: err?.message ?? t('hr.toast.error'), color: 'error' })
95
+ }
96
+ }
97
+
98
+ async function cancelAsset(asset: Asset) {
99
+ try {
100
+ await $api(`/hr/people/${props.person.id}/assets/${asset.id}`, {
101
+ method: 'PATCH',
102
+ body: { status: 'cancelled' },
103
+ })
104
+ await fetchAssets()
105
+ toast.add({ title: t('assets.toast.cancelled'), color: 'success' })
106
+ }
107
+ catch (err: any) {
108
+ toast.add({ title: err?.message ?? t('hr.toast.error'), color: 'error' })
109
+ }
110
+ }
111
+
112
+ const selectedAsset = ref<Asset | null>(null)
113
+
114
+ function openDetail(asset: Asset) {
115
+ selectedAsset.value = asset
116
+ }
117
+
118
+ onMounted(() => Promise.all([fetchAssets(), fetchFields()]))
119
+ </script>
120
+
121
+ <template>
122
+ <div class="p-6 max-w-4xl space-y-6">
123
+ <HrInfoSection
124
+ :title="$t('assets.title')"
125
+ icon="i-ph-laptop-light"
126
+ >
127
+ <div
128
+ v-if="loading"
129
+ class="p-6 text-muted text-sm"
130
+ >
131
+ {{ $t('common.loading') }}
132
+ </div>
133
+
134
+ <div
135
+ v-else-if="!activeAssets.length"
136
+ class="p-6 text-muted text-sm flex items-center justify-between"
137
+ >
138
+ <span>{{ $t('assets.no_active') }}</span>
139
+ <UButton
140
+ v-if="!readonly"
141
+ size="xs"
142
+ icon="i-ph-plus-light"
143
+ variant="soft"
144
+ @click="showIssueModal = true"
145
+ >
146
+ {{ $t('assets.issue') }}
147
+ </UButton>
148
+ </div>
149
+
150
+ <table
151
+ v-else
152
+ class="w-full text-sm"
153
+ >
154
+ <thead>
155
+ <tr class="text-left text-xs text-muted uppercase tracking-wider">
156
+ <th class="px-4 py-2.5 font-medium w-32">
157
+ {{ $t('assets.columns.type') }}
158
+ </th>
159
+ <th class="px-4 py-2.5 font-medium">
160
+ {{ $t('assets.columns.name') }}
161
+ </th>
162
+ <th class="px-4 py-2.5 font-medium">
163
+ {{ $t('assets.columns.serial') }}
164
+ </th>
165
+ <th class="px-4 py-2.5 font-medium">
166
+ {{ $t('assets.columns.value') }}
167
+ </th>
168
+ <th class="px-4 py-2.5 font-medium">
169
+ {{ $t('assets.columns.issued_at') }}
170
+ </th>
171
+ <th class="px-4 py-2.5 font-medium w-10" />
172
+ </tr>
173
+ </thead>
174
+ <tbody class="divide-y divide-default">
175
+ <tr
176
+ v-for="a in activeAssets"
177
+ :key="a.id"
178
+ class="cursor-pointer hover:bg-elevated/50 transition-colors"
179
+ @click="openDetail(a)"
180
+ >
181
+ <td class="px-4 py-2.5">
182
+ <span class="text-xs font-medium text-muted bg-muted/10 px-2 py-0.5 rounded">{{ a.type }}</span>
183
+ </td>
184
+ <td class="px-4 py-2.5 font-medium">
185
+ <div class="flex items-center gap-2">
186
+ <img
187
+ v-if="a.images?.length"
188
+ :src="getThumbnailUrl(a.images[0], { width: 32, height: 32, fit: 'cover' })"
189
+ class="size-8 rounded object-cover border border-default shrink-0"
190
+ >
191
+ {{ a.name }}
192
+ </div>
193
+ </td>
194
+ <td class="px-4 py-2.5 text-muted">
195
+ {{ a.serial_number || '—' }}
196
+ </td>
197
+ <td class="px-4 py-2.5 text-muted">
198
+ {{ a.value ? new Intl.NumberFormat().format(a.value) : '—' }}
199
+ </td>
200
+ <td class="px-4 py-2.5 text-muted">
201
+ {{ a.issued_at }}
202
+ </td>
203
+ <td class="px-4 py-2.5 text-right">
204
+ <div
205
+ v-if="!readonly"
206
+ class="flex items-center justify-end gap-1"
207
+ >
208
+ <UButton
209
+ size="xs"
210
+ color="neutral"
211
+ variant="ghost"
212
+ icon="i-ph-arrow-u-up-left-light"
213
+ :title="$t('assets.return')"
214
+ @click.stop="markReturned(a)"
215
+ />
216
+ <UButton
217
+ v-if="!a.returned_at && a.status !== 'cancelled'"
218
+ size="xs"
219
+ color="neutral"
220
+ variant="ghost"
221
+ icon="i-ph-x-circle-light"
222
+ :title="$t('assets.cancel')"
223
+ @click.stop="cancelAsset(a)"
224
+ />
225
+ </div>
226
+ </td>
227
+ </tr>
228
+ </tbody>
229
+ </table>
230
+
231
+ <div
232
+ v-if="!readonly && activeAssets.length"
233
+ class="px-4 py-3 border-t border-default"
234
+ >
235
+ <UButton
236
+ size="xs"
237
+ icon="i-ph-plus-light"
238
+ variant="soft"
239
+ @click="showIssueModal = true"
240
+ >
241
+ {{ $t('assets.issue') }}
242
+ </UButton>
243
+ </div>
244
+ </HrInfoSection>
245
+
246
+ <!-- Returned assets accordion -->
247
+ <UAccordion
248
+ v-if="returnedAssets.length"
249
+ :items="[{ label: $t('assets.returned_section', { count: returnedAssets.length }), slot: 'returned' }]"
250
+ >
251
+ <template #returned>
252
+ <table class="w-full text-sm">
253
+ <thead>
254
+ <tr class="text-left text-xs text-muted uppercase tracking-wider">
255
+ <th class="px-4 py-2.5 font-medium w-32">
256
+ {{ $t('assets.columns.type') }}
257
+ </th>
258
+ <th class="px-4 py-2.5 font-medium">
259
+ {{ $t('assets.columns.name') }}
260
+ </th>
261
+ <th class="px-4 py-2.5 font-medium">
262
+ {{ $t('assets.columns.serial') }}
263
+ </th>
264
+ <th class="px-4 py-2.5 font-medium">
265
+ {{ $t('assets.columns.issued_at') }}
266
+ </th>
267
+ <th class="px-4 py-2.5 font-medium">
268
+ {{ $t('assets.columns.returned_at') }}
269
+ </th>
270
+ </tr>
271
+ </thead>
272
+ <tbody class="divide-y divide-default">
273
+ <tr
274
+ v-for="a in returnedAssets"
275
+ :key="a.id"
276
+ >
277
+ <td class="px-4 py-2.5">
278
+ <span class="text-xs font-medium text-muted bg-muted/10 px-2 py-0.5 rounded">{{ a.type }}</span>
279
+ </td>
280
+ <td class="px-4 py-2.5 font-medium">
281
+ {{ a.name }}
282
+ </td>
283
+ <td class="px-4 py-2.5 text-muted">
284
+ {{ a.serial_number || '—' }}
285
+ </td>
286
+ <td class="px-4 py-2.5 text-muted">
287
+ {{ a.issued_at }}
288
+ </td>
289
+ <td class="px-4 py-2.5 text-muted">
290
+ {{ a.returned_at }}
291
+ </td>
292
+ </tr>
293
+ </tbody>
294
+ </table>
295
+ </template>
296
+ </UAccordion>
297
+
298
+ <!-- Issue Asset Modal -->
299
+ <UModal
300
+ v-model:open="showIssueModal"
301
+ :title="$t('assets.issue_modal.title')"
302
+ :ui="{ content: 'sm:max-w-2xl' }"
303
+ >
304
+ <template #body>
305
+ <div v-if="!isReady('hr_assets')" class="px-4 py-6 text-sm text-muted">{{ $t('common.loading') }}</div>
306
+ <FormRoot
307
+ v-else-if="issueFields.length"
308
+ v-model="issueForm"
309
+ :fields="issueFields"
310
+ :initial-values="{}"
311
+ />
312
+ </template>
313
+ <template #footer>
314
+ <div class="flex justify-end gap-2">
315
+ <UButton
316
+ color="neutral"
317
+ variant="outline"
318
+ :label="$t('common.cancel')"
319
+ @click="showIssueModal = false"
320
+ />
321
+ <UButton
322
+ color="primary"
323
+ :label="$t('assets.issue_modal.confirm')"
324
+ :loading="issueSaving"
325
+ :disabled="!issueForm.name"
326
+ @click="issueAsset"
327
+ />
328
+ </div>
329
+ </template>
330
+ </UModal>
331
+
332
+ <!-- Asset Detail Drawer -->
333
+ <USlideover
334
+ :open="!!selectedAsset"
335
+ :title="selectedAsset?.name ?? ''"
336
+ @update:open="(v) => { if (!v) selectedAsset = null }"
337
+ >
338
+ <template #body>
339
+ <div
340
+ v-if="selectedAsset"
341
+ class="space-y-6"
342
+ >
343
+ <!-- Image gallery -->
344
+ <div v-if="selectedAsset.images?.length">
345
+ <div class="grid gap-2" :class="selectedAsset.images.length === 1 ? 'grid-cols-1' : 'grid-cols-2'">
346
+ <img
347
+ v-for="imgId in selectedAsset.images"
348
+ :key="imgId"
349
+ :src="getThumbnailUrl(imgId, { width: 400, height: 300, fit: 'cover' })"
350
+ class="w-full rounded-lg object-cover border border-default cursor-pointer"
351
+ @click="window.open(getAssetUrl(imgId), '_blank')"
352
+ >
353
+ </div>
354
+ </div>
355
+
356
+ <!-- Info rows -->
357
+ <div class="space-y-3">
358
+ <div class="flex items-center justify-between py-2 border-b border-default">
359
+ <span class="text-sm text-muted">{{ $t('assets.columns.type') }}</span>
360
+ <UBadge
361
+ :label="selectedAsset.type"
362
+ color="neutral"
363
+ variant="subtle"
364
+ size="sm"
365
+ />
366
+ </div>
367
+ <div class="flex items-center justify-between py-2 border-b border-default">
368
+ <span class="text-sm text-muted">{{ $t('assets.columns.name') }}</span>
369
+ <span class="text-sm font-medium">{{ selectedAsset.name }}</span>
370
+ </div>
371
+ <div
372
+ v-if="selectedAsset.serial_number"
373
+ class="flex items-center justify-between py-2 border-b border-default"
374
+ >
375
+ <span class="text-sm text-muted">{{ $t('assets.columns.serial') }}</span>
376
+ <span class="text-sm font-mono">{{ selectedAsset.serial_number }}</span>
377
+ </div>
378
+ <div
379
+ v-if="selectedAsset.value"
380
+ class="flex items-center justify-between py-2 border-b border-default"
381
+ >
382
+ <span class="text-sm text-muted">{{ $t('assets.columns.value') }}</span>
383
+ <span class="text-sm font-medium">{{ new Intl.NumberFormat().format(selectedAsset.value) }}</span>
384
+ </div>
385
+ <div class="flex items-center justify-between py-2 border-b border-default">
386
+ <span class="text-sm text-muted">{{ $t('assets.columns.issued_at') }}</span>
387
+ <span class="text-sm">{{ selectedAsset.issued_at }}</span>
388
+ </div>
389
+ <div
390
+ v-if="selectedAsset.returned_at"
391
+ class="flex items-center justify-between py-2 border-b border-default"
392
+ >
393
+ <span class="text-sm text-muted">{{ $t('assets.columns.returned_at') }}</span>
394
+ <span class="text-sm">{{ selectedAsset.returned_at }}</span>
395
+ </div>
396
+ <div
397
+ v-if="selectedAsset.notes"
398
+ class="py-2"
399
+ >
400
+ <span class="text-sm text-muted block mb-1">{{ $t('assets.fields.notes') }}</span>
401
+ <p class="text-sm whitespace-pre-wrap">{{ selectedAsset.notes }}</p>
402
+ </div>
403
+ </div>
404
+
405
+ <!-- Actions -->
406
+ <div
407
+ v-if="!readonly && !selectedAsset.returned_at"
408
+ class="flex gap-2 pt-2"
409
+ >
410
+ <UButton
411
+ icon="i-ph-arrow-u-up-left-light"
412
+ color="warning"
413
+ variant="soft"
414
+ @click="markReturned(selectedAsset!); selectedAsset = null"
415
+ >
416
+ {{ $t('assets.return') }}
417
+ </UButton>
418
+ <UButton
419
+ v-if="selectedAsset.status !== 'cancelled'"
420
+ icon="i-ph-x-circle-light"
421
+ color="neutral"
422
+ variant="soft"
423
+ @click="cancelAsset(selectedAsset!); selectedAsset = null"
424
+ >
425
+ {{ $t('assets.cancel') }}
426
+ </UButton>
427
+ </div>
428
+ </div>
429
+ </template>
430
+ </USlideover>
431
+ </div>
432
+ </template>
@@ -0,0 +1,136 @@
1
+ <script lang="ts" setup>
2
+ import HrInfoSection from "../shared/section.vue"
3
+
4
+ const props = defineProps<{ person: Person, readonly?: boolean }>()
5
+
6
+ const api = useHrApi()
7
+ const toast = useToast()
8
+ const { t } = useI18n()
9
+
10
+ const data = ref<Record<string, any>>({})
11
+ const allowances = ref<Array<{ id?: number, name: string, amount: string, type?: string }>>([])
12
+ const edits = ref<Record<string, any>>({})
13
+ const editing = ref(false)
14
+ const formModel = computed({
15
+ get: () => editing.value ? edits.value : data.value,
16
+ set: (val) => { edits.value = val },
17
+ })
18
+ const loading = ref(false)
19
+ const saving = ref(false)
20
+
21
+ async function load() {
22
+ loading.value = true
23
+ try {
24
+ const res = await api.getCompensation(props.person.id)
25
+ if (res) {
26
+ const { allowances: al, ...rest } = res
27
+ data.value = rest
28
+ allowances.value = al ?? []
29
+ }
30
+ }
31
+ finally { loading.value = false }
32
+ }
33
+
34
+ onMounted(() => Promise.all([load(), fetchFields()]))
35
+
36
+ function startEdit() {
37
+ edits.value = { ...data.value }
38
+ editing.value = true
39
+ }
40
+
41
+ function cancelEdit() {
42
+ edits.value = {}
43
+ editing.value = false
44
+ }
45
+
46
+ async function save() {
47
+ saving.value = true
48
+ try {
49
+ await api.updateCompensation(props.person.id, { ...edits.value, allowances: allowances.value })
50
+ toast.add({ title: t('hr.toast.saved'), color: 'success' })
51
+ editing.value = false
52
+ await load()
53
+ }
54
+ catch { toast.add({ title: t('hr.toast.error'), color: 'error' }) }
55
+ finally { saving.value = false }
56
+ }
57
+
58
+ function addAllowance() {
59
+ allowances.value = [...allowances.value, { name: '', amount: '', type: 'Fixed' }]
60
+ }
61
+
62
+ function updateAllowance(idx: number, p: { name?: string, amount?: string, type?: string }) {
63
+ const list = [...allowances.value]
64
+ list[idx] = { ...list[idx]!, ...p }
65
+ allowances.value = list
66
+ }
67
+
68
+ function removeAllowance(idx: number) {
69
+ allowances.value = allowances.value.filter((_, i) => i !== idx)
70
+ }
71
+
72
+ const COMPENSATION_FIELD_NAMES = ['base_salary', 'currency', 'pay_frequency', 'bonus_target', 'bank_account']
73
+ const { pickFields, fetchAll: fetchFields, isReady } = useHrFieldRegistry(['hr_compensations'])
74
+
75
+ const compensationFields = computed(() => pickFields('hr_compensations', COMPENSATION_FIELD_NAMES))
76
+ </script>
77
+
78
+ <template>
79
+ <div class="space-y-4">
80
+ <HrInfoSection :title="$t('hr.compensation.title')" icon="i-ph-money-light">
81
+ <template #actions>
82
+ <div v-if="!readonly" class="flex items-center gap-2">
83
+ <template v-if="editing">
84
+ <UButton size="xs" variant="ghost" :label="$t('common.cancel')" @click="cancelEdit" />
85
+ <UButton size="xs" color="primary" :label="$t('common.save')" :loading="saving" @click="save" />
86
+ </template>
87
+ <UButton v-else size="xs" variant="soft" icon="i-ph-pencil-simple-light" :label="$t('common.edit')" @click="startEdit" />
88
+ </div>
89
+ </template>
90
+
91
+ <div v-if="loading || !isReady('hr_compensations')" class="px-4 py-6 text-sm text-muted">{{ $t('common.loading') }}</div>
92
+ <FormRoot
93
+ v-else-if="compensationFields.length"
94
+ v-model="formModel"
95
+ :fields="compensationFields"
96
+ :initial-values="data"
97
+ :disabled="!editing || readonly"
98
+ />
99
+ </HrInfoSection>
100
+
101
+ <HrInfoSection :title="$t('hr.compensation.sections.allowances')" icon="i-ph-coins-light">
102
+ <div class="p-4 space-y-2 text-sm">
103
+ <div v-for="(a, i) in allowances" :key="i" class="flex items-center gap-2">
104
+ <UInput
105
+ :model-value="a.name"
106
+ :disabled="!editing || readonly"
107
+ :placeholder="$t('hr.compensation.allowance.name')"
108
+ class="flex-1"
109
+ @update:model-value="(v) => updateAllowance(i, { name: String(v) })"
110
+ />
111
+ <UInput
112
+ :model-value="a.amount"
113
+ :disabled="!editing || readonly"
114
+ :placeholder="$t('hr.compensation.allowance.amount')"
115
+ class="flex-1"
116
+ @update:model-value="(v) => updateAllowance(i, { amount: String(v) })"
117
+ />
118
+ <UButton
119
+ v-if="editing && !readonly"
120
+ size="xs" color="neutral" variant="ghost"
121
+ icon="i-ph-trash-light"
122
+ @click="removeAllowance(i)"
123
+ />
124
+ </div>
125
+ <UButton
126
+ v-if="editing && !readonly"
127
+ size="xs" variant="soft" icon="i-ph-plus-light"
128
+ @click="addAllowance"
129
+ >
130
+ {{ $t('hr.compensation.allowance.add') }}
131
+ </UButton>
132
+ <p v-if="!allowances.length" class="text-muted text-xs">{{ $t('common.no_data') }}</p>
133
+ </div>
134
+ </HrInfoSection>
135
+ </div>
136
+ </template>
@@ -0,0 +1,77 @@
1
+ <script lang="ts" setup>
2
+ import HrInfoSection from "../shared/section.vue"
3
+
4
+ const props = defineProps<{ person: Person, readonly?: boolean }>()
5
+
6
+ const api = useHrApi()
7
+ const toast = useToast()
8
+ const { t } = useI18n()
9
+
10
+ const data = ref<Record<string, any>>({})
11
+ const edits = ref<Record<string, any>>({})
12
+ const editing = ref(false)
13
+ const formModel = computed({
14
+ get: () => editing.value ? edits.value : data.value,
15
+ set: (val) => { edits.value = val },
16
+ })
17
+ const loading = ref(false)
18
+ const saving = ref(false)
19
+
20
+ const CONTRACT_FIELD_NAMES = ['type', 'number', 'start_date', 'end_date', 'probation_end', 'renewal_count']
21
+ const { pickFields, fetchAll, isReady } = useHrFieldRegistry(['hr_contracts'])
22
+
23
+ const contractFields = computed(() => pickFields('hr_contracts', CONTRACT_FIELD_NAMES))
24
+
25
+ async function load() {
26
+ loading.value = true
27
+ try { data.value = (await api.getContract(props.person.id)) ?? {} }
28
+ finally { loading.value = false }
29
+ }
30
+
31
+ onMounted(() => Promise.all([load(), fetchAll()]))
32
+
33
+ function startEdit() {
34
+ edits.value = { ...data.value }
35
+ editing.value = true
36
+ }
37
+
38
+ function cancelEdit() {
39
+ edits.value = {}
40
+ editing.value = false
41
+ }
42
+
43
+ async function save() {
44
+ saving.value = true
45
+ try {
46
+ await api.updateContract(props.person.id, edits.value)
47
+ toast.add({ title: t('hr.toast.saved'), color: 'success' })
48
+ editing.value = false
49
+ await load()
50
+ }
51
+ catch { toast.add({ title: t('hr.toast.error'), color: 'error' }) }
52
+ finally { saving.value = false }
53
+ }
54
+ </script>
55
+
56
+ <template>
57
+ <HrInfoSection :title="$t('hr.contract.title')" icon="i-ph-file-text-light">
58
+ <template #actions>
59
+ <div v-if="!readonly" class="flex items-center gap-2">
60
+ <template v-if="editing">
61
+ <UButton size="xs" variant="ghost" :label="$t('common.cancel')" @click="cancelEdit" />
62
+ <UButton size="xs" color="primary" :label="$t('common.save')" :loading="saving" @click="save" />
63
+ </template>
64
+ <UButton v-else size="xs" variant="soft" icon="i-ph-pencil-simple-light" :label="$t('common.edit')" @click="startEdit" />
65
+ </div>
66
+ </template>
67
+
68
+ <div v-if="loading || !isReady('hr_contracts')" class="px-4 py-6 text-sm text-muted">{{ $t('common.loading') }}</div>
69
+ <FormRoot
70
+ v-else-if="contractFields.length"
71
+ v-model="formModel"
72
+ :fields="contractFields"
73
+ :initial-values="data"
74
+ :disabled="!editing || readonly"
75
+ />
76
+ </HrInfoSection>
77
+ </template>