@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,133 @@
1
+ <script lang="ts" setup>
2
+ import type { WorkflowStep } from "@odp/workflow/shared/types";
3
+
4
+ const props = defineProps<{
5
+ step: WorkflowStep;
6
+ interview: {
7
+ id: number;
8
+ score: number | null;
9
+ interviewer: string | null;
10
+ scheduled_at: string | null;
11
+ notes: string | null;
12
+ };
13
+ stepStatus: string;
14
+ canApprove: boolean;
15
+ disabled: boolean;
16
+ }>();
17
+
18
+ const emit = defineEmits<{
19
+ update: [iid: number, patch: Record<string, unknown>];
20
+ approve: [];
21
+ reject: [];
22
+ }>();
23
+
24
+ const { user: currentUser } = useCurrentUser();
25
+ const { pickFields, fetchAll, isReady } = useHrFieldRegistry(["hr_interviews"]);
26
+
27
+ const DETAIL_FIELDS = ["interviewer", "scheduled_at", "score", "notes"];
28
+ const formFields = computed(() => {
29
+ const fields = pickFields("hr_interviews", DETAIL_FIELDS);
30
+ return fields;
31
+ });
32
+
33
+ const formModel = ref<Record<string, any>>({});
34
+
35
+ watch(
36
+ () => props.interview,
37
+ (iv) => {
38
+ formModel.value = {
39
+ interviewer: iv.interviewer,
40
+ score: iv.score,
41
+ scheduled_at: iv.scheduled_at,
42
+ notes: iv.notes,
43
+ };
44
+ },
45
+ { immediate: true },
46
+ );
47
+
48
+ function onFormUpdate(newValues: Record<string, any>) {
49
+ const patch: Record<string, unknown> = {};
50
+ for (const key of DETAIL_FIELDS) {
51
+ const newVal = newValues[key] ?? null;
52
+ const oldVal = formModel.value[key] ?? null;
53
+ if (newVal !== oldVal) patch[key] = newVal;
54
+ }
55
+ if (Object.keys(patch).length) {
56
+ formModel.value = { ...formModel.value, ...patch };
57
+ emit("update", props.interview.id, patch);
58
+ }
59
+ }
60
+
61
+ const STATUS_BADGE_COLOR: Record<string, string> = {
62
+ approved: "success",
63
+ active: "info",
64
+ rejected: "error",
65
+ };
66
+
67
+ watch(
68
+ () => props.interview.id,
69
+ (newId, oldId) => {
70
+ if (
71
+ newId !== oldId &&
72
+ !props.interview.interviewer &&
73
+ currentUser.value
74
+ ) {
75
+ emit("update", props.interview.id, {
76
+ interviewer: currentUser.value.id,
77
+ });
78
+ }
79
+ },
80
+ { immediate: true },
81
+ );
82
+
83
+ onMounted(fetchAll);
84
+ </script>
85
+
86
+ <template>
87
+ <div class="border border-default rounded-xl p-4 space-y-3">
88
+ <div class="flex items-center gap-2">
89
+ <h4 class="text-sm font-semibold flex-1">{{ step.name }}</h4>
90
+ <UBadge
91
+ :color="(STATUS_BADGE_COLOR[stepStatus] ?? 'neutral') as any"
92
+ variant="subtle"
93
+ size="sm"
94
+ >
95
+ {{ stepStatus }}
96
+ </UBadge>
97
+ </div>
98
+
99
+ <div
100
+ v-if="!isReady('hr_interviews')"
101
+ class="py-4 text-sm text-muted text-center"
102
+ >
103
+ {{ $t("common.loading") }}
104
+ </div>
105
+ <FormRoot
106
+ v-else-if="formFields.length"
107
+ :model-value="formModel"
108
+ :fields="formFields"
109
+ :initial-values="{}"
110
+ :disabled="disabled"
111
+ @update:model-value="onFormUpdate"
112
+ />
113
+
114
+ <div v-if="canApprove" class="flex justify-end gap-2 pt-1">
115
+ <UButton
116
+ size="sm"
117
+ color="error"
118
+ variant="outline"
119
+ icon="i-ph-x-light"
120
+ :label="$t('hr.recruitment.reject_step')"
121
+ @click="emit('reject')"
122
+ />
123
+ <UButton
124
+ size="sm"
125
+ color="success"
126
+ variant="soft"
127
+ icon="i-ph-check-light"
128
+ :label="$t('hr.recruitment.approve_step')"
129
+ @click="emit('approve')"
130
+ />
131
+ </div>
132
+ </div>
133
+ </template>
@@ -0,0 +1,85 @@
1
+ <script lang="ts" setup>
2
+ import type { WorkflowStep } from "@odp/workflow/shared/types";
3
+
4
+ const props = defineProps<{
5
+ steps: WorkflowStep[];
6
+ currentStepId: string | null;
7
+ selectedStepId: string | null;
8
+ getStatus: (stepId: string) => string;
9
+ }>();
10
+
11
+ const emit = defineEmits<{ select: [stepId: string] }>();
12
+
13
+ const STATUS_ICON: Record<string, string> = {
14
+ approved: "i-ph-check-circle-fill",
15
+ rejected: "i-ph-x-circle-fill",
16
+ active: "i-ph-circle-notch-bold",
17
+ skipped: "i-ph-minus-circle-light",
18
+ timed_out: "i-ph-clock-countdown-light",
19
+ };
20
+
21
+ const STATUS_ICON_COLOR: Record<string, string> = {
22
+ approved: "text-success",
23
+ rejected: "text-error",
24
+ };
25
+
26
+ const STATUS_STEP_CLASS: Record<string, string> = {
27
+ approved: "border-success/30 bg-success/5",
28
+ rejected: "border-error/30 bg-error/5",
29
+ };
30
+
31
+ function stepIcon(stepId: string): string {
32
+ return STATUS_ICON[props.getStatus(stepId)] ?? "i-ph-circle-light";
33
+ }
34
+
35
+ function stepIconColor(stepId: string): string {
36
+ const status = props.getStatus(stepId);
37
+ if (STATUS_ICON_COLOR[status]) return STATUS_ICON_COLOR[status]!;
38
+ if (stepId === props.currentStepId) return "text-primary animate-spin";
39
+ return "text-muted";
40
+ }
41
+
42
+ function stepClass(step: WorkflowStep): string[] {
43
+ const classes: string[] = [];
44
+
45
+ if (props.selectedStepId === step.id) classes.push("ring-2 ring-primary/40");
46
+
47
+ if (step.id === props.currentStepId) {
48
+ classes.push("border-primary bg-primary/10 font-medium");
49
+ } else {
50
+ classes.push(STATUS_STEP_CLASS[props.getStatus(step.id)] ?? "border-default");
51
+ }
52
+
53
+ classes.push(step.type === "approval" ? "cursor-pointer hover:bg-elevated/50" : "cursor-default opacity-60");
54
+
55
+ return classes;
56
+ }
57
+
58
+ function connectorClass(stepId: string): string {
59
+ return props.getStatus(stepId) === "pending" ? "bg-default" : "bg-primary";
60
+ }
61
+ </script>
62
+
63
+ <template>
64
+ <div class="flex items-center gap-1 overflow-x-auto pb-2">
65
+ <template v-for="(step, idx) in steps" :key="step.id">
66
+ <div
67
+ v-if="idx > 0"
68
+ class="h-px w-6 shrink-0"
69
+ :class="connectorClass(step.id)"
70
+ />
71
+ <button
72
+ class="flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-xs shrink-0 transition-colors"
73
+ :class="stepClass(step)"
74
+ @click="step.type === 'approval' && emit('select', step.id)"
75
+ >
76
+ <UIcon
77
+ :name="stepIcon(step.id)"
78
+ class="size-3.5"
79
+ :class="stepIconColor(step.id)"
80
+ />
81
+ <span>{{ step.name }}</span>
82
+ </button>
83
+ </template>
84
+ </div>
85
+ </template>
@@ -0,0 +1,263 @@
1
+ <script lang="ts" setup>
2
+ import type { PipelineProgress, RecruitmentPipeline } from '../../../composables/use-recruitment-workflow'
3
+ import AppSidebar from './app-sidebar.vue'
4
+ import PipelinePicker from './pipeline-picker.vue'
5
+ import StepStepper from './step-stepper.vue'
6
+ import StepDetail from './step-detail.vue'
7
+ import ApplicationFormModal from './application-form-modal.vue'
8
+ import WorkflowTimeline from '../shared/workflow-timeline.vue'
9
+
10
+ const props = defineProps<{ personId: string | number }>()
11
+
12
+ const { $api } = useNuxtApp()
13
+ const { t } = useI18n()
14
+ const toast = useToast()
15
+ const { getAvailablePipelines, startPipeline, loadInstance, approveStep, rejectStep } = useRecruitmentWorkflow()
16
+
17
+ interface Application {
18
+ id: number; person_id: number; status: string; position: string | null
19
+ notes: string | null; workflow_instance_id: string | null; date_created: string
20
+ }
21
+ interface Interview {
22
+ id: number; application_id: number; type: string; status: string
23
+ score: number | null; interviewer: string | null; notes: string | null
24
+ scheduled_at: string | null; label: string | null; workflow_step_id: string | null
25
+ date_created: string
26
+ }
27
+
28
+ const applications = ref<Application[]>([])
29
+ const interviews = ref<Interview[]>([])
30
+ const loading = ref(false)
31
+ const loadingInterviews = ref(false)
32
+ const selectedAppId = ref<number | null>(null)
33
+ const showCreateModal = ref(false)
34
+ const pipelines = ref<RecruitmentPipeline[]>([])
35
+ const pipelineProgress = ref<PipelineProgress | null>(null)
36
+ const loadingPipeline = ref(false)
37
+ const selectedStepId = ref<string | null>(null)
38
+ const rejectComment = ref("")
39
+ const showRejectModal = ref(false)
40
+ const pendingRejectStepId = ref<string | null>(null)
41
+
42
+ const selectedApp = computed(() => applications.value.find(a => a.id === selectedAppId.value) ?? null)
43
+ const isActive = computed(() => selectedApp.value?.status === "active")
44
+ const hasWorkflow = computed(() => !!selectedApp.value?.workflow_instance_id)
45
+
46
+ const sortedSteps = computed(() => {
47
+ if (!pipelineProgress.value) return []
48
+ return [...pipelineProgress.value.steps].sort((a, b) => a.sort_order - b.sort_order)
49
+ })
50
+ const approvalSteps = computed(() => sortedSteps.value.filter(s => s.type === 'approval'))
51
+
52
+ const selectedStepInterview = computed(() =>
53
+ selectedStepId.value ? interviews.value.find(iv => iv.workflow_step_id === selectedStepId.value) ?? null : null,
54
+ )
55
+ const selectedStepDef = computed(() =>
56
+ selectedStepId.value ? sortedSteps.value.find(s => s.id === selectedStepId.value) ?? null : null,
57
+ )
58
+ const selectedInstanceStep = computed(() =>
59
+ selectedStepId.value && pipelineProgress.value
60
+ ? pipelineProgress.value.instanceSteps.find(is => is.step_id === selectedStepId.value) ?? null
61
+ : null,
62
+ )
63
+ const canApproveSelected = computed(() => selectedInstanceStep.value?.status === 'active' && isActive.value)
64
+
65
+ function getInstanceStepStatus(stepId: string): string {
66
+ return pipelineProgress.value?.instanceSteps.find(s => s.step_id === stepId)?.status ?? "pending"
67
+ }
68
+
69
+ async function loadPipelines() { try { pipelines.value = await getAvailablePipelines() } catch {} }
70
+ async function loadApplications() {
71
+ loading.value = true
72
+ try {
73
+ const res = await $api<{ data: Application[] }>(`/hr/people/${props.personId}/applications`)
74
+ applications.value = res.data ?? []
75
+ if (!selectedAppId.value && applications.value.length) selectedAppId.value = applications.value[0]!.id
76
+ } catch { toast.add({ title: t("hr.toast.error"), color: "error" }) }
77
+ finally { loading.value = false }
78
+ }
79
+ async function loadInterviews() {
80
+ if (!selectedAppId.value) { interviews.value = []; return }
81
+ loadingInterviews.value = true
82
+ try {
83
+ const res = await $api<{ data: Interview[] }>(`/hr/people/${props.personId}/applications/${selectedAppId.value}/interviews`)
84
+ interviews.value = res.data ?? []
85
+ } catch { interviews.value = [] }
86
+ finally { loadingInterviews.value = false }
87
+ }
88
+ async function loadWorkflowProgress() {
89
+ const app = selectedApp.value
90
+ if (!app?.workflow_instance_id) { pipelineProgress.value = null; selectedStepId.value = null; return }
91
+ loadingPipeline.value = true
92
+ try {
93
+ pipelineProgress.value = await loadInstance(app.workflow_instance_id)
94
+ const cur = pipelineProgress.value?.currentStepId
95
+ if (cur && sortedSteps.value.find(s => s.id === cur)?.type === 'approval') selectedStepId.value = cur
96
+ else if (approvalSteps.value.length) selectedStepId.value = approvalSteps.value[0]!.id
97
+ } catch { pipelineProgress.value = null }
98
+ finally { loadingPipeline.value = false }
99
+ }
100
+
101
+ watch(selectedAppId, () => { selectedStepId.value = null; loadInterviews(); loadWorkflowProgress() })
102
+
103
+ async function onApplicationSaved(app: Application, workflowId: string | null) {
104
+ if (workflowId) {
105
+ try { const inst = await startPipeline(props.personId, app.id, workflowId); app.workflow_instance_id = inst.id }
106
+ catch { toast.add({ title: t("hr.recruitment.workflow_start_error"), color: "warning" }) }
107
+ }
108
+ applications.value.unshift(app); selectedAppId.value = app.id
109
+ }
110
+ async function handleStartPipeline(workflowId: string) {
111
+ const app = selectedApp.value; if (!app) return
112
+ try {
113
+ const inst = await startPipeline(props.personId, app.id, workflowId)
114
+ app.workflow_instance_id = inst.id
115
+ const idx = applications.value.findIndex(a => a.id === app.id)
116
+ if (idx >= 0) applications.value[idx] = { ...app }
117
+ await loadInterviews(); await loadWorkflowProgress()
118
+ toast.add({ title: t("hr.recruitment.pipeline_started"), color: "success" })
119
+ } catch { toast.add({ title: t("hr.toast.error"), color: "error" }) }
120
+ }
121
+ async function updateAppStatus(appId: number, status: string) {
122
+ try {
123
+ await $api(`/hr/people/${props.personId}/applications/${appId}`, { method: "PATCH", body: { status } })
124
+ const idx = applications.value.findIndex(a => a.id === appId)
125
+ if (idx >= 0) applications.value[idx] = { ...applications.value[idx]!, status }
126
+ toast.add({ title: t("hr.toast.saved"), color: "success" })
127
+ } catch { toast.add({ title: t("hr.toast.error"), color: "error" }) }
128
+ }
129
+ async function updateInterview(iid: number, patch: Record<string, unknown>) {
130
+ if (!selectedAppId.value) return
131
+ try {
132
+ const res = await $api<{ data: Interview }>(`/hr/people/${props.personId}/applications/${selectedAppId.value}/interviews/${iid}`, { method: "PATCH", body: patch })
133
+ const idx = interviews.value.findIndex(i => i.id === iid)
134
+ if (idx >= 0) interviews.value[idx] = res.data
135
+ } catch { toast.add({ title: t("hr.toast.error"), color: "error" }) }
136
+ }
137
+ async function handleApproveStep() {
138
+ const inst = pipelineProgress.value?.instance; const is = selectedInstanceStep.value
139
+ if (!inst || !is) return
140
+ try {
141
+ await approveStep(inst.id, is.step_id); await loadWorkflowProgress()
142
+ const iv = selectedStepInterview.value
143
+ if (iv && iv.status === 'Scheduled') await updateInterview(iv.id, { status: 'Done' })
144
+ toast.add({ title: t("hr.recruitment.step_approved"), color: "success" })
145
+ } catch { toast.add({ title: t("hr.toast.error"), color: "error" }) }
146
+ }
147
+ function openRejectModal() {
148
+ if (!selectedInstanceStep.value) return
149
+ pendingRejectStepId.value = selectedInstanceStep.value.step_id; rejectComment.value = ""; showRejectModal.value = true
150
+ }
151
+ async function handleRejectStep() {
152
+ const inst = pipelineProgress.value?.instance
153
+ if (!inst || !pendingRejectStepId.value || !rejectComment.value.trim()) return
154
+ try {
155
+ await rejectStep(inst.id, pendingRejectStepId.value, rejectComment.value.trim())
156
+ showRejectModal.value = false; await loadWorkflowProgress()
157
+ toast.add({ title: t("hr.recruitment.step_rejected"), color: "success" })
158
+ } catch { toast.add({ title: t("hr.toast.error"), color: "error" }) }
159
+ }
160
+
161
+ onMounted(() => { loadApplications(); loadPipelines() })
162
+ </script>
163
+
164
+ <template>
165
+ <div class="flex h-full min-h-[400px]">
166
+ <AppSidebar
167
+ :applications="applications"
168
+ :selected-app-id="selectedAppId"
169
+ :loading="loading"
170
+ @select="selectedAppId = $event"
171
+ @create="showCreateModal = true"
172
+ />
173
+
174
+ <div class="flex-1 p-4 overflow-y-auto">
175
+ <div v-if="!selectedApp" class="text-muted text-sm text-center py-16">
176
+ {{ $t("hr.recruitment.select_application") }}
177
+ </div>
178
+ <div v-else>
179
+ <!-- App header -->
180
+ <div class="flex items-center justify-between mb-4">
181
+ <div class="flex items-center gap-2">
182
+ <h3 class="text-sm font-semibold">{{ selectedApp.position }}</h3>
183
+ <UBadge
184
+ :color="({ active: 'info', hired: 'success', rejected: 'error', withdrawn: 'neutral' }[selectedApp.status] ?? 'neutral') as any"
185
+ variant="subtle" size="md"
186
+ >
187
+ {{ $t(`hr.recruitment.app_status.${selectedApp.status}`) }}
188
+ </UBadge>
189
+ </div>
190
+ <div v-if="isActive" class="flex gap-2">
191
+ <UButton size="sm" color="error" variant="outline" icon="i-ph-x-circle-light" :label="$t('hr.recruitment.reject')" @click="updateAppStatus(selectedApp.id, 'rejected')" />
192
+ <UButton size="sm" color="success" variant="soft" icon="i-ph-check-circle-light" :label="$t('hr.recruitment.pass')" @click="updateAppStatus(selectedApp.id, 'hired')" />
193
+ </div>
194
+ </div>
195
+
196
+ <div v-if="loadingPipeline" class="text-xs text-muted py-4 text-center">{{ $t("common.loading") }}</div>
197
+
198
+ <PipelinePicker v-else-if="!hasWorkflow && isActive" :pipelines="pipelines" @start="handleStartPipeline" />
199
+
200
+ <div v-else-if="pipelineProgress" class="space-y-4">
201
+ <div
202
+ v-if="pipelineProgress.isCompleted || pipelineProgress.isRejected"
203
+ class="p-2 rounded-lg text-center text-xs font-medium"
204
+ :class="pipelineProgress.isCompleted ? 'bg-success/10 text-success' : 'bg-error/10 text-error'"
205
+ >
206
+ {{ pipelineProgress.isCompleted ? $t('hr.recruitment.pipeline_completed') : $t('hr.recruitment.pipeline_rejected') }}
207
+ </div>
208
+
209
+ <StepStepper
210
+ :steps="sortedSteps"
211
+ :current-step-id="pipelineProgress.currentStepId"
212
+ :selected-step-id="selectedStepId"
213
+ :get-status="getInstanceStepStatus"
214
+ @select="selectedStepId = $event"
215
+ />
216
+
217
+ <div v-if="selectedStepDef && selectedStepInterview" class="space-y-2">
218
+ <div class="flex justify-end">
219
+ <UButton
220
+ size="xs"
221
+ variant="link"
222
+ color="neutral"
223
+ icon="i-ph-arrow-square-out-light"
224
+ :label="$t('hr.recruitment.open_full_page')"
225
+ :to="`/hr/talents/${props.personId}/interview/${selectedStepDef.id}`"
226
+ />
227
+ </div>
228
+ <StepDetail
229
+ :step="selectedStepDef"
230
+ :interview="selectedStepInterview"
231
+ :step-status="getInstanceStepStatus(selectedStepDef.id)"
232
+ :can-approve="canApproveSelected"
233
+ :disabled="!isActive"
234
+ @update="updateInterview"
235
+ @approve="handleApproveStep"
236
+ @reject="openRejectModal"
237
+ />
238
+ </div>
239
+
240
+ <div v-else-if="approvalSteps.length && !selectedStepInterview" class="text-xs text-muted text-center py-6">
241
+ {{ $t("hr.recruitment.select_step_hint") }}
242
+ </div>
243
+
244
+ <WorkflowTimeline :actions="pipelineProgress.actions" />
245
+ </div>
246
+ </div>
247
+ </div>
248
+
249
+ <ApplicationFormModal v-model:open="showCreateModal" :person-id="props.personId" :pipelines="pipelines" @saved="onApplicationSaved" />
250
+
251
+ <UModal :open="showRejectModal" :title="$t('hr.recruitment.reject_step')" @close="showRejectModal = false">
252
+ <template #body>
253
+ <UTextarea v-model="rejectComment" :placeholder="$t('hr.recruitment.reject_reason_placeholder')" size="sm" :rows="3" autofocus />
254
+ </template>
255
+ <template #footer>
256
+ <div class="flex justify-end gap-2">
257
+ <UButton color="neutral" variant="outline" :label="$t('common.cancel')" @click="showRejectModal = false" />
258
+ <UButton color="error" :label="$t('hr.recruitment.reject_step')" :disabled="!rejectComment.trim()" @click="handleRejectStep" />
259
+ </div>
260
+ </template>
261
+ </UModal>
262
+ </div>
263
+ </template>
@@ -0,0 +1,59 @@
1
+ export type Department = {
2
+ id: number
3
+ name: string
4
+ description?: string | null
5
+ color?: string | null
6
+ icon?: string | null
7
+ head?: { id: number, full_name: string, avatar?: string } | null
8
+ member_count?: number
9
+ avg_tenure_months?: number
10
+ }
11
+
12
+ export function useDepartments() {
13
+ const departments = useState<Department[]>('hr:departments', () => [])
14
+ const loading = ref(false)
15
+
16
+ async function fetchDepartments(force = false) {
17
+ if (!force && departments.value.length > 0) return
18
+ loading.value = true
19
+ try {
20
+ const res = await $fetch<any>('/api/hr/departments')
21
+ departments.value = Array.isArray(res) ? res : (res?.data ?? [])
22
+ }
23
+ catch { /* noop */ }
24
+ finally {
25
+ loading.value = false
26
+ }
27
+ }
28
+
29
+ async function saveDepartment(id: number | null, payload: Partial<Department>) {
30
+ if (id) {
31
+ const updated = await $fetch<Department>(`/api/hr/departments/${id}`, {
32
+ method: 'PATCH',
33
+ body: payload,
34
+ })
35
+ const idx = departments.value.findIndex(d => d.id === id)
36
+ if (idx >= 0) departments.value[idx] = { ...departments.value[idx], ...updated }
37
+ return updated
38
+ }
39
+ else {
40
+ const created = await $fetch<Department>('/api/hr/departments', {
41
+ method: 'POST',
42
+ body: payload,
43
+ })
44
+ departments.value = [created, ...departments.value]
45
+ return created
46
+ }
47
+ }
48
+
49
+ async function deleteDepartment(id: number) {
50
+ await $fetch(`/api/hr/departments/${id}`, { method: 'DELETE' })
51
+ departments.value = departments.value.filter(d => d.id !== id)
52
+ }
53
+
54
+ if (import.meta.client) {
55
+ fetchDepartments()
56
+ }
57
+
58
+ return { departments, loading, fetchDepartments, saveDepartment, deleteDepartment }
59
+ }
@@ -0,0 +1,24 @@
1
+ import type { InjectionKey, Ref } from "vue"
2
+
3
+ export interface EmployeeDetailContext {
4
+ person: Ref<Person>
5
+ isReadonly: (tab: string) => boolean
6
+ onUpdated: (person: Person) => void
7
+ }
8
+
9
+ const EMPLOYEE_DETAIL_KEY: InjectionKey<EmployeeDetailContext> =
10
+ Symbol("hr-employee-detail")
11
+
12
+ export function provideEmployeeDetail(ctx: EmployeeDetailContext) {
13
+ provide(EMPLOYEE_DETAIL_KEY, ctx)
14
+ }
15
+
16
+ export function useEmployeeDetail(): EmployeeDetailContext {
17
+ const ctx = inject(EMPLOYEE_DETAIL_KEY)
18
+ if (!ctx) {
19
+ throw new Error(
20
+ "useEmployeeDetail() must be called within the employee detail layout",
21
+ )
22
+ }
23
+ return ctx
24
+ }
@@ -0,0 +1,48 @@
1
+ export function useHolidays() {
2
+ const holidays = useState<CompanyHoliday[]>('holidays', () => [])
3
+ const loading = useState('holidays.loading', () => false)
4
+
5
+ async function fetchHolidays(year?: number) {
6
+ loading.value = true
7
+ try {
8
+ const res = await $fetch<any>('/api/hr/holidays', { query: year ? { year: String(year) } : {} })
9
+ holidays.value = (res?.data ?? res) ?? []
10
+ }
11
+ finally {
12
+ loading.value = false
13
+ }
14
+ }
15
+
16
+ async function createHoliday(payload: Partial<CompanyHoliday>) {
17
+ const res = await $fetch<any>('/api/hr/holidays', { method: 'POST', body: payload })
18
+ await fetchHolidays()
19
+ return res?.data ?? res
20
+ }
21
+
22
+ async function updateHoliday(id: number, payload: Partial<CompanyHoliday>) {
23
+ const res = await $fetch<any>(`/api/hr/holidays/${id}`, { method: 'PATCH', body: payload })
24
+ await fetchHolidays()
25
+ return res?.data ?? res
26
+ }
27
+
28
+ async function deleteHoliday(id: number) {
29
+ await $fetch(`/api/hr/holidays/${id}`, { method: 'DELETE' })
30
+ holidays.value = holidays.value.filter(h => h.id !== id)
31
+ }
32
+
33
+ async function copyFromPreviousYear(targetYear: number) {
34
+ const res = await $fetch<any>('/api/hr/holidays/copy', { method: 'POST', body: { target_year: targetYear } })
35
+ await fetchHolidays(targetYear)
36
+ return res?.data ?? res
37
+ }
38
+
39
+ return {
40
+ holidays,
41
+ loading,
42
+ fetchHolidays,
43
+ createHoliday,
44
+ updateHoliday,
45
+ deleteHoliday,
46
+ copyFromPreviousYear,
47
+ }
48
+ }