@hed-hog/operations 0.0.295 → 0.0.296

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 (126) hide show
  1. package/dist/operations.controller.d.ts +415 -0
  2. package/dist/operations.controller.d.ts.map +1 -0
  3. package/dist/operations.controller.js +333 -0
  4. package/dist/operations.controller.js.map +1 -0
  5. package/dist/operations.module.d.ts.map +1 -1
  6. package/dist/operations.module.js +4 -3
  7. package/dist/operations.module.js.map +1 -1
  8. package/dist/operations.service.d.ts +589 -153
  9. package/dist/operations.service.d.ts.map +1 -1
  10. package/dist/operations.service.js +2229 -100
  11. package/dist/operations.service.js.map +1 -1
  12. package/hedhog/data/menu.yaml +198 -251
  13. package/hedhog/data/role.yaml +23 -14
  14. package/hedhog/data/route.yaml +317 -143
  15. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +310 -0
  16. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +631 -0
  17. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +132 -0
  18. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +558 -0
  19. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +291 -0
  20. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +689 -0
  21. package/hedhog/frontend/app/_lib/api.ts.ejs +32 -0
  22. package/hedhog/frontend/app/_lib/hooks/use-operations-access.ts.ejs +44 -0
  23. package/hedhog/frontend/app/_lib/types.ts.ejs +360 -0
  24. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +129 -25
  25. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +14 -0
  26. package/hedhog/frontend/app/approvals/page.tsx.ejs +386 -147
  27. package/hedhog/frontend/app/collaborators/[id]/edit/page.tsx.ejs +11 -0
  28. package/hedhog/frontend/app/collaborators/[id]/page.tsx.ejs +11 -0
  29. package/hedhog/frontend/app/collaborators/new/page.tsx.ejs +5 -0
  30. package/hedhog/frontend/app/collaborators/page.tsx.ejs +261 -0
  31. package/hedhog/frontend/app/contracts/[id]/edit/page.tsx.ejs +11 -0
  32. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +11 -108
  33. package/hedhog/frontend/app/contracts/new/page.tsx.ejs +17 -0
  34. package/hedhog/frontend/app/contracts/page.tsx.ejs +262 -181
  35. package/hedhog/frontend/app/page.tsx.ejs +319 -177
  36. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +11 -0
  37. package/hedhog/frontend/app/projects/[id]/page.tsx.ejs +11 -936
  38. package/hedhog/frontend/app/projects/new/page.tsx.ejs +5 -0
  39. package/hedhog/frontend/app/projects/page.tsx.ejs +236 -1074
  40. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +418 -0
  41. package/hedhog/frontend/app/team/page.tsx.ejs +339 -0
  42. package/hedhog/frontend/app/time-off/page.tsx.ejs +328 -0
  43. package/hedhog/frontend/app/timesheets/page.tsx.ejs +636 -126
  44. package/hedhog/frontend/messages/en.json +648 -454
  45. package/hedhog/frontend/messages/pt.json +647 -454
  46. package/hedhog/table/operations_approval.yaml +49 -0
  47. package/hedhog/table/operations_approval_history.yaml +29 -0
  48. package/hedhog/table/{operations_employee.yaml → operations_collaborator.yaml} +67 -64
  49. package/hedhog/table/operations_collaborator_schedule_day.yaml +34 -0
  50. package/hedhog/table/operations_contract.yaml +100 -48
  51. package/hedhog/table/operations_contract_document.yaml +39 -0
  52. package/hedhog/table/operations_contract_financial_term.yaml +40 -0
  53. package/hedhog/table/operations_contract_history.yaml +27 -0
  54. package/hedhog/table/operations_contract_party.yaml +46 -0
  55. package/hedhog/table/operations_contract_revision.yaml +38 -0
  56. package/hedhog/table/operations_contract_signature.yaml +38 -0
  57. package/hedhog/table/operations_project.yaml +54 -50
  58. package/hedhog/table/{operations_allocation.yaml → operations_project_assignment.yaml} +55 -52
  59. package/hedhog/table/operations_schedule_adjustment_day.yaml +34 -0
  60. package/hedhog/table/operations_schedule_adjustment_request.yaml +53 -0
  61. package/hedhog/table/operations_time_off_request.yaml +57 -0
  62. package/hedhog/table/operations_timesheet.yaml +41 -36
  63. package/hedhog/table/operations_timesheet_entry.yaml +40 -50
  64. package/package.json +8 -7
  65. package/src/operations.controller.ts +182 -0
  66. package/src/operations.module.ts +22 -21
  67. package/src/operations.service.ts +3595 -137
  68. package/hedhog/data/operations_career_level.yaml +0 -102
  69. package/hedhog/data/operations_career_track.yaml +0 -8
  70. package/hedhog/data/operations_certification.yaml +0 -38
  71. package/hedhog/data/operations_evaluation_cycle.yaml +0 -18
  72. package/hedhog/data/operations_performance_criterion.yaml +0 -48
  73. package/hedhog/frontend/app/_components/allocation-calendar.tsx.ejs +0 -56
  74. package/hedhog/frontend/app/_components/kanban-board.tsx.ejs +0 -626
  75. package/hedhog/frontend/app/_components/timesheet-entry-dialog.tsx.ejs +0 -142
  76. package/hedhog/frontend/app/_lib/hooks/use-operations-data.ts.ejs +0 -41
  77. package/hedhog/frontend/app/_lib/hooks/use-operations-growth-data.ts.ejs +0 -63
  78. package/hedhog/frontend/app/_lib/mocks/allocations.mock.ts.ejs +0 -74
  79. package/hedhog/frontend/app/_lib/mocks/contracts.mock.ts.ejs +0 -74
  80. package/hedhog/frontend/app/_lib/mocks/operations-growth.mock.ts.ejs +0 -824
  81. package/hedhog/frontend/app/_lib/mocks/projects.mock.ts.ejs +0 -455
  82. package/hedhog/frontend/app/_lib/mocks/tasks.mock.ts.ejs +0 -117
  83. package/hedhog/frontend/app/_lib/mocks/timesheets.mock.ts.ejs +0 -84
  84. package/hedhog/frontend/app/_lib/mocks/users.mock.ts.ejs +0 -67
  85. package/hedhog/frontend/app/_lib/services/contracts.service.ts.ejs +0 -10
  86. package/hedhog/frontend/app/_lib/services/operations-growth.service.ts.ejs +0 -31
  87. package/hedhog/frontend/app/_lib/services/projects.service.ts.ejs +0 -10
  88. package/hedhog/frontend/app/_lib/services/tasks.service.ts.ejs +0 -10
  89. package/hedhog/frontend/app/_lib/services/timesheets.service.ts.ejs +0 -10
  90. package/hedhog/frontend/app/_lib/types/operations-growth.ts.ejs +0 -209
  91. package/hedhog/frontend/app/_lib/types/operations.ts.ejs +0 -156
  92. package/hedhog/frontend/app/_lib/utils/growth.ts.ejs +0 -62
  93. package/hedhog/frontend/app/_lib/utils/metrics.ts.ejs +0 -103
  94. package/hedhog/frontend/app/_lib/utils/status.ts.ejs +0 -80
  95. package/hedhog/frontend/app/allocations/page.tsx.ejs +0 -155
  96. package/hedhog/frontend/app/career/page.tsx.ejs +0 -143
  97. package/hedhog/frontend/app/certifications/page.tsx.ejs +0 -202
  98. package/hedhog/frontend/app/evaluations/page.tsx.ejs +0 -278
  99. package/hedhog/frontend/app/goals/page.tsx.ejs +0 -171
  100. package/hedhog/frontend/app/growth/page.tsx.ejs +0 -288
  101. package/hedhog/frontend/app/manager/page.tsx.ejs +0 -175
  102. package/hedhog/frontend/app/rewards/page.tsx.ejs +0 -196
  103. package/hedhog/frontend/app/tasks/page.tsx.ejs +0 -999
  104. package/hedhog/table/operations_calibration_item.yaml +0 -61
  105. package/hedhog/table/operations_calibration_session.yaml +0 -25
  106. package/hedhog/table/operations_career_level.yaml +0 -75
  107. package/hedhog/table/operations_career_track.yaml +0 -21
  108. package/hedhog/table/operations_certification.yaml +0 -48
  109. package/hedhog/table/operations_employee_certification.yaml +0 -43
  110. package/hedhog/table/operations_employee_connect.yaml +0 -61
  111. package/hedhog/table/operations_employee_evaluation.yaml +0 -113
  112. package/hedhog/table/operations_employee_evaluation_item.yaml +0 -39
  113. package/hedhog/table/operations_employee_profile.yaml +0 -80
  114. package/hedhog/table/operations_employee_skill_matrix.yaml +0 -30
  115. package/hedhog/table/operations_evaluation_cycle.yaml +0 -31
  116. package/hedhog/table/operations_goal.yaml +0 -67
  117. package/hedhog/table/operations_goal_progress.yaml +0 -31
  118. package/hedhog/table/operations_performance_criterion.yaml +0 -29
  119. package/hedhog/table/operations_promotion_readiness.yaml +0 -49
  120. package/hedhog/table/operations_promotion_recommendation.yaml +0 -63
  121. package/hedhog/table/operations_public_recognition.yaml +0 -46
  122. package/hedhog/table/operations_reward.yaml +0 -100
  123. package/hedhog/table/operations_score_event.yaml +0 -81
  124. package/hedhog/table/operations_task.yaml +0 -60
  125. package/src/operations-data.controller.ts +0 -54
  126. package/src/operations-growth.controller.ts +0 -44
@@ -0,0 +1,339 @@
1
+ 'use client';
2
+
3
+ import { EmptyState, Page } from '@/components/entity-list';
4
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
5
+ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
6
+ import {
7
+ Table,
8
+ TableBody,
9
+ TableCell,
10
+ TableHead,
11
+ TableHeader,
12
+ TableRow,
13
+ } from '@/components/ui/table';
14
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
15
+ import { Users } from 'lucide-react';
16
+ import { useTranslations } from 'next-intl';
17
+ import { OperationsHeader } from '../_components/operations-header';
18
+ import { StatusBadge } from '../_components/status-badge';
19
+ import { fetchOperations } from '../_lib/api';
20
+ import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
21
+ import type { OperationsTeamOverview } from '../_lib/types';
22
+ import {
23
+ formatDateRange,
24
+ formatEnumLabel,
25
+ formatHours,
26
+ getStatusBadgeClass,
27
+ } from '../_lib/utils/format';
28
+
29
+ export default function OperationsTeamPage() {
30
+ const t = useTranslations('operations.TeamPage');
31
+ const commonT = useTranslations('operations.Common');
32
+ const { request, currentLocaleCode } = useApp();
33
+ const access = useOperationsAccess();
34
+
35
+ const { data: team, refetch } = useQuery<OperationsTeamOverview>({
36
+ queryKey: ['operations-team-page', currentLocaleCode],
37
+ enabled: access.isSupervisor,
38
+ queryFn: () => fetchOperations<OperationsTeamOverview>(request, '/operations/team'),
39
+ });
40
+
41
+ if (!access.isSupervisor && !access.isLoading) {
42
+ return (
43
+ <Page>
44
+ <OperationsHeader
45
+ title={t('title')}
46
+ description={t('description')}
47
+ current={t('breadcrumb')}
48
+ />
49
+ <EmptyState
50
+ icon={<Users className="size-12" />}
51
+ title={commonT('states.noAccessTitle')}
52
+ description={t('noAccessDescription')}
53
+ actionLabel={commonT('actions.refresh')}
54
+ onAction={() => void refetch()}
55
+ />
56
+ </Page>
57
+ );
58
+ }
59
+
60
+ const items = [
61
+ {
62
+ key: 'members',
63
+ title: t('cards.members'),
64
+ value: team?.teamMembers.length ?? 0,
65
+ },
66
+ {
67
+ key: 'projects',
68
+ title: t('cards.projects'),
69
+ value: team?.projectCount ?? 0,
70
+ },
71
+ {
72
+ key: 'approvals',
73
+ title: t('cards.pendingApprovals'),
74
+ value: team?.pendingApprovals ?? 0,
75
+ },
76
+ {
77
+ key: 'timeOff',
78
+ title: t('cards.timeOff'),
79
+ value: team?.pendingItems?.timeOffRequests ?? 0,
80
+ },
81
+ {
82
+ key: 'schedule',
83
+ title: t('cards.scheduleAdjustments'),
84
+ value: team?.pendingItems?.scheduleAdjustmentRequests ?? 0,
85
+ },
86
+ {
87
+ key: 'timesheets',
88
+ title: t('cards.timesheets'),
89
+ value: team?.pendingItems?.timesheets ?? 0,
90
+ },
91
+ ];
92
+
93
+ return (
94
+ <Page>
95
+ <OperationsHeader
96
+ title={t('title')}
97
+ description={t('description')}
98
+ current={t('breadcrumb')}
99
+ />
100
+
101
+ <KpiCardsGrid items={items} columns={4} />
102
+
103
+ {team?.teamMembers?.length ? (
104
+ <div className="space-y-6">
105
+ <div className="overflow-x-auto rounded-md border">
106
+ <Table>
107
+ <TableHeader>
108
+ <TableRow>
109
+ <TableHead>{commonT('labels.collaborator')}</TableHead>
110
+ <TableHead>{commonT('labels.department')}</TableHead>
111
+ <TableHead>{commonT('labels.status')}</TableHead>
112
+ <TableHead>{commonT('labels.activeAssignments')}</TableHead>
113
+ <TableHead>{commonT('labels.pendingApprovals')}</TableHead>
114
+ <TableHead>{t('sections.requests')}</TableHead>
115
+ </TableRow>
116
+ </TableHeader>
117
+ <TableBody>
118
+ {team.teamMembers.map((member) => (
119
+ <TableRow key={member.id}>
120
+ <TableCell>
121
+ <div className="font-medium">{member.displayName}</div>
122
+ <div className="text-xs text-muted-foreground">
123
+ {[member.code, formatEnumLabel(member.collaboratorType)]
124
+ .filter(Boolean)
125
+ .join(' • ')}
126
+ </div>
127
+ </TableCell>
128
+ <TableCell>
129
+ {[member.department, member.title]
130
+ .filter(Boolean)
131
+ .join(' • ') || commonT('labels.notAvailable')}
132
+ </TableCell>
133
+ <TableCell>
134
+ <StatusBadge
135
+ label={formatEnumLabel(member.status)}
136
+ className={getStatusBadgeClass(member.status)}
137
+ />
138
+ </TableCell>
139
+ <TableCell>{member.activeAssignments ?? 0}</TableCell>
140
+ <TableCell>{member.pendingApprovals ?? 0}</TableCell>
141
+ <TableCell>
142
+ <div className="text-sm">
143
+ {t('labels.timeOffRequestsCount', {
144
+ count: member.pendingTimeOffRequests ?? 0,
145
+ })}
146
+ </div>
147
+ <div className="text-xs text-muted-foreground">
148
+ {t('labels.scheduleRequestsCount', {
149
+ count: member.pendingScheduleAdjustmentRequests ?? 0,
150
+ })}
151
+ </div>
152
+ </TableCell>
153
+ </TableRow>
154
+ ))}
155
+ </TableBody>
156
+ </Table>
157
+ </div>
158
+
159
+ <div className="grid gap-6 xl:grid-cols-2">
160
+ <Card>
161
+ <CardHeader>
162
+ <CardTitle>{t('sections.pendingApprovals')}</CardTitle>
163
+ </CardHeader>
164
+ <CardContent>
165
+ {team.pendingApprovalQueue.length ? (
166
+ <div className="space-y-3">
167
+ {team.pendingApprovalQueue.map((approval) => (
168
+ <div key={approval.id} className="rounded-lg border p-3">
169
+ <div className="flex items-center justify-between gap-3">
170
+ <div>
171
+ <div className="font-medium">{approval.requesterName}</div>
172
+ <div className="text-xs text-muted-foreground">
173
+ {formatEnumLabel(approval.targetType)}
174
+ </div>
175
+ </div>
176
+ <StatusBadge
177
+ label={formatEnumLabel(approval.status)}
178
+ className={getStatusBadgeClass(approval.status)}
179
+ />
180
+ </div>
181
+ <div className="mt-2 text-sm text-muted-foreground">
182
+ {approval.targetType === 'timesheet'
183
+ ? [
184
+ formatDateRange(
185
+ approval.timesheetWeekStartDate,
186
+ approval.timesheetWeekEndDate
187
+ ),
188
+ approval.timesheetProjectNames,
189
+ formatHours(approval.timesheetTotalHours),
190
+ ]
191
+ .filter(Boolean)
192
+ .join(' • ')
193
+ : approval.targetType === 'time_off_request'
194
+ ? [
195
+ formatEnumLabel(approval.timeOffType),
196
+ formatDateRange(
197
+ approval.timeOffStartDate,
198
+ approval.timeOffEndDate
199
+ ),
200
+ ]
201
+ .filter(Boolean)
202
+ .join(' • ')
203
+ : [
204
+ formatEnumLabel(approval.scheduleRequestScope),
205
+ formatDateRange(
206
+ approval.scheduleStartDate,
207
+ approval.scheduleEndDate
208
+ ),
209
+ ]
210
+ .filter(Boolean)
211
+ .join(' • ')}
212
+ </div>
213
+ </div>
214
+ ))}
215
+ </div>
216
+ ) : (
217
+ <div className="text-sm text-muted-foreground">
218
+ {t('states.noPendingApprovals')}
219
+ </div>
220
+ )}
221
+ </CardContent>
222
+ </Card>
223
+
224
+ <Card>
225
+ <CardHeader>
226
+ <CardTitle>{t('sections.projects')}</CardTitle>
227
+ </CardHeader>
228
+ <CardContent>
229
+ {team.teamProjects.length ? (
230
+ <div className="space-y-3">
231
+ {team.teamProjects.map((project) => (
232
+ <div key={project.id} className="rounded-lg border p-3">
233
+ <div className="flex items-center justify-between gap-3">
234
+ <div>
235
+ <div className="font-medium">{project.name}</div>
236
+ <div className="text-xs text-muted-foreground">
237
+ {[project.code, project.clientName]
238
+ .filter(Boolean)
239
+ .join(' • ')}
240
+ </div>
241
+ </div>
242
+ <StatusBadge
243
+ label={formatEnumLabel(project.status)}
244
+ className={getStatusBadgeClass(project.status)}
245
+ />
246
+ </div>
247
+ <div className="mt-2 text-sm text-muted-foreground">
248
+ {t('labels.projectSummary', {
249
+ teamSize: project.teamSize,
250
+ pendingTimesheets: project.pendingTimesheets,
251
+ })}
252
+ </div>
253
+ </div>
254
+ ))}
255
+ </div>
256
+ ) : (
257
+ <div className="text-sm text-muted-foreground">
258
+ {t('states.noProjects')}
259
+ </div>
260
+ )}
261
+ </CardContent>
262
+ </Card>
263
+ </div>
264
+
265
+ <div className="grid gap-6 xl:grid-cols-2">
266
+ <Card>
267
+ <CardHeader>
268
+ <CardTitle>{t('sections.timeOff')}</CardTitle>
269
+ </CardHeader>
270
+ <CardContent>
271
+ {team.pendingTimeOffRequests.length ? (
272
+ <div className="space-y-3">
273
+ {team.pendingTimeOffRequests.map((requestItem) => (
274
+ <div key={requestItem.id} className="rounded-lg border p-3">
275
+ <div className="font-medium">{requestItem.collaboratorName}</div>
276
+ <div className="text-sm text-muted-foreground">
277
+ {[
278
+ formatEnumLabel(requestItem.requestType),
279
+ formatDateRange(requestItem.startDate, requestItem.endDate),
280
+ ]
281
+ .filter(Boolean)
282
+ .join(' • ')}
283
+ </div>
284
+ </div>
285
+ ))}
286
+ </div>
287
+ ) : (
288
+ <div className="text-sm text-muted-foreground">
289
+ {t('states.noTimeOff')}
290
+ </div>
291
+ )}
292
+ </CardContent>
293
+ </Card>
294
+
295
+ <Card>
296
+ <CardHeader>
297
+ <CardTitle>{t('sections.scheduleAdjustments')}</CardTitle>
298
+ </CardHeader>
299
+ <CardContent>
300
+ {team.pendingScheduleAdjustmentRequests.length ? (
301
+ <div className="space-y-3">
302
+ {team.pendingScheduleAdjustmentRequests.map((requestItem) => (
303
+ <div key={requestItem.id} className="rounded-lg border p-3">
304
+ <div className="font-medium">{requestItem.collaboratorName}</div>
305
+ <div className="text-sm text-muted-foreground">
306
+ {[
307
+ formatEnumLabel(requestItem.requestScope),
308
+ formatDateRange(
309
+ requestItem.effectiveStartDate,
310
+ requestItem.effectiveEndDate
311
+ ),
312
+ ]
313
+ .filter(Boolean)
314
+ .join(' • ')}
315
+ </div>
316
+ </div>
317
+ ))}
318
+ </div>
319
+ ) : (
320
+ <div className="text-sm text-muted-foreground">
321
+ {t('states.noScheduleAdjustments')}
322
+ </div>
323
+ )}
324
+ </CardContent>
325
+ </Card>
326
+ </div>
327
+ </div>
328
+ ) : (
329
+ <EmptyState
330
+ icon={<Users className="size-12" />}
331
+ title={commonT('states.emptyTitle')}
332
+ description={t('emptyDescription')}
333
+ actionLabel={commonT('actions.refresh')}
334
+ onAction={() => void refetch()}
335
+ />
336
+ )}
337
+ </Page>
338
+ );
339
+ }
@@ -0,0 +1,328 @@
1
+ 'use client';
2
+
3
+ import { EmptyState, Page, SearchBar } from '@/components/entity-list';
4
+ import { Button } from '@/components/ui/button';
5
+ import { Input } from '@/components/ui/input';
6
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
7
+ import {
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from '@/components/ui/select';
14
+ import {
15
+ Sheet,
16
+ SheetContent,
17
+ SheetDescription,
18
+ SheetHeader,
19
+ SheetTitle,
20
+ } from '@/components/ui/sheet';
21
+ import {
22
+ Table,
23
+ TableBody,
24
+ TableCell,
25
+ TableHead,
26
+ TableHeader,
27
+ TableRow,
28
+ } from '@/components/ui/table';
29
+ import { Textarea } from '@/components/ui/textarea';
30
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
31
+ import { Palmtree, Plus } from 'lucide-react';
32
+ import { useMemo, useState } from 'react';
33
+ import { useTranslations } from 'next-intl';
34
+ import { OperationsHeader } from '../_components/operations-header';
35
+ import { StatusBadge } from '../_components/status-badge';
36
+ import { fetchOperations, mutateOperations } from '../_lib/api';
37
+ import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
38
+ import type { OperationsTimeOffRequest } from '../_lib/types';
39
+ import { parseNumberInput, trimToNull } from '../_lib/utils/forms';
40
+ import {
41
+ formatDateRange,
42
+ formatEnumLabel,
43
+ getStatusBadgeClass,
44
+ } from '../_lib/utils/format';
45
+
46
+ type TimeOffFormState = {
47
+ requestType: string;
48
+ startDate: string;
49
+ endDate: string;
50
+ totalDays: string;
51
+ reason: string;
52
+ };
53
+
54
+ const emptyForm: TimeOffFormState = {
55
+ requestType: 'vacation',
56
+ startDate: '',
57
+ endDate: '',
58
+ totalDays: '',
59
+ reason: '',
60
+ };
61
+
62
+ export default function OperationsTimeOffPage() {
63
+ const t = useTranslations('operations.TimeOffPage');
64
+ const commonT = useTranslations('operations.Common');
65
+ const { request, showToastHandler, currentLocaleCode } = useApp();
66
+ const access = useOperationsAccess();
67
+ const [search, setSearch] = useState('');
68
+ const [statusFilter, setStatusFilter] = useState('all');
69
+ const [isSheetOpen, setIsSheetOpen] = useState(false);
70
+ const [form, setForm] = useState<TimeOffFormState>(emptyForm);
71
+
72
+ const { data: requests = [], refetch } = useQuery<OperationsTimeOffRequest[]>({
73
+ queryKey: ['operations-time-off', currentLocaleCode],
74
+ enabled: access.isCollaborator,
75
+ queryFn: () =>
76
+ fetchOperations<OperationsTimeOffRequest[]>(request, '/operations/time-off'),
77
+ });
78
+
79
+ const filteredRows = useMemo(
80
+ () =>
81
+ requests.filter((item) => {
82
+ const matchesSearch = !search.trim()
83
+ ? true
84
+ : [item.collaboratorName, item.approverName, item.reason, item.requestType]
85
+ .filter(Boolean)
86
+ .some((value) =>
87
+ String(value).toLowerCase().includes(search.trim().toLowerCase())
88
+ );
89
+ const matchesStatus =
90
+ statusFilter === 'all' ? true : item.status === statusFilter;
91
+ return matchesSearch && matchesStatus;
92
+ }),
93
+ [requests, search, statusFilter]
94
+ );
95
+
96
+ const cards = [
97
+ {
98
+ key: 'submitted',
99
+ title: t('cards.submitted'),
100
+ value: requests.filter((item) => item.status === 'submitted').length,
101
+ },
102
+ {
103
+ key: 'approved',
104
+ title: t('cards.approved'),
105
+ value: requests.filter((item) => item.status === 'approved').length,
106
+ },
107
+ {
108
+ key: 'days',
109
+ title: t('cards.days'),
110
+ value: requests.reduce(
111
+ (total, item) => total + Number(item.totalDays ?? 0),
112
+ 0
113
+ ),
114
+ },
115
+ ];
116
+
117
+ const onSubmit = async () => {
118
+ if (!form.startDate || !form.endDate) {
119
+ showToastHandler?.('error', t('messages.requiredFields'));
120
+ return;
121
+ }
122
+
123
+ try {
124
+ await mutateOperations(request, '/operations/time-off', 'POST', {
125
+ requestType: form.requestType,
126
+ startDate: form.startDate,
127
+ endDate: form.endDate,
128
+ totalDays: parseNumberInput(form.totalDays),
129
+ reason: trimToNull(form.reason),
130
+ });
131
+
132
+ showToastHandler?.('success', t('messages.saveSuccess'));
133
+ setIsSheetOpen(false);
134
+ setForm(emptyForm);
135
+ await refetch();
136
+ } catch {
137
+ showToastHandler?.('error', t('messages.saveError'));
138
+ }
139
+ };
140
+
141
+ return (
142
+ <Page>
143
+ <OperationsHeader
144
+ title={t('title')}
145
+ description={t('description')}
146
+ current={t('breadcrumb')}
147
+ actions={
148
+ access.isCollaborator ? (
149
+ <Button size="sm" onClick={() => setIsSheetOpen(true)}>
150
+ <Plus className="size-4" />
151
+ {commonT('actions.create')}
152
+ </Button>
153
+ ) : undefined
154
+ }
155
+ />
156
+
157
+ <SearchBar
158
+ searchQuery={search}
159
+ onSearchChange={setSearch}
160
+ onSearch={() => undefined}
161
+ placeholder={t('searchPlaceholder')}
162
+ controls={[
163
+ {
164
+ id: 'status',
165
+ type: 'select',
166
+ value: statusFilter,
167
+ onChange: setStatusFilter,
168
+ placeholder: commonT('labels.status'),
169
+ options: [
170
+ { value: 'all', label: commonT('filters.allStatuses') },
171
+ { value: 'submitted', label: formatEnumLabel('submitted') },
172
+ { value: 'approved', label: formatEnumLabel('approved') },
173
+ { value: 'rejected', label: formatEnumLabel('rejected') },
174
+ ],
175
+ },
176
+ ]}
177
+ />
178
+
179
+ <KpiCardsGrid items={cards} columns={3} />
180
+
181
+ {filteredRows.length > 0 ? (
182
+ <div className="overflow-x-auto rounded-md border">
183
+ <Table>
184
+ <TableHeader>
185
+ <TableRow>
186
+ <TableHead>{commonT('labels.collaborator')}</TableHead>
187
+ <TableHead>{commonT('labels.requestType')}</TableHead>
188
+ <TableHead>{commonT('labels.timeline')}</TableHead>
189
+ <TableHead>{commonT('labels.approver')}</TableHead>
190
+ <TableHead>{commonT('labels.status')}</TableHead>
191
+ <TableHead>{commonT('labels.reason')}</TableHead>
192
+ <TableHead>{commonT('labels.notes')}</TableHead>
193
+ </TableRow>
194
+ </TableHeader>
195
+ <TableBody>
196
+ {filteredRows.map((requestItem) => (
197
+ <TableRow key={requestItem.id}>
198
+ <TableCell>{requestItem.collaboratorName}</TableCell>
199
+ <TableCell>{formatEnumLabel(requestItem.requestType)}</TableCell>
200
+ <TableCell>
201
+ <div>
202
+ {formatDateRange(requestItem.startDate, requestItem.endDate)}
203
+ </div>
204
+ <div className="text-xs text-muted-foreground">
205
+ {requestItem.totalDays ?? 0} {commonT('labels.days')}
206
+ </div>
207
+ </TableCell>
208
+ <TableCell>
209
+ {requestItem.approverName || commonT('labels.notAssigned')}
210
+ </TableCell>
211
+ <TableCell>
212
+ <StatusBadge
213
+ label={formatEnumLabel(requestItem.status)}
214
+ className={getStatusBadgeClass(requestItem.status)}
215
+ />
216
+ </TableCell>
217
+ <TableCell>
218
+ {requestItem.reason || commonT('labels.noNotes')}
219
+ </TableCell>
220
+ <TableCell>
221
+ {requestItem.approverNote || commonT('labels.noNotes')}
222
+ </TableCell>
223
+ </TableRow>
224
+ ))}
225
+ </TableBody>
226
+ </Table>
227
+ </div>
228
+ ) : (
229
+ <EmptyState
230
+ icon={<Palmtree className="size-12" />}
231
+ title={commonT('states.emptyTitle')}
232
+ description={t('emptyDescription')}
233
+ actionLabel={access.isCollaborator ? commonT('actions.create') : commonT('actions.refresh')}
234
+ onAction={access.isCollaborator ? () => setIsSheetOpen(true) : () => void refetch()}
235
+ />
236
+ )}
237
+
238
+ <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
239
+ <SheetContent className="w-full overflow-y-auto sm:max-w-xl">
240
+ <SheetHeader>
241
+ <SheetTitle>{t('sheet.title')}</SheetTitle>
242
+ <SheetDescription>{t('sheet.description')}</SheetDescription>
243
+ </SheetHeader>
244
+
245
+ <div className="mt-6 grid gap-4">
246
+ <div className="space-y-2">
247
+ <label className="text-sm font-medium">{commonT('labels.requestType')}</label>
248
+ <Select
249
+ value={form.requestType}
250
+ onValueChange={(value) =>
251
+ setForm((current) => ({ ...current, requestType: value }))
252
+ }
253
+ >
254
+ <SelectTrigger>
255
+ <SelectValue />
256
+ </SelectTrigger>
257
+ <SelectContent>
258
+ <SelectItem value="vacation">Vacation</SelectItem>
259
+ <SelectItem value="personal_time">Personal Time</SelectItem>
260
+ <SelectItem value="sick_leave">Sick Leave</SelectItem>
261
+ <SelectItem value="unpaid_leave">Unpaid Leave</SelectItem>
262
+ <SelectItem value="other">Other</SelectItem>
263
+ </SelectContent>
264
+ </Select>
265
+ </div>
266
+
267
+ <div className="grid gap-4 md:grid-cols-2">
268
+ <div className="space-y-2">
269
+ <label className="text-sm font-medium">{commonT('labels.startDate')}</label>
270
+ <Input
271
+ type="date"
272
+ value={form.startDate}
273
+ onChange={(event) =>
274
+ setForm((current) => ({
275
+ ...current,
276
+ startDate: event.target.value,
277
+ }))
278
+ }
279
+ />
280
+ </div>
281
+ <div className="space-y-2">
282
+ <label className="text-sm font-medium">{commonT('labels.endDate')}</label>
283
+ <Input
284
+ type="date"
285
+ value={form.endDate}
286
+ onChange={(event) =>
287
+ setForm((current) => ({
288
+ ...current,
289
+ endDate: event.target.value,
290
+ }))
291
+ }
292
+ />
293
+ </div>
294
+ </div>
295
+
296
+ <div className="space-y-2">
297
+ <label className="text-sm font-medium">{commonT('labels.days')}</label>
298
+ <Input
299
+ type="number"
300
+ step="0.5"
301
+ value={form.totalDays}
302
+ onChange={(event) =>
303
+ setForm((current) => ({
304
+ ...current,
305
+ totalDays: event.target.value,
306
+ }))
307
+ }
308
+ />
309
+ </div>
310
+
311
+ <div className="space-y-2">
312
+ <label className="text-sm font-medium">{commonT('labels.reason')}</label>
313
+ <Textarea
314
+ rows={4}
315
+ value={form.reason}
316
+ onChange={(event) =>
317
+ setForm((current) => ({ ...current, reason: event.target.value }))
318
+ }
319
+ />
320
+ </div>
321
+
322
+ <Button onClick={() => void onSubmit()}>{commonT('actions.save')}</Button>
323
+ </div>
324
+ </SheetContent>
325
+ </Sheet>
326
+ </Page>
327
+ );
328
+ }