@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
@@ -1,126 +1,636 @@
1
- 'use client';
2
-
3
- import { Page } from '@/components/entity-list';
4
- import { Input } from '@/components/ui/input';
5
- import {
6
- Table,
7
- TableBody,
8
- TableCell,
9
- TableHead,
10
- TableHeader,
11
- TableRow,
12
- } from '@/components/ui/table';
13
- import { useTranslations } from 'next-intl';
14
- import { useMemo, useState } from 'react';
15
- import { OperationsHeader } from '../_components/operations-header';
16
- import { SectionCard } from '../_components/section-card';
17
- import { StatusBadge } from '../_components/status-badge';
18
- import { TimesheetEntryDialog } from '../_components/timesheet-entry-dialog';
19
- import { useOperationsData } from '../_lib/hooks/use-operations-data';
20
- import { formatDate, formatHours } from '../_lib/utils/format';
21
- import { getApprovalBadgeClasses, getApprovalLabel } from '../_lib/utils/status';
22
-
23
- export default function TimesheetsPage() {
24
- const t = useTranslations('operations.TimesheetsPage');
25
- const { timesheets, users, projects, tasks, dailyTotals } =
26
- useOperationsData();
27
- const [search, setSearch] = useState('');
28
-
29
- const filteredTimesheets = useMemo(
30
- () =>
31
- timesheets.filter((entry) => {
32
- const user = users.find((item) => item.id === entry.userId);
33
- const project = projects.find((item) => item.id === entry.projectId);
34
- const task = tasks.find((item) => item.id === entry.taskId);
35
-
36
- return `${user?.name} ${project?.name} ${task?.title} ${entry.description}`
37
- .toLowerCase()
38
- .includes(search.toLowerCase());
39
- }),
40
- [timesheets, users, projects, tasks, search]
41
- );
42
-
43
- const weeklyTotal = filteredTimesheets.reduce(
44
- (sum, entry) => sum + entry.hours,
45
- 0
46
- );
47
-
48
- return (
49
- <Page>
50
- <OperationsHeader
51
- title={t('title')}
52
- description={t('description')}
53
- current={t('breadcrumb')}
54
- actions={
55
- <TimesheetEntryDialog users={users} projects={projects} tasks={tasks} />
56
- }
57
- />
58
-
59
- <div className="grid gap-4 md:grid-cols-3">
60
- <SectionCard title={t('summary.totalHours')}>
61
- <p className="text-3xl font-semibold">{formatHours(weeklyTotal)}</p>
62
- </SectionCard>
63
- <SectionCard title={t('summary.dailyTotals')}>
64
- <div className="space-y-2 text-sm">
65
- {Object.entries(dailyTotals).map(([date, total]) => (
66
- <div key={date} className="flex items-center justify-between">
67
- <span>{formatDate(date)}</span>
68
- <span>{formatHours(total)}</span>
69
- </div>
70
- ))}
71
- </div>
72
- </SectionCard>
73
- <SectionCard title={t('summary.weeklyTotal')}>
74
- <p className="text-3xl font-semibold">{formatHours(weeklyTotal)}</p>
75
- </SectionCard>
76
- </div>
77
-
78
- <SectionCard title={t('gridTitle')} description={t('gridDescription')}>
79
- <div className="mb-4">
80
- <Input
81
- value={search}
82
- onChange={(event) => setSearch(event.target.value)}
83
- placeholder={t('searchPlaceholder')}
84
- />
85
- </div>
86
- <Table>
87
- <TableHeader>
88
- <TableRow>
89
- <TableHead>{t('columns.date')}</TableHead>
90
- <TableHead>{t('columns.user')}</TableHead>
91
- <TableHead>{t('columns.project')}</TableHead>
92
- <TableHead>{t('columns.task')}</TableHead>
93
- <TableHead>{t('columns.hours')}</TableHead>
94
- <TableHead>{t('columns.description')}</TableHead>
95
- <TableHead>{t('columns.status')}</TableHead>
96
- </TableRow>
97
- </TableHeader>
98
- <TableBody>
99
- {filteredTimesheets.map((entry) => {
100
- const user = users.find((item) => item.id === entry.userId);
101
- const project = projects.find((item) => item.id === entry.projectId);
102
- const task = tasks.find((item) => item.id === entry.taskId);
103
-
104
- return (
105
- <TableRow key={entry.id}>
106
- <TableCell>{formatDate(entry.date)}</TableCell>
107
- <TableCell>{user?.name}</TableCell>
108
- <TableCell>{project?.name}</TableCell>
109
- <TableCell>{task?.title}</TableCell>
110
- <TableCell>{formatHours(entry.hours)}</TableCell>
111
- <TableCell>{entry.description}</TableCell>
112
- <TableCell>
113
- <StatusBadge
114
- label={getApprovalLabel(entry.status)}
115
- className={getApprovalBadgeClasses(entry.status)}
116
- />
117
- </TableCell>
118
- </TableRow>
119
- );
120
- })}
121
- </TableBody>
122
- </Table>
123
- </SectionCard>
124
- </Page>
125
- );
126
- }
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 { ClipboardList, Pencil, Plus, Send, Trash2 } 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 {
39
+ OperationsCollaborator,
40
+ OperationsProject,
41
+ OperationsTimesheet,
42
+ } from '../_lib/types';
43
+ import { parseNumberInput, trimToNull } from '../_lib/utils/forms';
44
+ import {
45
+ formatDateRange,
46
+ formatEnumLabel,
47
+ formatHours,
48
+ getStatusBadgeClass,
49
+ } from '../_lib/utils/format';
50
+
51
+ type TimesheetEntryFormState = {
52
+ projectAssignmentId: string;
53
+ activityLabel: string;
54
+ workDate: string;
55
+ hours: string;
56
+ description: string;
57
+ };
58
+
59
+ type TimesheetFormState = {
60
+ weekStartDate: string;
61
+ weekEndDate: string;
62
+ notes: string;
63
+ entries: TimesheetEntryFormState[];
64
+ };
65
+
66
+ const createEmptyEntry = (): TimesheetEntryFormState => ({
67
+ projectAssignmentId: 'none',
68
+ activityLabel: '',
69
+ workDate: '',
70
+ hours: '',
71
+ description: '',
72
+ });
73
+
74
+ const emptyForm: TimesheetFormState = {
75
+ weekStartDate: '',
76
+ weekEndDate: '',
77
+ notes: '',
78
+ entries: [createEmptyEntry()],
79
+ };
80
+
81
+ function toFormState(timesheet?: OperationsTimesheet | null): TimesheetFormState {
82
+ if (!timesheet) {
83
+ return emptyForm;
84
+ }
85
+
86
+ return {
87
+ weekStartDate: timesheet.weekStartDate ?? '',
88
+ weekEndDate: timesheet.weekEndDate ?? '',
89
+ notes: timesheet.notes ?? '',
90
+ entries:
91
+ timesheet.entries?.length
92
+ ? timesheet.entries.map((entry) => ({
93
+ projectAssignmentId: entry.projectAssignmentId
94
+ ? String(entry.projectAssignmentId)
95
+ : 'none',
96
+ activityLabel: entry.activityLabel ?? '',
97
+ workDate: entry.workDate ?? '',
98
+ hours:
99
+ entry.hours !== null && entry.hours !== undefined
100
+ ? String(entry.hours)
101
+ : '',
102
+ description: entry.description ?? '',
103
+ }))
104
+ : [createEmptyEntry()],
105
+ };
106
+ }
107
+
108
+ export default function OperationsTimesheetsPage() {
109
+ const t = useTranslations('operations.TimesheetsPage');
110
+ const commonT = useTranslations('operations.Common');
111
+ const { request, showToastHandler, currentLocaleCode } = useApp();
112
+ const access = useOperationsAccess();
113
+ const [search, setSearch] = useState('');
114
+ const [statusFilter, setStatusFilter] = useState('all');
115
+ const [isSheetOpen, setIsSheetOpen] = useState(false);
116
+ const [editingTimesheet, setEditingTimesheet] = useState<OperationsTimesheet | null>(
117
+ null
118
+ );
119
+ const [form, setForm] = useState<TimesheetFormState>(emptyForm);
120
+
121
+ const { data: timesheets = [], refetch } = useQuery<OperationsTimesheet[]>({
122
+ queryKey: ['operations-timesheets', currentLocaleCode],
123
+ queryFn: () =>
124
+ fetchOperations<OperationsTimesheet[]>(request, '/operations/timesheets'),
125
+ });
126
+
127
+ const { data: me } = useQuery<OperationsCollaborator>({
128
+ queryKey: ['operations-timesheets-me', currentLocaleCode],
129
+ enabled: access.isCollaborator,
130
+ queryFn: () =>
131
+ fetchOperations<OperationsCollaborator>(request, '/operations/collaborators/me'),
132
+ });
133
+
134
+ const { data: projects = [] } = useQuery<OperationsProject[]>({
135
+ queryKey: ['operations-timesheet-project-options', currentLocaleCode],
136
+ enabled: access.isCollaborator,
137
+ queryFn: () => fetchOperations<OperationsProject[]>(request, '/operations/projects'),
138
+ });
139
+
140
+ const projectOptions = useMemo(
141
+ () =>
142
+ projects
143
+ .filter((project) => project.myAssignmentId)
144
+ .map((project) => ({
145
+ value: String(project.myAssignmentId),
146
+ label: [project.name, project.myRoleLabel].filter(Boolean).join(' • '),
147
+ })),
148
+ [projects]
149
+ );
150
+
151
+ const filteredRows = useMemo(
152
+ () =>
153
+ timesheets.filter((item) => {
154
+ const matchesSearch = !search.trim()
155
+ ? true
156
+ : [
157
+ item.collaboratorName,
158
+ item.approverName,
159
+ item.notes,
160
+ ...((item.entries ?? []).flatMap((entry) => [
161
+ entry.projectName,
162
+ entry.description,
163
+ ]) as Array<string | undefined>),
164
+ ]
165
+ .filter(Boolean)
166
+ .some((value) =>
167
+ String(value).toLowerCase().includes(search.trim().toLowerCase())
168
+ );
169
+ const matchesStatus =
170
+ statusFilter === 'all' ? true : item.status === statusFilter;
171
+ return matchesSearch && matchesStatus;
172
+ }),
173
+ [timesheets, search, statusFilter]
174
+ );
175
+
176
+ const cards = [
177
+ {
178
+ key: 'all',
179
+ title: t('cards.visible'),
180
+ value: timesheets.length,
181
+ },
182
+ {
183
+ key: 'pending',
184
+ title: t('cards.pending'),
185
+ value: timesheets.filter((item) => item.status === 'submitted').length,
186
+ },
187
+ {
188
+ key: 'hours',
189
+ title: t('cards.hours'),
190
+ value: formatHours(
191
+ timesheets.reduce(
192
+ (total, item) => total + Number(item.totalHours ?? 0),
193
+ 0
194
+ )
195
+ ),
196
+ },
197
+ ];
198
+
199
+ const openCreate = () => {
200
+ setEditingTimesheet(null);
201
+ setForm(emptyForm);
202
+ setIsSheetOpen(true);
203
+ };
204
+
205
+ const openEdit = (timesheet: OperationsTimesheet) => {
206
+ setEditingTimesheet(timesheet);
207
+ setForm(toFormState(timesheet));
208
+ setIsSheetOpen(true);
209
+ };
210
+
211
+ const canManageRow = (timesheet: OperationsTimesheet) => {
212
+ return Boolean(
213
+ me?.id &&
214
+ timesheet.collaboratorId === me.id &&
215
+ ['draft', 'rejected'].includes(timesheet.status)
216
+ );
217
+ };
218
+
219
+ const updateEntry = (
220
+ index: number,
221
+ patch: Partial<TimesheetEntryFormState>
222
+ ) => {
223
+ setForm((current) => ({
224
+ ...current,
225
+ entries: current.entries.map((entry, entryIndex) =>
226
+ entryIndex === index ? { ...entry, ...patch } : entry
227
+ ),
228
+ }));
229
+ };
230
+
231
+ const addEntry = () => {
232
+ setForm((current) => ({
233
+ ...current,
234
+ entries: [...current.entries, createEmptyEntry()],
235
+ }));
236
+ };
237
+
238
+ const removeEntry = (index: number) => {
239
+ setForm((current) => ({
240
+ ...current,
241
+ entries:
242
+ current.entries.length === 1
243
+ ? [createEmptyEntry()]
244
+ : current.entries.filter((_, entryIndex) => entryIndex !== index),
245
+ }));
246
+ };
247
+
248
+ const onSubmit = async () => {
249
+ if (!form.weekStartDate || !form.weekEndDate) {
250
+ showToastHandler?.('error', t('messages.requiredFields'));
251
+ return;
252
+ }
253
+
254
+ const payload = {
255
+ weekStartDate: form.weekStartDate,
256
+ weekEndDate: form.weekEndDate,
257
+ notes: trimToNull(form.notes),
258
+ entries: form.entries
259
+ .filter((entry) => entry.workDate || entry.hours || entry.description)
260
+ .map((entry) => ({
261
+ projectAssignmentId:
262
+ entry.projectAssignmentId === 'none'
263
+ ? null
264
+ : parseNumberInput(entry.projectAssignmentId),
265
+ activityLabel: trimToNull(entry.activityLabel),
266
+ workDate: entry.workDate,
267
+ hours: parseNumberInput(entry.hours) ?? 0,
268
+ description: trimToNull(entry.description),
269
+ })),
270
+ };
271
+
272
+ if (payload.entries.some((entry) => !entry.workDate || !entry.hours)) {
273
+ showToastHandler?.('error', t('messages.entryValidation'));
274
+ return;
275
+ }
276
+
277
+ try {
278
+ if (editingTimesheet) {
279
+ await mutateOperations(
280
+ request,
281
+ `/operations/timesheets/${editingTimesheet.id}`,
282
+ 'PATCH',
283
+ payload
284
+ );
285
+ } else {
286
+ await mutateOperations(request, '/operations/timesheets', 'POST', payload);
287
+ }
288
+
289
+ showToastHandler?.('success', t('messages.saveSuccess'));
290
+ setIsSheetOpen(false);
291
+ setEditingTimesheet(null);
292
+ setForm(emptyForm);
293
+ await refetch();
294
+ } catch {
295
+ showToastHandler?.('error', t('messages.saveError'));
296
+ }
297
+ };
298
+
299
+ const submitTimesheet = async (timesheetId: number) => {
300
+ try {
301
+ await request({
302
+ url: `/operations/timesheets/${timesheetId}/submit`,
303
+ method: 'POST',
304
+ });
305
+ showToastHandler?.('success', t('messages.submitSuccess'));
306
+ await refetch();
307
+ } catch {
308
+ showToastHandler?.('error', t('messages.submitError'));
309
+ }
310
+ };
311
+
312
+ return (
313
+ <Page>
314
+ <OperationsHeader
315
+ title={t('title')}
316
+ description={t('description')}
317
+ current={t('breadcrumb')}
318
+ actions={
319
+ access.isCollaborator ? (
320
+ <Button size="sm" onClick={openCreate}>
321
+ <Plus className="size-4" />
322
+ {commonT('actions.create')}
323
+ </Button>
324
+ ) : undefined
325
+ }
326
+ />
327
+
328
+ <SearchBar
329
+ searchQuery={search}
330
+ onSearchChange={setSearch}
331
+ onSearch={() => undefined}
332
+ placeholder={t('searchPlaceholder')}
333
+ controls={[
334
+ {
335
+ id: 'status',
336
+ type: 'select',
337
+ value: statusFilter,
338
+ onChange: setStatusFilter,
339
+ placeholder: commonT('labels.status'),
340
+ options: [
341
+ { value: 'all', label: commonT('filters.allStatuses') },
342
+ { value: 'draft', label: formatEnumLabel('draft') },
343
+ { value: 'submitted', label: formatEnumLabel('submitted') },
344
+ { value: 'approved', label: formatEnumLabel('approved') },
345
+ { value: 'rejected', label: formatEnumLabel('rejected') },
346
+ ],
347
+ },
348
+ ]}
349
+ />
350
+
351
+ <KpiCardsGrid items={cards} columns={3} />
352
+
353
+ {filteredRows.length > 0 ? (
354
+ <div className="overflow-x-auto rounded-md border">
355
+ <Table>
356
+ <TableHeader>
357
+ <TableRow>
358
+ <TableHead>{commonT('labels.collaborator')}</TableHead>
359
+ <TableHead>{commonT('labels.week')}</TableHead>
360
+ <TableHead>{commonT('labels.entries')}</TableHead>
361
+ <TableHead>{commonT('labels.totalHours')}</TableHead>
362
+ <TableHead>{commonT('labels.approver')}</TableHead>
363
+ <TableHead>{commonT('labels.timeline')}</TableHead>
364
+ <TableHead>{commonT('labels.status')}</TableHead>
365
+ <TableHead>{commonT('labels.actions')}</TableHead>
366
+ </TableRow>
367
+ </TableHeader>
368
+ <TableBody>
369
+ {filteredRows.map((timesheet) => (
370
+ <TableRow key={timesheet.id}>
371
+ <TableCell>
372
+ <div className="font-medium">{timesheet.collaboratorName}</div>
373
+ <div className="text-xs text-muted-foreground">
374
+ {timesheet.notes || commonT('labels.noNotes')}
375
+ </div>
376
+ </TableCell>
377
+ <TableCell>
378
+ {formatDateRange(
379
+ timesheet.weekStartDate,
380
+ timesheet.weekEndDate
381
+ )}
382
+ </TableCell>
383
+ <TableCell>
384
+ <div className="font-medium">
385
+ {timesheet.entries?.length ?? 0} {commonT('labels.lines')}
386
+ </div>
387
+ <div className="text-xs text-muted-foreground">
388
+ {(timesheet.entries ?? [])
389
+ .slice(0, 2)
390
+ .map((entry) =>
391
+ [entry.projectName, entry.activityLabel]
392
+ .filter(Boolean)
393
+ .join(' • ') || commonT('labels.unassigned')
394
+ )
395
+ .join(', ') || commonT('labels.unassigned')}
396
+ </div>
397
+ </TableCell>
398
+ <TableCell>{formatHours(timesheet.totalHours)}</TableCell>
399
+ <TableCell>
400
+ {timesheet.approverName || commonT('labels.notAssigned')}
401
+ </TableCell>
402
+ <TableCell>
403
+ <div>{formatDateRange(timesheet.weekStartDate, timesheet.weekEndDate)}</div>
404
+ <div className="text-xs text-muted-foreground">
405
+ {timesheet.decisionNote || commonT('labels.noNotes')}
406
+ </div>
407
+ </TableCell>
408
+ <TableCell>
409
+ <StatusBadge
410
+ label={formatEnumLabel(timesheet.status)}
411
+ className={getStatusBadgeClass(timesheet.status)}
412
+ />
413
+ </TableCell>
414
+ <TableCell>
415
+ <div className="flex justify-end gap-2">
416
+ {canManageRow(timesheet) ? (
417
+ <>
418
+ <Button
419
+ variant="outline"
420
+ size="icon"
421
+ onClick={() => openEdit(timesheet)}
422
+ >
423
+ <Pencil className="size-4" />
424
+ </Button>
425
+ <Button
426
+ size="icon"
427
+ onClick={() => void submitTimesheet(timesheet.id)}
428
+ >
429
+ <Send className="size-4" />
430
+ </Button>
431
+ </>
432
+ ) : (
433
+ <span className="text-xs text-muted-foreground">
434
+ {commonT('labels.viewOnly')}
435
+ </span>
436
+ )}
437
+ </div>
438
+ </TableCell>
439
+ </TableRow>
440
+ ))}
441
+ </TableBody>
442
+ </Table>
443
+ </div>
444
+ ) : (
445
+ <EmptyState
446
+ icon={<ClipboardList className="size-12" />}
447
+ title={commonT('states.emptyTitle')}
448
+ description={t('emptyDescription')}
449
+ actionLabel={access.isCollaborator ? commonT('actions.create') : commonT('actions.refresh')}
450
+ onAction={access.isCollaborator ? openCreate : () => void refetch()}
451
+ />
452
+ )}
453
+
454
+ <Sheet
455
+ open={isSheetOpen}
456
+ onOpenChange={(open) => {
457
+ setIsSheetOpen(open);
458
+ if (!open) {
459
+ setEditingTimesheet(null);
460
+ setForm(emptyForm);
461
+ }
462
+ }}
463
+ >
464
+ <SheetContent className="w-full overflow-y-auto sm:max-w-3xl">
465
+ <SheetHeader>
466
+ <SheetTitle>
467
+ {editingTimesheet ? t('sheet.editTitle') : t('sheet.createTitle')}
468
+ </SheetTitle>
469
+ <SheetDescription>{t('sheet.description')}</SheetDescription>
470
+ </SheetHeader>
471
+
472
+ <div className="mt-6 grid gap-4">
473
+ <div className="grid gap-4 md:grid-cols-2">
474
+ <div className="space-y-2">
475
+ <label className="text-sm font-medium">{commonT('labels.weekStart')}</label>
476
+ <Input
477
+ type="date"
478
+ value={form.weekStartDate}
479
+ onChange={(event) =>
480
+ setForm((current) => ({
481
+ ...current,
482
+ weekStartDate: event.target.value,
483
+ }))
484
+ }
485
+ />
486
+ </div>
487
+ <div className="space-y-2">
488
+ <label className="text-sm font-medium">{commonT('labels.weekEnd')}</label>
489
+ <Input
490
+ type="date"
491
+ value={form.weekEndDate}
492
+ onChange={(event) =>
493
+ setForm((current) => ({
494
+ ...current,
495
+ weekEndDate: event.target.value,
496
+ }))
497
+ }
498
+ />
499
+ </div>
500
+ </div>
501
+
502
+ <div className="space-y-2">
503
+ <label className="text-sm font-medium">{commonT('labels.notes')}</label>
504
+ <Textarea
505
+ rows={3}
506
+ value={form.notes}
507
+ onChange={(event) =>
508
+ setForm((current) => ({ ...current, notes: event.target.value }))
509
+ }
510
+ />
511
+ </div>
512
+
513
+ <div className="space-y-3">
514
+ <div className="flex items-center justify-between">
515
+ <div>
516
+ <div className="text-sm font-medium">{t('entries.title')}</div>
517
+ <div className="text-xs text-muted-foreground">
518
+ {projectOptions.length > 0
519
+ ? t('entries.description')
520
+ : t('entries.projectHint')}
521
+ </div>
522
+ </div>
523
+ <Button variant="outline" size="sm" onClick={addEntry}>
524
+ <Plus className="size-4" />
525
+ {commonT('actions.addLine')}
526
+ </Button>
527
+ </div>
528
+
529
+ <div className="space-y-3">
530
+ {form.entries.map((entry, index) => (
531
+ <div
532
+ key={index}
533
+ className="grid gap-3 rounded-lg border p-4 lg:grid-cols-[1.4fr_1fr_1fr_0.8fr_auto]"
534
+ >
535
+ <div className="space-y-3">
536
+ <div className="space-y-2">
537
+ <label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
538
+ {commonT('labels.projectAssignment')}
539
+ </label>
540
+ <Select
541
+ value={entry.projectAssignmentId}
542
+ onValueChange={(value) =>
543
+ updateEntry(index, { projectAssignmentId: value })
544
+ }
545
+ >
546
+ <SelectTrigger>
547
+ <SelectValue />
548
+ </SelectTrigger>
549
+ <SelectContent>
550
+ <SelectItem value="none">
551
+ {commonT('labels.unassigned')}
552
+ </SelectItem>
553
+ {projectOptions.map((option) => (
554
+ <SelectItem key={option.value} value={option.value}>
555
+ {option.label}
556
+ </SelectItem>
557
+ ))}
558
+ </SelectContent>
559
+ </Select>
560
+ </div>
561
+
562
+ <div className="space-y-2">
563
+ <label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
564
+ {commonT('labels.activity')}
565
+ </label>
566
+ <Input
567
+ value={entry.activityLabel}
568
+ onChange={(event) =>
569
+ updateEntry(index, { activityLabel: event.target.value })
570
+ }
571
+ placeholder={t('entries.activityPlaceholder')}
572
+ />
573
+ </div>
574
+
575
+ <div className="space-y-2">
576
+ <label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
577
+ {commonT('labels.description')}
578
+ </label>
579
+ <Textarea
580
+ rows={2}
581
+ value={entry.description}
582
+ onChange={(event) =>
583
+ updateEntry(index, { description: event.target.value })
584
+ }
585
+ />
586
+ </div>
587
+ </div>
588
+
589
+ <div className="space-y-2">
590
+ <label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
591
+ {commonT('labels.workDate')}
592
+ </label>
593
+ <Input
594
+ type="date"
595
+ value={entry.workDate}
596
+ onChange={(event) =>
597
+ updateEntry(index, { workDate: event.target.value })
598
+ }
599
+ />
600
+ </div>
601
+
602
+ <div className="space-y-2">
603
+ <label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
604
+ {commonT('labels.hours')}
605
+ </label>
606
+ <Input
607
+ type="number"
608
+ step="0.5"
609
+ value={entry.hours}
610
+ onChange={(event) =>
611
+ updateEntry(index, { hours: event.target.value })
612
+ }
613
+ />
614
+ </div>
615
+
616
+ <div className="flex items-start justify-end">
617
+ <Button
618
+ variant="outline"
619
+ size="icon"
620
+ onClick={() => removeEntry(index)}
621
+ >
622
+ <Trash2 className="size-4" />
623
+ </Button>
624
+ </div>
625
+ </div>
626
+ ))}
627
+ </div>
628
+ </div>
629
+
630
+ <Button onClick={onSubmit}>{commonT('actions.save')}</Button>
631
+ </div>
632
+ </SheetContent>
633
+ </Sheet>
634
+ </Page>
635
+ );
636
+ }