@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.
- package/app/components/hr/contact/tab.vue +238 -0
- package/app/components/hr/department/card.vue +141 -0
- package/app/components/hr/department/form-modal.vue +90 -0
- package/app/components/hr/employees/assets/tab.vue +432 -0
- package/app/components/hr/employees/compensation.vue +136 -0
- package/app/components/hr/employees/contract.vue +77 -0
- package/app/components/hr/employees/insurance.vue +164 -0
- package/app/components/hr/employees/leave/tab.vue +180 -0
- package/app/components/hr/employees/provisioning/form-modal.vue +219 -0
- package/app/components/hr/employees/provisioning/tab.vue +187 -0
- package/app/components/hr/employees/tab.vue +38 -0
- package/app/components/hr/leave/calendar-tab.vue +649 -0
- package/app/components/hr/leave/override-modal.vue +62 -0
- package/app/components/hr/leave/request-modal.vue +185 -0
- package/app/components/hr/leave/requests-tab.vue +289 -0
- package/app/components/hr/leave/timeline-tab.vue +259 -0
- package/app/components/hr/offboarding/tab.vue +303 -0
- package/app/components/hr/person/activity/tab.vue +65 -0
- package/app/components/hr/person/activity/timeline.vue +119 -0
- package/app/components/hr/person/detail.vue +303 -0
- package/app/components/hr/person/document/tab-documents.vue +120 -0
- package/app/components/hr/person/document/template-edit-drawer.vue +215 -0
- package/app/components/hr/person/document/template-preview-card.vue +39 -0
- package/app/components/hr/person/document/trigger-modal.vue +121 -0
- package/app/components/hr/person/employee-form-modal.vue +78 -0
- package/app/components/hr/person/form-modal.vue +78 -0
- package/app/components/hr/person/list-row.vue +40 -0
- package/app/components/hr/person/profile/tab.vue +231 -0
- package/app/components/hr/settings/automation.vue +113 -0
- package/app/components/hr/settings/documents.vue +200 -0
- package/app/components/hr/settings/general.vue +87 -0
- package/app/components/hr/settings/holidays.vue +171 -0
- package/app/components/hr/settings/integrations.vue +185 -0
- package/app/components/hr/settings/policies.vue +83 -0
- package/app/components/hr/settings/policy/benefit-override-modal.vue +59 -0
- package/app/components/hr/settings/policy/editor-eligibility.vue +27 -0
- package/app/components/hr/settings/policy/editor-leave-base.vue +37 -0
- package/app/components/hr/settings/policy/editor-tenure-bonus.vue +61 -0
- package/app/components/hr/settings/recruitment.vue +128 -0
- package/app/components/hr/settings/taxonomies.vue +170 -0
- package/app/components/hr/shared/row.vue +21 -0
- package/app/components/hr/shared/section.vue +20 -0
- package/app/components/hr/shared/source-badge.vue +42 -0
- package/app/components/hr/shared/stage-badge.vue +24 -0
- package/app/components/hr/shared/workflow-timeline.vue +27 -0
- package/app/components/hr/talents/app-sidebar.vue +54 -0
- package/app/components/hr/talents/application-form-modal.vue +114 -0
- package/app/components/hr/talents/pipeline-picker.vue +56 -0
- package/app/components/hr/talents/step-detail.vue +133 -0
- package/app/components/hr/talents/step-stepper.vue +85 -0
- package/app/components/hr/talents/tab.vue +263 -0
- package/app/composables/use-departments.ts +59 -0
- package/app/composables/use-employee-detail.ts +24 -0
- package/app/composables/use-holidays.ts +48 -0
- package/app/composables/use-hr-api.ts +210 -0
- package/app/composables/use-hr-field-registry.ts +76 -0
- package/app/composables/use-hr-policies.ts +66 -0
- package/app/composables/use-hr-settings.ts +118 -0
- package/app/composables/use-leave.ts +71 -0
- package/app/composables/use-offboarding.ts +49 -0
- package/app/composables/use-people.ts +149 -0
- package/app/composables/use-providers.ts +44 -0
- package/app/composables/use-recruitment-workflow.ts +173 -0
- package/app/composables/use-templates.ts +44 -0
- package/app/composables/use-triggers.ts +26 -0
- package/app/config/column-renderers.ts +4 -0
- package/app/config/form-layouts.ts +193 -0
- package/app/data/hr-schema.ts +2608 -0
- package/app/lib/policy-engine.ts +116 -0
- package/app/pages/hr/departments.vue +114 -0
- package/app/pages/hr/employees/[id]/activity.vue +10 -0
- package/app/pages/hr/employees/[id]/assets.vue +14 -0
- package/app/pages/hr/employees/[id]/employment.vue +14 -0
- package/app/pages/hr/employees/[id]/index.vue +9 -0
- package/app/pages/hr/employees/[id]/offboarding.vue +7 -0
- package/app/pages/hr/employees/[id]/profile.vue +11 -0
- package/app/pages/hr/employees/[id]/provisioning.vue +17 -0
- package/app/pages/hr/employees/[id].vue +313 -0
- package/app/pages/hr/employees/index.vue +291 -0
- package/app/pages/hr/index.vue +3 -0
- package/app/pages/hr/leave.vue +79 -0
- package/app/pages/hr/settings.vue +43 -0
- package/app/pages/hr/setup.vue +3 -0
- package/app/pages/hr/talents/[id]/interview/[stepId].vue +231 -0
- package/app/pages/hr/talents/[id].vue +52 -0
- package/app/pages/hr/talents/index.vue +224 -0
- package/app/pages/hr.vue +129 -0
- package/app/plugins/hr-contacts-sync.client.ts +3 -0
- package/app/plugins/hr-extensions.ts +36 -0
- package/app/plugins/hr-setup.ts +5 -0
- package/app/plugins/navigations.ts +22 -0
- package/app/utils/hr-permissions.ts +27 -0
- package/app/utils/hr-policy-seed-step.ts +110 -0
- package/i18n/locales/en.json +726 -0
- package/i18n/locales/vi.json +688 -0
- package/nuxt.config.ts +19 -0
- package/package.json +27 -0
- package/server/api/hr/departments/[id].delete.ts +12 -0
- package/server/api/hr/departments/[id].patch.ts +14 -0
- package/server/api/hr/departments/index.get.ts +11 -0
- package/server/api/hr/departments/index.post.ts +13 -0
- package/server/api/hr/documents/templates/[id]/preview.post.ts +16 -0
- package/server/api/hr/documents/templates/[id].delete.ts +14 -0
- package/server/api/hr/documents/templates/[id].patch.ts +16 -0
- package/server/api/hr/documents/templates/index.get.ts +15 -0
- package/server/api/hr/documents/templates/index.post.ts +15 -0
- package/server/api/hr/documents/triggers/[id].patch.ts +16 -0
- package/server/api/hr/documents/triggers/index.get.ts +13 -0
- package/server/api/hr/fields/[collection].get.ts +14 -0
- package/server/api/hr/holidays/[id].delete.ts +14 -0
- package/server/api/hr/holidays/[id].patch.ts +16 -0
- package/server/api/hr/holidays/copy.post.ts +15 -0
- package/server/api/hr/holidays/index.get.ts +15 -0
- package/server/api/hr/holidays/index.post.ts +15 -0
- package/server/api/hr/leave/requests/[id].patch.ts +22 -0
- package/server/api/hr/leave/requests.get.ts +15 -0
- package/server/api/hr/leave/types.get.ts +13 -0
- package/server/api/hr/offboarding/[id]/cancel.post.ts +8 -0
- package/server/api/hr/offboarding/[id]/deprovision.post.ts +8 -0
- package/server/api/hr/offboarding/[id]/finalize.post.ts +8 -0
- package/server/api/hr/offboarding/[id]/return-assets.post.ts +8 -0
- package/server/api/hr/offboarding/[id]/settlement.get.ts +8 -0
- package/server/api/hr/offboarding/[id]/tasks/[taskId].patch.ts +10 -0
- package/server/api/hr/offboarding/[id].get.ts +8 -0
- package/server/api/hr/offboarding/[id].patch.ts +9 -0
- package/server/api/hr/offboarding/index.get.ts +7 -0
- package/server/api/hr/people/[id]/applications/[appId]/interviews/[iid].delete.ts +16 -0
- package/server/api/hr/people/[id]/applications/[appId]/interviews/[iid].patch.ts +18 -0
- package/server/api/hr/people/[id]/applications/[appId]/interviews/index.get.ts +15 -0
- package/server/api/hr/people/[id]/applications/[appId]/interviews/index.post.ts +17 -0
- package/server/api/hr/people/[id]/applications/[appId].patch.ts +17 -0
- package/server/api/hr/people/[id]/applications/index.get.ts +14 -0
- package/server/api/hr/people/[id]/applications/index.post.ts +16 -0
- package/server/api/hr/people/[id]/assets/[aid].delete.ts +13 -0
- package/server/api/hr/people/[id]/assets/[aid].patch.ts +15 -0
- package/server/api/hr/people/[id]/assets/index.get.ts +12 -0
- package/server/api/hr/people/[id]/assets/index.post.ts +14 -0
- package/server/api/hr/people/[id]/compensations.get.ts +14 -0
- package/server/api/hr/people/[id]/compensations.patch.ts +16 -0
- package/server/api/hr/people/[id]/contracts.get.ts +14 -0
- package/server/api/hr/people/[id]/contracts.patch.ts +16 -0
- package/server/api/hr/people/[id]/documents/[did].delete.ts +15 -0
- package/server/api/hr/people/[id]/documents/index.get.ts +14 -0
- package/server/api/hr/people/[id]/documents/index.post.ts +16 -0
- package/server/api/hr/people/[id]/insurances.get.ts +14 -0
- package/server/api/hr/people/[id]/insurances.patch.ts +16 -0
- package/server/api/hr/people/[id]/leave-balances/[bid].patch.ts +17 -0
- package/server/api/hr/people/[id]/leave-balances/index.get.ts +14 -0
- package/server/api/hr/people/[id]/leave-requests/index.get.ts +14 -0
- package/server/api/hr/people/[id]/leave-requests/index.post.ts +16 -0
- package/server/api/hr/people/[id]/link-user.post.ts +16 -0
- package/server/api/hr/people/[id]/notes/[nid].delete.ts +15 -0
- package/server/api/hr/people/[id]/notes/index.get.ts +14 -0
- package/server/api/hr/people/[id]/notes/index.post.ts +16 -0
- package/server/api/hr/people/[id]/offboarding/cases.get.ts +12 -0
- package/server/api/hr/people/[id]/offboarding.get.ts +12 -0
- package/server/api/hr/people/[id]/offboarding.post.ts +14 -0
- package/server/api/hr/people/[id]/provisioning/[logId]/retry.post.ts +7 -0
- package/server/api/hr/people/[id]/provisioning/index.get.ts +6 -0
- package/server/api/hr/people/[id]/provisioning/index.post.ts +7 -0
- package/server/api/hr/people/[id]/transition.post.ts +19 -0
- package/server/api/hr/people/[id]/transitions.get.ts +14 -0
- package/server/api/hr/people/[id].delete.ts +15 -0
- package/server/api/hr/people/[id].get.ts +14 -0
- package/server/api/hr/people/[id].patch.ts +17 -0
- package/server/api/hr/people/index.get.ts +15 -0
- package/server/api/hr/people/index.post.ts +19 -0
- package/server/api/hr/policies/[id].patch.ts +16 -0
- package/server/api/hr/policies/index.get.ts +13 -0
- package/server/api/hr/providers/[id]/test.post.ts +6 -0
- package/server/api/hr/providers/[id].delete.ts +6 -0
- package/server/api/hr/providers/[id].patch.ts +7 -0
- package/server/api/hr/providers/index.get.ts +5 -0
- package/server/api/hr/providers/index.post.ts +6 -0
- package/server/api/hr/settings/employment-types/[id].delete.ts +14 -0
- package/server/api/hr/settings/employment-types/[id].patch.ts +16 -0
- package/server/api/hr/settings/employment-types/index.get.ts +13 -0
- package/server/api/hr/settings/employment-types/index.post.ts +15 -0
- package/server/api/hr/settings/index.get.ts +13 -0
- package/server/api/hr/settings/index.patch.ts +15 -0
- package/server/api/hr/settings/leave-types/[id].delete.ts +14 -0
- package/server/api/hr/settings/leave-types/[id].patch.ts +16 -0
- package/server/api/hr/settings/leave-types/index.get.ts +13 -0
- package/server/api/hr/settings/leave-types/index.post.ts +15 -0
- package/shared/types/form-layout.ts +30 -0
- package/shared/types/index.ts +2 -0
- package/shared/types/integration.ts +41 -0
- package/shared/types/leave.ts +53 -0
- package/shared/types/offboarding.ts +46 -0
- package/shared/types/person.ts +54 -0
- package/shared/types/settings.ts +16 -0
- 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>
|