@hed-hog/operations 0.0.305 → 0.0.309

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 (138) hide show
  1. package/dist/controllers/operations-approvals.controller.d.ts +114 -1
  2. package/dist/controllers/operations-approvals.controller.d.ts.map +1 -1
  3. package/dist/controllers/operations-approvals.controller.js +16 -3
  4. package/dist/controllers/operations-approvals.controller.js.map +1 -1
  5. package/dist/controllers/operations-collaborators.controller.d.ts +16 -1
  6. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
  7. package/dist/controllers/operations-collaborators.controller.js +16 -3
  8. package/dist/controllers/operations-collaborators.controller.js.map +1 -1
  9. package/dist/controllers/operations-contracts.controller.d.ts +14 -453
  10. package/dist/controllers/operations-contracts.controller.d.ts.map +1 -1
  11. package/dist/controllers/operations-contracts.controller.js +11 -112
  12. package/dist/controllers/operations-contracts.controller.js.map +1 -1
  13. package/dist/controllers/operations-org-structure.controller.d.ts +65 -2
  14. package/dist/controllers/operations-org-structure.controller.d.ts.map +1 -1
  15. package/dist/controllers/operations-org-structure.controller.js +18 -5
  16. package/dist/controllers/operations-org-structure.controller.js.map +1 -1
  17. package/dist/controllers/operations-projects.controller.d.ts +28 -4
  18. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  19. package/dist/controllers/operations-projects.controller.js +17 -5
  20. package/dist/controllers/operations-projects.controller.js.map +1 -1
  21. package/dist/controllers/operations-timesheets.controller.d.ts +52 -4
  22. package/dist/controllers/operations-timesheets.controller.d.ts.map +1 -1
  23. package/dist/controllers/operations-timesheets.controller.js +28 -11
  24. package/dist/controllers/operations-timesheets.controller.js.map +1 -1
  25. package/dist/dto/list-approvals.dto.d.ts +6 -0
  26. package/dist/dto/list-approvals.dto.d.ts.map +1 -0
  27. package/dist/dto/list-approvals.dto.js +28 -0
  28. package/dist/dto/list-approvals.dto.js.map +1 -0
  29. package/dist/dto/list-collaborator-types.dto.d.ts +3 -1
  30. package/dist/dto/list-collaborator-types.dto.d.ts.map +1 -1
  31. package/dist/dto/list-collaborator-types.dto.js +7 -1
  32. package/dist/dto/list-collaborator-types.dto.js.map +1 -1
  33. package/dist/dto/list-collaborators.dto.d.ts +1 -0
  34. package/dist/dto/list-collaborators.dto.d.ts.map +1 -1
  35. package/dist/dto/list-collaborators.dto.js +5 -0
  36. package/dist/dto/list-collaborators.dto.js.map +1 -1
  37. package/dist/dto/list-contracts.dto.d.ts +8 -0
  38. package/dist/dto/list-contracts.dto.d.ts.map +1 -0
  39. package/dist/dto/list-contracts.dto.js +38 -0
  40. package/dist/dto/list-contracts.dto.js.map +1 -0
  41. package/dist/dto/list-departments.dto.d.ts +5 -0
  42. package/dist/dto/list-departments.dto.d.ts.map +1 -0
  43. package/dist/dto/list-departments.dto.js +23 -0
  44. package/dist/dto/list-departments.dto.js.map +1 -0
  45. package/dist/dto/list-projects.dto.d.ts +5 -0
  46. package/dist/dto/list-projects.dto.d.ts.map +1 -0
  47. package/dist/dto/list-projects.dto.js +23 -0
  48. package/dist/dto/list-projects.dto.js.map +1 -0
  49. package/dist/dto/list-schedule-adjustments.dto.d.ts +5 -0
  50. package/dist/dto/list-schedule-adjustments.dto.d.ts.map +1 -0
  51. package/dist/dto/list-schedule-adjustments.dto.js +23 -0
  52. package/dist/dto/list-schedule-adjustments.dto.js.map +1 -0
  53. package/dist/dto/list-time-off-requests.dto.d.ts +5 -0
  54. package/dist/dto/list-time-off-requests.dto.d.ts.map +1 -0
  55. package/dist/dto/list-time-off-requests.dto.js +23 -0
  56. package/dist/dto/list-time-off-requests.dto.js.map +1 -0
  57. package/dist/dto/list-timesheets.dto.d.ts +5 -0
  58. package/dist/dto/list-timesheets.dto.d.ts.map +1 -0
  59. package/dist/dto/list-timesheets.dto.js +23 -0
  60. package/dist/dto/list-timesheets.dto.js.map +1 -0
  61. package/dist/dto/reorder-collaborator-types.dto.d.ts +4 -0
  62. package/dist/dto/reorder-collaborator-types.dto.d.ts.map +1 -0
  63. package/dist/dto/reorder-collaborator-types.dto.js +25 -0
  64. package/dist/dto/reorder-collaborator-types.dto.js.map +1 -0
  65. package/dist/dto/update-collaborator-type.dto.d.ts +3 -1
  66. package/dist/dto/update-collaborator-type.dto.d.ts.map +1 -1
  67. package/dist/dto/update-collaborator-type.dto.js +2 -1
  68. package/dist/dto/update-collaborator-type.dto.js.map +1 -1
  69. package/dist/operations.service.d.ts +362 -271
  70. package/dist/operations.service.d.ts.map +1 -1
  71. package/dist/operations.service.js +1195 -1098
  72. package/dist/operations.service.js.map +1 -1
  73. package/dist/operations.service.spec.js +73 -22
  74. package/dist/operations.service.spec.js.map +1 -1
  75. package/hedhog/data/menu.yaml +19 -55
  76. package/hedhog/data/operations_collaborator_type.yaml +76 -76
  77. package/hedhog/data/route.yaml +52 -70
  78. package/hedhog/frontend/app/_components/async-options-combobox.tsx.ejs +5 -3
  79. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +8 -1
  80. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +15 -10
  81. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +108 -213
  82. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +251 -2039
  83. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +167 -60
  84. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +70 -301
  85. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +102 -51
  86. package/hedhog/frontend/app/_components/timesheet-task-create-sheet.tsx.ejs +1 -0
  87. package/hedhog/frontend/app/_lib/types.ts.ejs +19 -24
  88. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +14 -9
  89. package/hedhog/frontend/app/approvals/page.tsx.ejs +843 -151
  90. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +457 -154
  91. package/hedhog/frontend/app/collaborators/page.tsx.ejs +118 -49
  92. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +2 -2
  93. package/hedhog/frontend/app/contracts/page.tsx.ejs +215 -617
  94. package/hedhog/frontend/app/departments/page.tsx.ejs +257 -113
  95. package/hedhog/frontend/app/projects/page.tsx.ejs +90 -51
  96. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +546 -118
  97. package/hedhog/frontend/app/time-off/page.tsx.ejs +400 -123
  98. package/hedhog/frontend/app/timesheets/page.tsx.ejs +647 -342
  99. package/hedhog/frontend/messages/en.json +148 -14
  100. package/hedhog/frontend/messages/pt.json +199 -56
  101. package/hedhog/table/operations_collaborator.yaml +18 -18
  102. package/hedhog/table/operations_collaborator_equity_participation.yaml +43 -43
  103. package/hedhog/table/operations_collaborator_type.yaml +33 -33
  104. package/hedhog/table/operations_contract.yaml +0 -9
  105. package/hedhog/table/operations_contract_document.yaml +33 -33
  106. package/package.json +4 -4
  107. package/src/controllers/operations-approvals.controller.ts +9 -3
  108. package/src/controllers/operations-collaborators.controller.ts +15 -2
  109. package/src/controllers/operations-contracts.controller.ts +8 -92
  110. package/src/controllers/operations-org-structure.controller.ts +17 -4
  111. package/src/controllers/operations-projects.controller.ts +10 -4
  112. package/src/controllers/operations-timesheets.controller.ts +30 -8
  113. package/src/dto/create-collaborator-type.dto.ts +43 -43
  114. package/src/dto/create-collaborator.dto.ts +223 -223
  115. package/src/dto/list-approvals.dto.ts +12 -0
  116. package/src/dto/list-collaborator-types.dto.ts +20 -15
  117. package/src/dto/list-collaborators.dto.ts +34 -30
  118. package/src/dto/list-contracts.dto.ts +20 -0
  119. package/src/dto/list-departments.dto.ts +8 -0
  120. package/src/dto/list-projects.dto.ts +8 -0
  121. package/src/dto/list-schedule-adjustments.dto.ts +8 -0
  122. package/src/dto/list-time-off-requests.dto.ts +8 -0
  123. package/src/dto/list-timesheets.dto.ts +8 -0
  124. package/src/dto/reorder-collaborator-types.dto.ts +10 -0
  125. package/src/dto/update-collaborator-type.dto.ts +4 -3
  126. package/src/dto/update-collaborator.dto.ts +3 -3
  127. package/src/operations.service.spec.ts +96 -30
  128. package/src/operations.service.ts +1738 -1777
  129. package/hedhog/frontend/app/_components/contract-creation-wizard.tsx.ejs +0 -631
  130. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +0 -526
  131. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +0 -247
  132. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +0 -3520
  133. package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +0 -380
  134. package/hedhog/frontend/app/team/page.tsx.ejs +0 -352
  135. package/hedhog/table/operations_contract_financial_term.yaml +0 -40
  136. package/hedhog/table/operations_contract_revision.yaml +0 -38
  137. package/hedhog/table/operations_contract_signature.yaml +0 -38
  138. package/hedhog/table/operations_contract_template.yaml +0 -58
@@ -1,6 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { EmptyState, Page, SearchBar } from '@/components/entity-list';
3
+ import {
4
+ EmptyState,
5
+ Page,
6
+ PaginationFooter,
7
+ SearchBar,
8
+ } from '@/components/entity-list';
4
9
  import {
5
10
  AlertDialog,
6
11
  AlertDialogAction,
@@ -12,13 +17,6 @@ import {
12
17
  AlertDialogTitle,
13
18
  } from '@/components/ui/alert-dialog';
14
19
  import { Button } from '@/components/ui/button';
15
- import {
16
- Card,
17
- CardContent,
18
- CardDescription,
19
- CardHeader,
20
- CardTitle,
21
- } from '@/components/ui/card';
22
20
  import {
23
21
  Form,
24
22
  FormControl,
@@ -53,14 +51,15 @@ import { zodResolver } from '@hookform/resolvers/zod';
53
51
  import {
54
52
  ClipboardList,
55
53
  Clock3,
54
+ Eye,
55
+ LayoutGrid,
56
+ List,
56
57
  Loader2,
57
58
  Plus,
58
59
  Send,
59
- Sparkles,
60
- Trash2,
61
60
  } from 'lucide-react';
62
61
  import { useTranslations } from 'next-intl';
63
- import { useEffect, useMemo, useState } from 'react';
62
+ import { useEffect, useMemo, useRef, useState } from 'react';
64
63
  import { useForm, useWatch } from 'react-hook-form';
65
64
  import { z } from 'zod';
66
65
 
@@ -99,7 +98,10 @@ type QuickEntryFormValues = {
99
98
  description: string;
100
99
  };
101
100
 
102
- const getTodayDate = () => new Date().toISOString().slice(0, 10);
101
+ const getTodayDate = () => {
102
+ const now = new Date();
103
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
104
+ };
103
105
 
104
106
  const buildDefaultFormValues = (): QuickEntryFormValues => ({
105
107
  projectId: undefined,
@@ -145,7 +147,13 @@ function formatDateLabel(value?: string | null) {
145
147
  return '—';
146
148
  }
147
149
 
148
- const date = new Date(`${value}T00:00:00`);
150
+ // Always extract just the YYYY-MM-DD part to avoid UTC offset shifting the date
151
+ const match = String(value).match(/(\d{4}-\d{2}-\d{2})/);
152
+ if (!match) {
153
+ return value;
154
+ }
155
+
156
+ const date = new Date(`${match[1]}T12:00:00`);
149
157
 
150
158
  if (Number.isNaN(date.getTime())) {
151
159
  return value;
@@ -202,11 +210,25 @@ export default function OperationsTimesheetsPage() {
202
210
  const access = useOperationsAccess();
203
211
  const [search, setSearch] = useState('');
204
212
  const [statusFilter, setStatusFilter] = useState('all');
213
+ const [page, setPage] = useState(1);
214
+ const [pageSize, setPageSize] = useState(12);
215
+ const [viewMode, setViewMode] = useState<'table' | 'cards'>(() => {
216
+ if (typeof window === 'undefined') return 'table';
217
+ const saved = window.localStorage.getItem(
218
+ 'operations-timesheets-view-mode'
219
+ );
220
+ return saved === 'cards' ? 'cards' : 'table';
221
+ });
205
222
  const [isSheetOpen, setIsSheetOpen] = useState(false);
223
+ const [isDetailsOpen, setIsDetailsOpen] = useState(false);
206
224
  const [isTaskCreateSheetOpen, setIsTaskCreateSheetOpen] = useState(false);
207
225
  const [keepContextOnSave, setKeepContextOnSave] = useState(true);
226
+ const [editingEntry, setEditingEntry] =
227
+ useState<OperationsTimesheetEntry | null>(null);
208
228
  const [entryToDelete, setEntryToDelete] =
209
229
  useState<OperationsTimesheetEntry | null>(null);
230
+ const [selectedTimesheet, setSelectedTimesheet] =
231
+ useState<OperationsTimesheet | null>(null);
210
232
  const [isDeletingEntry, setIsDeletingEntry] = useState(false);
211
233
  const [projectQuery, setProjectQuery] = useState({ search: '', page: 1 });
212
234
  const [projectOptions, setProjectOptions] = useState<
@@ -215,6 +237,7 @@ export default function OperationsTimesheetsPage() {
215
237
  const [taskQuery, setTaskQuery] = useState({ search: '', page: 1 });
216
238
  const [taskRefreshKey, setTaskRefreshKey] = useState(0);
217
239
  const [taskOptions, setTaskOptions] = useState<OperationsTaskOption[]>([]);
240
+ const previousProjectIdRef = useRef<number | undefined>(undefined);
218
241
 
219
242
  const quickEntrySchema = useMemo(
220
243
  () =>
@@ -254,13 +277,42 @@ export default function OperationsTimesheetsPage() {
254
277
  name: 'unit',
255
278
  });
256
279
 
257
- const { data: timesheets = [], refetch } = useQuery<OperationsTimesheet[]>({
258
- queryKey: ['operations-timesheets', currentLocaleCode],
259
- queryFn: () =>
260
- fetchOperations<OperationsTimesheet[]>(request, '/operations/timesheets'),
261
- placeholderData: (previous) => previous ?? [],
280
+ const { data: timesheetsResponse, refetch } = useQuery<
281
+ PaginatedResponse<OperationsTimesheet>
282
+ >({
283
+ queryKey: [
284
+ 'operations-timesheets',
285
+ currentLocaleCode,
286
+ search,
287
+ statusFilter,
288
+ page,
289
+ pageSize,
290
+ ],
291
+ queryFn: () => {
292
+ const params = new URLSearchParams({
293
+ page: String(page),
294
+ pageSize: String(pageSize),
295
+ });
296
+ if (search.trim()) params.set('search', search.trim());
297
+ if (statusFilter !== 'all') params.set('status', statusFilter);
298
+ return fetchOperations<PaginatedResponse<OperationsTimesheet>>(
299
+ request,
300
+ `/operations/timesheets?${params.toString()}`
301
+ );
302
+ },
303
+ placeholderData: (previous) => previous,
262
304
  });
263
305
 
306
+ const timesheets = timesheetsResponse?.data ?? [];
307
+
308
+ const handleViewModeChange = (value: string) => {
309
+ if (value !== 'table' && value !== 'cards') return;
310
+ setViewMode(value as 'table' | 'cards');
311
+ if (typeof window !== 'undefined') {
312
+ window.localStorage.setItem('operations-timesheets-view-mode', value);
313
+ }
314
+ };
315
+
264
316
  const { data: me } = useQuery<OperationsCollaborator>({
265
317
  queryKey: ['operations-timesheets-me', currentLocaleCode],
266
318
  enabled: access.isCollaborator,
@@ -271,11 +323,9 @@ export default function OperationsTimesheetsPage() {
271
323
  ),
272
324
  });
273
325
 
274
- const {
275
- data: recentEntriesResponse,
276
- isLoading: isRecentEntriesLoading,
277
- refetch: refetchRecentEntries,
278
- } = useQuery<PaginatedResponse<OperationsTimesheetEntry>>({
326
+ const { refetch: refetchRecentEntries } = useQuery<
327
+ PaginatedResponse<OperationsTimesheetEntry>
328
+ >({
279
329
  queryKey: [
280
330
  'operations-timesheet-recent-entries',
281
331
  currentLocaleCode,
@@ -307,66 +357,73 @@ export default function OperationsTimesheetsPage() {
307
357
  placeholderData: (previous) => previous,
308
358
  });
309
359
 
310
- const { data: projectOptionsResponse, isLoading: isProjectOptionsLoading } =
311
- useQuery<PaginatedResponse<OperationsProjectOption>>({
312
- queryKey: [
313
- 'operations-timesheet-project-options',
314
- currentLocaleCode,
315
- projectQuery.search,
316
- projectQuery.page,
317
- ],
318
- enabled: access.isCollaborator && isSheetOpen,
319
- queryFn: () => {
320
- const params = new URLSearchParams({
321
- page: String(projectQuery.page),
322
- pageSize: '20',
323
- });
324
-
325
- if (projectQuery.search.trim()) {
326
- params.set('search', projectQuery.search.trim());
327
- }
360
+ const {
361
+ data: projectOptionsResponse,
362
+ isLoading: isProjectOptionsLoading,
363
+ error: projectOptionsError,
364
+ } = useQuery<PaginatedResponse<OperationsProjectOption>>({
365
+ queryKey: [
366
+ 'operations-timesheet-project-options',
367
+ currentLocaleCode,
368
+ projectQuery.search,
369
+ projectQuery.page,
370
+ ],
371
+ enabled: access.isCollaborator && isSheetOpen,
372
+ queryFn: () => {
373
+ const params = new URLSearchParams({
374
+ page: String(projectQuery.page),
375
+ pageSize: '20',
376
+ });
328
377
 
329
- return fetchOperations<PaginatedResponse<OperationsProjectOption>>(
330
- request,
331
- `/operations/projects/options?${params.toString()}`
332
- );
333
- },
334
- placeholderData: (previous) => previous,
335
- });
378
+ if (projectQuery.search.trim()) {
379
+ params.set('search', projectQuery.search.trim());
380
+ }
336
381
 
337
- const { data: taskOptionsResponse, isLoading: isTaskOptionsLoading } =
338
- useQuery<PaginatedResponse<OperationsTaskOption>>({
339
- queryKey: [
340
- 'operations-timesheet-task-options',
341
- currentLocaleCode,
342
- selectedProjectId,
343
- taskQuery.search,
344
- taskQuery.page,
345
- taskRefreshKey,
346
- ],
347
- enabled:
348
- access.isCollaborator && isSheetOpen && Boolean(selectedProjectId),
349
- queryFn: () => {
350
- const params = new URLSearchParams({
351
- page: String(taskQuery.page),
352
- pageSize: '20',
353
- });
354
-
355
- if (taskQuery.search.trim()) {
356
- params.set('search', taskQuery.search.trim());
357
- }
382
+ return fetchOperations<PaginatedResponse<OperationsProjectOption>>(
383
+ request,
384
+ `/operations/projects/options?${params.toString()}`
385
+ );
386
+ },
387
+ placeholderData: (previous) => previous,
388
+ });
358
389
 
359
- if (selectedProjectId) {
360
- params.set('projectId', String(selectedProjectId));
361
- }
390
+ const selectedProject =
391
+ projectOptions.find((option) => option.id === selectedProjectId) ?? null;
362
392
 
363
- return fetchOperations<PaginatedResponse<OperationsTaskOption>>(
364
- request,
365
- `/operations/tasks?${params.toString()}`
366
- );
367
- },
368
- placeholderData: (previous) => previous,
369
- });
393
+ const {
394
+ data: taskOptionsResponse,
395
+ isLoading: isTaskOptionsLoading,
396
+ error: taskOptionsError,
397
+ } = useQuery<PaginatedResponse<OperationsTaskOption>>({
398
+ queryKey: [
399
+ 'operations-timesheet-task-options',
400
+ currentLocaleCode,
401
+ selectedProjectId,
402
+ taskQuery.search,
403
+ taskQuery.page,
404
+ taskRefreshKey,
405
+ ],
406
+ enabled: access.isCollaborator && isSheetOpen && Boolean(selectedProjectId),
407
+ queryFn: () => {
408
+ const params = new URLSearchParams({
409
+ page: String(taskQuery.page),
410
+ pageSize: '20',
411
+ });
412
+
413
+ if (taskQuery.search.trim()) {
414
+ params.set('search', taskQuery.search.trim());
415
+ }
416
+
417
+ if (selectedProjectId) {
418
+ params.set('projectId', String(selectedProjectId));
419
+ }
420
+
421
+ return fetchOperations<PaginatedResponse<OperationsTaskOption>>(
422
+ request,
423
+ `/operations/tasks?${params.toString()}`
424
+ );
425
+ },
426
+ });
370
427
 
371
428
  useEffect(() => {
372
429
  if (!projectOptionsResponse) {
@@ -375,10 +432,12 @@ export default function OperationsTimesheetsPage() {
375
432
 
376
433
  setProjectOptions((current) =>
377
434
  projectQuery.page === 1
378
- ? (projectOptionsResponse.data ?? [])
435
+ ? editingEntry
436
+ ? appendUniqueById(current, projectOptionsResponse.data ?? [])
437
+ : (projectOptionsResponse.data ?? [])
379
438
  : appendUniqueById(current, projectOptionsResponse.data ?? [])
380
439
  );
381
- }, [projectOptionsResponse, projectQuery.page]);
440
+ }, [editingEntry, projectOptionsResponse, projectQuery.page]);
382
441
 
383
442
  useEffect(() => {
384
443
  if (!selectedProjectId) {
@@ -392,45 +451,78 @@ export default function OperationsTimesheetsPage() {
392
451
 
393
452
  setTaskOptions((current) =>
394
453
  taskQuery.page === 1
395
- ? (taskOptionsResponse.data ?? [])
396
- : appendUniqueById(current, taskOptionsResponse.data ?? [])
454
+ ? editingEntry
455
+ ? appendUniqueById(
456
+ current,
457
+ (taskOptionsResponse.data ?? []).filter(
458
+ (option) => option.projectId === selectedProjectId
459
+ )
460
+ )
461
+ : (taskOptionsResponse.data ?? []).filter(
462
+ (option) => option.projectId === selectedProjectId
463
+ )
464
+ : appendUniqueById(
465
+ current,
466
+ (taskOptionsResponse.data ?? []).filter(
467
+ (option) => option.projectId === selectedProjectId
468
+ )
469
+ )
397
470
  );
398
- }, [selectedProjectId, taskOptionsResponse, taskQuery.page]);
471
+ }, [editingEntry, selectedProjectId, taskOptionsResponse, taskQuery.page]);
399
472
 
400
- const recentEntries = recentEntriesResponse?.data ?? [];
401
- const selectedProject =
402
- projectOptions.find((option) => option.id === selectedProjectId) ?? null;
473
+ useEffect(() => {
474
+ const previousProjectId = previousProjectIdRef.current;
475
+ previousProjectIdRef.current = selectedProjectId;
476
+
477
+ if (
478
+ previousProjectId === undefined ||
479
+ previousProjectId === selectedProjectId
480
+ ) {
481
+ return;
482
+ }
483
+
484
+ setTaskOptions([]);
485
+ setTaskQuery({ search: '', page: 1 });
486
+ form.setValue('taskId', undefined, {
487
+ shouldDirty: true,
488
+ shouldTouch: true,
489
+ shouldValidate: false,
490
+ });
491
+ }, [form, selectedProjectId]);
492
+
493
+ useEffect(() => {
494
+ if (!projectOptionsError) {
495
+ return;
496
+ }
497
+
498
+ showToastHandler?.(
499
+ 'error',
500
+ getOperationsErrorMessage(
501
+ projectOptionsError,
502
+ t('messages.projectLoadError')
503
+ )
504
+ );
505
+ }, [projectOptionsError, showToastHandler, t]);
506
+
507
+ useEffect(() => {
508
+ if (!taskOptionsError) {
509
+ return;
510
+ }
511
+
512
+ showToastHandler?.(
513
+ 'error',
514
+ getOperationsErrorMessage(taskOptionsError, t('messages.taskLoadError'))
515
+ );
516
+ }, [showToastHandler, t, taskOptionsError]);
517
+
518
+ const filteredTaskOptions = selectedProjectId
519
+ ? taskOptions.filter((option) => option.projectId === selectedProjectId)
520
+ : [];
403
521
  const selectedTask =
404
- taskOptions.find((option) => option.id === selectedTaskId) ?? null;
522
+ filteredTaskOptions.find((option) => option.id === selectedTaskId) ?? null;
523
+ const isEditingEntry = Boolean(editingEntry);
405
524
 
406
- const filteredRows = useMemo(
407
- () =>
408
- timesheets.filter((item) => {
409
- const matchesSearch = !search.trim()
410
- ? true
411
- : [
412
- item.collaboratorName,
413
- item.approverName,
414
- item.notes,
415
- ...((item.entries ?? []).flatMap((entry) => [
416
- entry.projectName,
417
- entry.taskName,
418
- entry.activityLabel,
419
- entry.description,
420
- ]) as Array<string | undefined>),
421
- ]
422
- .filter(Boolean)
423
- .some((value) =>
424
- String(value)
425
- .toLowerCase()
426
- .includes(search.trim().toLowerCase())
427
- );
428
- const matchesStatus =
429
- statusFilter === 'all' ? true : item.status === statusFilter;
430
- return matchesSearch && matchesStatus;
431
- }),
432
- [timesheets, search, statusFilter]
433
- );
525
+ const filteredRows = timesheets;
434
526
 
435
527
  const cards = [
436
528
  {
@@ -459,9 +551,11 @@ export default function OperationsTimesheetsPage() {
459
551
  ];
460
552
 
461
553
  const openCreate = () => {
554
+ setEditingEntry(null);
462
555
  form.reset(buildDefaultFormValues());
463
556
  setProjectQuery({ search: '', page: 1 });
464
557
  setTaskQuery({ search: '', page: 1 });
558
+ setProjectOptions([]);
465
559
  setTaskOptions([]);
466
560
  setIsTaskCreateSheetOpen(false);
467
561
  setIsSheetOpen(true);
@@ -469,13 +563,20 @@ export default function OperationsTimesheetsPage() {
469
563
 
470
564
  const closeCreateSheet = () => {
471
565
  setIsSheetOpen(false);
566
+ setEditingEntry(null);
472
567
  setIsTaskCreateSheetOpen(false);
473
568
  form.reset(buildDefaultFormValues());
474
569
  setProjectQuery({ search: '', page: 1 });
475
570
  setTaskQuery({ search: '', page: 1 });
571
+ setProjectOptions([]);
476
572
  setTaskOptions([]);
477
573
  };
478
574
 
575
+ const openDetails = (timesheet: OperationsTimesheet) => {
576
+ setSelectedTimesheet(timesheet);
577
+ setIsDetailsOpen(true);
578
+ };
579
+
479
580
  const canManageRow = (timesheet: OperationsTimesheet) => {
480
581
  return Boolean(
481
582
  me?.id &&
@@ -484,28 +585,39 @@ export default function OperationsTimesheetsPage() {
484
585
  );
485
586
  };
486
587
 
487
- const canManageEntry = (entry: OperationsTimesheetEntry) => {
488
- return Boolean(
489
- me?.id &&
490
- entry.collaboratorId === me.id &&
491
- ['draft', 'rejected'].includes(entry.status ?? '')
492
- );
493
- };
494
-
495
588
  const handleQuickEntrySubmit = async (values: QuickEntryFormValues) => {
589
+ const payload = {
590
+ projectId: values.projectId,
591
+ taskId: values.taskId,
592
+ workDate: values.workDate,
593
+ duration: values.duration,
594
+ unit: values.unit,
595
+ description: trimToNull(values.description),
596
+ };
597
+
496
598
  try {
497
- await mutateOperations(request, '/operations/timesheet-entries', 'POST', {
498
- projectId: values.projectId,
499
- taskId: values.taskId,
500
- workDate: values.workDate,
501
- duration: values.duration,
502
- unit: values.unit,
503
- description: trimToNull(values.description),
504
- });
599
+ if (editingEntry?.id) {
600
+ await mutateOperations(
601
+ request,
602
+ `/operations/timesheet-entries/${editingEntry.id}`,
603
+ 'PATCH',
604
+ payload
605
+ );
606
+ } else {
607
+ await mutateOperations(
608
+ request,
609
+ '/operations/timesheet-entries',
610
+ 'POST',
611
+ payload
612
+ );
613
+ }
505
614
 
506
- showToastHandler?.('success', t('messages.saveSuccess'));
615
+ showToastHandler?.(
616
+ 'success',
617
+ editingEntry ? t('messages.updateSuccess') : t('messages.saveSuccess')
618
+ );
507
619
 
508
- if (keepContextOnSave) {
620
+ if (!editingEntry && keepContextOnSave) {
509
621
  form.reset(buildFollowUpFormValues(values, true));
510
622
  setProjectQuery({ search: '', page: 1 });
511
623
  setTaskQuery({ search: '', page: 1 });
@@ -519,7 +631,10 @@ export default function OperationsTimesheetsPage() {
519
631
  } catch (error) {
520
632
  showToastHandler?.(
521
633
  'error',
522
- getOperationsErrorMessage(error, t('messages.saveError'))
634
+ getOperationsErrorMessage(
635
+ error,
636
+ editingEntry ? t('messages.updateError') : t('messages.saveError')
637
+ )
523
638
  );
524
639
  }
525
640
  };
@@ -604,222 +719,260 @@ export default function OperationsTimesheetsPage() {
604
719
  }
605
720
  />
606
721
 
607
- <SearchBar
608
- searchQuery={search}
609
- onSearchChange={setSearch}
610
- onSearch={() => undefined}
611
- placeholder={t('searchPlaceholder')}
612
- controls={[
613
- {
614
- id: 'status',
615
- type: 'select',
616
- value: statusFilter,
617
- onChange: setStatusFilter,
618
- placeholder: commonT('labels.status'),
619
- options: [
620
- { value: 'all', label: commonT('filters.allStatuses') },
621
- { value: 'draft', label: formatEnumLabel('draft') },
622
- { value: 'submitted', label: formatEnumLabel('submitted') },
623
- { value: 'approved', label: formatEnumLabel('approved') },
624
- { value: 'rejected', label: formatEnumLabel('rejected') },
625
- ],
626
- },
627
- ]}
628
- />
722
+ <div className="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-center">
723
+ <div className="flex-1">
724
+ <SearchBar
725
+ searchQuery={search}
726
+ onSearchChange={(value) => {
727
+ setSearch(value);
728
+ setPage(1);
729
+ }}
730
+ showSearchButton={false}
731
+ debounceMs={500}
732
+ placeholder={t('searchPlaceholder')}
733
+ controls={[
734
+ {
735
+ id: 'status',
736
+ type: 'select',
737
+ value: statusFilter,
738
+ onChange: (value) => {
739
+ setStatusFilter(value);
740
+ setPage(1);
741
+ },
742
+ placeholder: commonT('labels.status'),
743
+ options: [
744
+ { value: 'all', label: commonT('filters.allStatuses') },
745
+ { value: 'draft', label: formatEnumLabel('draft') },
746
+ { value: 'submitted', label: formatEnumLabel('submitted') },
747
+ { value: 'approved', label: formatEnumLabel('approved') },
748
+ { value: 'rejected', label: formatEnumLabel('rejected') },
749
+ ],
750
+ },
751
+ ]}
752
+ />
753
+ </div>
754
+
755
+ <div className="flex items-center justify-between gap-3 xl:justify-end">
756
+ <span className="text-xs font-medium text-muted-foreground">
757
+ {t('viewMode')}
758
+ </span>
759
+ <ToggleGroup
760
+ type="single"
761
+ value={viewMode}
762
+ onValueChange={handleViewModeChange}
763
+ variant="outline"
764
+ size="sm"
765
+ >
766
+ <ToggleGroupItem value="table" className="gap-1.5 px-2.5">
767
+ <List className="h-4 w-4" />
768
+ <span className="hidden sm:inline">{t('viewModeTable')}</span>
769
+ </ToggleGroupItem>
770
+ <ToggleGroupItem value="cards" className="gap-1.5 px-2.5">
771
+ <LayoutGrid className="h-4 w-4" />
772
+ <span className="hidden sm:inline">{t('viewModeCards')}</span>
773
+ </ToggleGroupItem>
774
+ </ToggleGroup>
775
+ </div>
776
+ </div>
629
777
 
630
778
  <KpiCardsGrid items={cards} columns={3} />
631
779
 
632
- {access.isCollaborator ? (
633
- <Card>
634
- <CardHeader className="border-b">
635
- <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
636
- <div className="flex items-start gap-3">
637
- <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
638
- <Sparkles className="size-5" />
780
+ {timesheets.length > 0 ? (
781
+ viewMode === 'cards' ? (
782
+ <div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
783
+ {timesheets.map((timesheet) => (
784
+ <div
785
+ key={timesheet.id}
786
+ className="rounded-lg border bg-card shadow-sm p-4 space-y-3"
787
+ >
788
+ <div className="flex items-start justify-between gap-3">
789
+ <div className="min-w-0">
790
+ <div className="truncate font-semibold">
791
+ {timesheet.collaboratorName}
792
+ </div>
793
+ <div className="truncate text-xs text-muted-foreground">
794
+ {formatDateRange(
795
+ timesheet.weekStartDate,
796
+ timesheet.weekEndDate
797
+ )}
798
+ </div>
799
+ </div>
800
+ <StatusBadge
801
+ label={
802
+ t.has(`statuses.${timesheet.status}`)
803
+ ? t(`statuses.${timesheet.status}`)
804
+ : formatEnumLabel(timesheet.status)
805
+ }
806
+ className={getStatusBadgeClass(timesheet.status)}
807
+ />
639
808
  </div>
640
-
641
- <div className="space-y-1">
642
- <CardTitle>{t('recentEntries.title')}</CardTitle>
643
- <CardDescription>
644
- {t('recentEntries.description')}
645
- </CardDescription>
809
+ <div className="grid grid-cols-2 gap-2 text-sm text-muted-foreground">
810
+ <div>
811
+ <span className="font-medium text-foreground">
812
+ {commonT('labels.totalHours')}:
813
+ </span>{' '}
814
+ {formatHours(timesheet.totalHours)}
815
+ </div>
816
+ <div>
817
+ <span className="font-medium text-foreground">
818
+ {commonT('labels.entries')}:
819
+ </span>{' '}
820
+ {timesheet.entries?.length ?? 0}
821
+ </div>
646
822
  </div>
823
+ {timesheet.approverName ? (
824
+ <div className="text-sm text-muted-foreground">
825
+ <span className="font-medium text-foreground">
826
+ {commonT('labels.approver')}:
827
+ </span>{' '}
828
+ {timesheet.approverName}
829
+ </div>
830
+ ) : null}
831
+ {canManageRow(timesheet) ? (
832
+ <div className="flex justify-end gap-2 border-t border-border/60 pt-3">
833
+ <Button
834
+ variant="outline"
835
+ size="sm"
836
+ className="cursor-pointer"
837
+ onClick={() => openDetails(timesheet)}
838
+ >
839
+ <Eye className="size-4" />
840
+ {t('actions.viewDetails')}
841
+ </Button>
842
+ <Button
843
+ size="sm"
844
+ onClick={() => void submitTimesheet(timesheet.id)}
845
+ >
846
+ <Send className="size-4" />
847
+ {commonT('actions.submit')}
848
+ </Button>
849
+ </div>
850
+ ) : (
851
+ <div className="flex justify-end border-t border-border/60 pt-3">
852
+ <Button
853
+ variant="outline"
854
+ size="sm"
855
+ className="cursor-pointer"
856
+ onClick={() => openDetails(timesheet)}
857
+ >
858
+ <Eye className="size-4" />
859
+ {t('actions.viewDetails')}
860
+ </Button>
861
+ </div>
862
+ )}
647
863
  </div>
648
-
649
- <Button size="sm" onClick={openCreate}>
650
- <Plus className="size-4" />
651
- {t('sheet.createTitle')}
652
- </Button>
653
- </div>
654
- </CardHeader>
655
-
656
- <CardContent className="px-0">
657
- {isRecentEntriesLoading ? (
658
- <div className="px-6 py-6 text-sm text-muted-foreground">
659
- {t('recentEntries.loading')}
660
- </div>
661
- ) : recentEntries.length > 0 ? (
662
- <div className="divide-y">
663
- {recentEntries.map((entry) => (
664
- <div
665
- key={entry.id}
666
- className="flex flex-col gap-3 px-6 py-4 sm:flex-row sm:items-center sm:justify-between"
667
- >
668
- <div className="min-w-0 space-y-1">
669
- <div className="flex flex-wrap items-center gap-2">
670
- <span className="font-medium text-foreground">
671
- {[entry.projectCode, entry.projectName]
672
- .filter(Boolean)
673
- .join(' • ') || commonT('labels.unassigned')}
674
- </span>
675
- <span className="text-muted-foreground">•</span>
676
- <span className="text-sm text-muted-foreground">
677
- {entry.taskName ||
678
- entry.activityLabel ||
679
- commonT('labels.noNotes')}
680
- </span>
864
+ ))}
865
+ </div>
866
+ ) : (
867
+ <div className="overflow-x-auto rounded-md border">
868
+ <Table>
869
+ <TableHeader>
870
+ <TableRow>
871
+ <TableHead>{commonT('labels.collaborator')}</TableHead>
872
+ <TableHead>{commonT('labels.week')}</TableHead>
873
+ <TableHead>{commonT('labels.entries')}</TableHead>
874
+ <TableHead>{commonT('labels.totalHours')}</TableHead>
875
+ <TableHead>{commonT('labels.approver')}</TableHead>
876
+ <TableHead>{commonT('labels.decisionNote')}</TableHead>
877
+ <TableHead>{commonT('labels.status')}</TableHead>
878
+ <TableHead>{commonT('labels.actions')}</TableHead>
879
+ </TableRow>
880
+ </TableHeader>
881
+ <TableBody>
882
+ {filteredRows.map((timesheet) => (
883
+ <TableRow key={timesheet.id} className="hover:bg-muted/30">
884
+ <TableCell>
885
+ <div className="font-medium">
886
+ {timesheet.collaboratorName}
681
887
  </div>
682
-
683
- <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
684
- <span>{formatDateLabel(entry.workDate)}</span>
685
- {entry.description ? (
888
+ <div className="text-xs text-muted-foreground">
889
+ {timesheet.notes || commonT('labels.noNotes')}
890
+ </div>
891
+ </TableCell>
892
+ <TableCell>
893
+ {formatDateRange(
894
+ timesheet.weekStartDate,
895
+ timesheet.weekEndDate
896
+ )}
897
+ </TableCell>
898
+ <TableCell>
899
+ <div className="font-medium">
900
+ {timesheet.entries?.length ?? 0}{' '}
901
+ {commonT('labels.lines')}
902
+ </div>
903
+ <div className="text-xs text-muted-foreground">
904
+ {(timesheet.entries ?? [])
905
+ .slice(0, 2)
906
+ .map(
907
+ (entry) =>
908
+ [
909
+ entry.projectName,
910
+ entry.taskName || entry.activityLabel,
911
+ ]
912
+ .filter(Boolean)
913
+ .join(' • ') || commonT('labels.unassigned')
914
+ )
915
+ .join(', ') || commonT('labels.unassigned')}
916
+ </div>
917
+ </TableCell>
918
+ <TableCell>{formatHours(timesheet.totalHours)}</TableCell>
919
+ <TableCell>
920
+ {timesheet.approverName || commonT('labels.notAssigned')}
921
+ </TableCell>
922
+ <TableCell>
923
+ <div className="text-sm text-muted-foreground">
924
+ {timesheet.decisionNote || commonT('labels.noNotes')}
925
+ </div>
926
+ </TableCell>
927
+ <TableCell>
928
+ <StatusBadge
929
+ label={
930
+ t.has(`statuses.${timesheet.status}`)
931
+ ? t(`statuses.${timesheet.status}`)
932
+ : formatEnumLabel(timesheet.status)
933
+ }
934
+ className={getStatusBadgeClass(timesheet.status)}
935
+ />
936
+ </TableCell>
937
+ <TableCell>
938
+ <div className="flex justify-end gap-2">
939
+ {canManageRow(timesheet) ? (
686
940
  <>
687
- <span>•</span>
688
- <span className="truncate">
689
- {entry.description}
690
- </span>
941
+ <Button
942
+ variant="outline"
943
+ size="sm"
944
+ className="cursor-pointer"
945
+ onClick={() => openDetails(timesheet)}
946
+ >
947
+ <Eye className="size-4" />
948
+ {t('actions.viewDetails')}
949
+ </Button>
950
+ <Button
951
+ size="icon"
952
+ onClick={() => void submitTimesheet(timesheet.id)}
953
+ >
954
+ <Send className="size-4" />
955
+ </Button>
691
956
  </>
692
- ) : null}
693
- </div>
694
- </div>
695
-
696
- <div className="flex items-center gap-2 self-start sm:self-center">
697
- {entry.status ? (
698
- <StatusBadge
699
- label={formatEnumLabel(entry.status)}
700
- className={getStatusBadgeClass(entry.status)}
701
- />
702
- ) : null}
703
-
704
- <span className="inline-flex items-center rounded-md bg-muted px-2.5 py-1 text-xs font-medium text-foreground">
705
- {formatEntryDuration(
706
- entry.durationMinutes,
707
- entry.hours
957
+ ) : (
958
+ <Button
959
+ variant="outline"
960
+ size="sm"
961
+ className="cursor-pointer"
962
+ onClick={() => openDetails(timesheet)}
963
+ >
964
+ <Eye className="size-4" />
965
+ {t('actions.viewDetails')}
966
+ </Button>
708
967
  )}
709
- </span>
710
-
711
- {canManageEntry(entry) ? (
712
- <Button
713
- type="button"
714
- variant="ghost"
715
- size="icon"
716
- className="text-muted-foreground hover:text-destructive"
717
- onClick={() => setEntryToDelete(entry)}
718
- aria-label={t('messages.confirmDeleteTitle')}
719
- >
720
- <Trash2 className="size-4" />
721
- </Button>
722
- ) : null}
723
- </div>
724
- </div>
968
+ </div>
969
+ </TableCell>
970
+ </TableRow>
725
971
  ))}
726
- </div>
727
- ) : (
728
- <div className="px-6 py-6 text-sm text-muted-foreground">
729
- {t('recentEntries.empty')}
730
- </div>
731
- )}
732
- </CardContent>
733
- </Card>
734
- ) : null}
735
-
736
- {filteredRows.length > 0 ? (
737
- <div className="overflow-x-auto rounded-md border">
738
- <Table>
739
- <TableHeader>
740
- <TableRow>
741
- <TableHead>{commonT('labels.collaborator')}</TableHead>
742
- <TableHead>{commonT('labels.week')}</TableHead>
743
- <TableHead>{commonT('labels.entries')}</TableHead>
744
- <TableHead>{commonT('labels.totalHours')}</TableHead>
745
- <TableHead>{commonT('labels.approver')}</TableHead>
746
- <TableHead>{commonT('labels.decisionNote')}</TableHead>
747
- <TableHead>{commonT('labels.status')}</TableHead>
748
- <TableHead>{commonT('labels.actions')}</TableHead>
749
- </TableRow>
750
- </TableHeader>
751
- <TableBody>
752
- {filteredRows.map((timesheet) => (
753
- <TableRow key={timesheet.id} className="hover:bg-muted/30">
754
- <TableCell>
755
- <div className="font-medium">
756
- {timesheet.collaboratorName}
757
- </div>
758
- <div className="text-xs text-muted-foreground">
759
- {timesheet.notes || commonT('labels.noNotes')}
760
- </div>
761
- </TableCell>
762
- <TableCell>
763
- {formatDateRange(
764
- timesheet.weekStartDate,
765
- timesheet.weekEndDate
766
- )}
767
- </TableCell>
768
- <TableCell>
769
- <div className="font-medium">
770
- {timesheet.entries?.length ?? 0} {commonT('labels.lines')}
771
- </div>
772
- <div className="text-xs text-muted-foreground">
773
- {(timesheet.entries ?? [])
774
- .slice(0, 2)
775
- .map(
776
- (entry) =>
777
- [
778
- entry.projectName,
779
- entry.taskName || entry.activityLabel,
780
- ]
781
- .filter(Boolean)
782
- .join(' • ') || commonT('labels.unassigned')
783
- )
784
- .join(', ') || commonT('labels.unassigned')}
785
- </div>
786
- </TableCell>
787
- <TableCell>{formatHours(timesheet.totalHours)}</TableCell>
788
- <TableCell>
789
- {timesheet.approverName || commonT('labels.notAssigned')}
790
- </TableCell>
791
- <TableCell>
792
- <div className="text-sm text-muted-foreground">
793
- {timesheet.decisionNote || commonT('labels.noNotes')}
794
- </div>
795
- </TableCell>
796
- <TableCell>
797
- <StatusBadge
798
- label={formatEnumLabel(timesheet.status)}
799
- className={getStatusBadgeClass(timesheet.status)}
800
- />
801
- </TableCell>
802
- <TableCell>
803
- <div className="flex justify-end gap-2">
804
- {canManageRow(timesheet) ? (
805
- <Button
806
- size="icon"
807
- onClick={() => void submitTimesheet(timesheet.id)}
808
- >
809
- <Send className="size-4" />
810
- </Button>
811
- ) : (
812
- <span className="text-xs text-muted-foreground">
813
- {commonT('labels.viewOnly')}
814
- </span>
815
- )}
816
- </div>
817
- </TableCell>
818
- </TableRow>
819
- ))}
820
- </TableBody>
821
- </Table>
822
- </div>
972
+ </TableBody>
973
+ </Table>
974
+ </div>
975
+ )
823
976
  ) : (
824
977
  <EmptyState
825
978
  icon={<ClipboardList className="size-12" />}
@@ -834,6 +987,18 @@ export default function OperationsTimesheetsPage() {
834
987
  />
835
988
  )}
836
989
 
990
+ <PaginationFooter
991
+ currentPage={page}
992
+ pageSize={pageSize}
993
+ totalItems={timesheetsResponse?.total ?? 0}
994
+ pageSizeOptions={[12, 24, 48]}
995
+ onPageChange={setPage}
996
+ onPageSizeChange={(size) => {
997
+ setPageSize(size);
998
+ setPage(1);
999
+ }}
1000
+ />
1001
+
837
1002
  <Sheet
838
1003
  open={isSheetOpen}
839
1004
  onOpenChange={(open) => {
@@ -857,7 +1022,11 @@ export default function OperationsTimesheetsPage() {
857
1022
  </div>
858
1023
 
859
1024
  <div className="space-y-1">
860
- <SheetTitle>{t('sheet.createTitle')}</SheetTitle>
1025
+ <SheetTitle>
1026
+ {isEditingEntry
1027
+ ? t('sheet.editTitle')
1028
+ : t('sheet.createTitle')}
1029
+ </SheetTitle>
861
1030
  <SheetDescription>{t('sheet.description')}</SheetDescription>
862
1031
  </div>
863
1032
  </div>
@@ -926,7 +1095,7 @@ export default function OperationsTimesheetsPage() {
926
1095
  <AsyncOptionsCombobox
927
1096
  value={field.value}
928
1097
  selectedOption={selectedTask}
929
- options={taskOptions}
1098
+ options={filteredTaskOptions}
930
1099
  onSelect={(option) => field.onChange(option?.id)}
931
1100
  searchValue={taskQuery.search}
932
1101
  onSearchValueChange={(value) =>
@@ -1091,6 +1260,142 @@ export default function OperationsTimesheetsPage() {
1091
1260
  onCreated={handleTaskCreated}
1092
1261
  />
1093
1262
 
1263
+ <Sheet open={isDetailsOpen} onOpenChange={setIsDetailsOpen}>
1264
+ <SheetContent className="w-full overflow-y-auto sm:max-w-2xl">
1265
+ <SheetHeader>
1266
+ <SheetTitle>{t('details.title')}</SheetTitle>
1267
+ <SheetDescription>{t('details.description')}</SheetDescription>
1268
+ </SheetHeader>
1269
+
1270
+ {selectedTimesheet ? (
1271
+ <div className="mt-6 space-y-5 px-4 pb-8">
1272
+ <div className="rounded-lg border bg-muted/20 p-4">
1273
+ <div className="flex flex-wrap items-start justify-between gap-3">
1274
+ <div>
1275
+ <div className="text-lg font-semibold">
1276
+ {selectedTimesheet.collaboratorName}
1277
+ </div>
1278
+ <div className="mt-1 text-sm text-muted-foreground">
1279
+ {formatDateRange(
1280
+ selectedTimesheet.weekStartDate,
1281
+ selectedTimesheet.weekEndDate
1282
+ )}
1283
+ </div>
1284
+ </div>
1285
+ <StatusBadge
1286
+ label={
1287
+ t.has(`statuses.${selectedTimesheet.status}`)
1288
+ ? t(`statuses.${selectedTimesheet.status}`)
1289
+ : formatEnumLabel(selectedTimesheet.status)
1290
+ }
1291
+ className={getStatusBadgeClass(selectedTimesheet.status)}
1292
+ />
1293
+ </div>
1294
+ </div>
1295
+
1296
+ <div className="grid gap-4 sm:grid-cols-2">
1297
+ <div>
1298
+ <div className="text-xs font-medium uppercase text-muted-foreground">
1299
+ {commonT('labels.totalHours')}
1300
+ </div>
1301
+ <div className="mt-1 text-sm">
1302
+ {formatHours(selectedTimesheet.totalHours)}
1303
+ </div>
1304
+ </div>
1305
+ <div>
1306
+ <div className="text-xs font-medium uppercase text-muted-foreground">
1307
+ {commonT('labels.entries')}
1308
+ </div>
1309
+ <div className="mt-1 text-sm">
1310
+ {selectedTimesheet.entries?.length ?? 0}
1311
+ </div>
1312
+ </div>
1313
+ <div>
1314
+ <div className="text-xs font-medium uppercase text-muted-foreground">
1315
+ {commonT('labels.approver')}
1316
+ </div>
1317
+ <div className="mt-1 text-sm">
1318
+ {selectedTimesheet.approverName ||
1319
+ commonT('labels.notAssigned')}
1320
+ </div>
1321
+ </div>
1322
+ <div>
1323
+ <div className="text-xs font-medium uppercase text-muted-foreground">
1324
+ {t('details.period')}
1325
+ </div>
1326
+ <div className="mt-1 text-sm">
1327
+ {formatDateLabel(selectedTimesheet.weekStartDate)} -{' '}
1328
+ {formatDateLabel(selectedTimesheet.weekEndDate)}
1329
+ </div>
1330
+ </div>
1331
+ </div>
1332
+
1333
+ <div>
1334
+ <div className="text-xs font-medium uppercase text-muted-foreground">
1335
+ {commonT('labels.notes')}
1336
+ </div>
1337
+ <div className="mt-1 rounded-lg border p-3 text-sm">
1338
+ {selectedTimesheet.notes || commonT('labels.noNotes')}
1339
+ </div>
1340
+ </div>
1341
+
1342
+ <div>
1343
+ <div className="text-xs font-medium uppercase text-muted-foreground">
1344
+ {commonT('labels.decisionNote')}
1345
+ </div>
1346
+ <div className="mt-1 rounded-lg border p-3 text-sm">
1347
+ {selectedTimesheet.decisionNote || commonT('labels.noNotes')}
1348
+ </div>
1349
+ </div>
1350
+
1351
+ <div>
1352
+ <div className="text-xs font-medium uppercase text-muted-foreground">
1353
+ {t('details.entriesTitle')}
1354
+ </div>
1355
+ <div className="mt-2 space-y-3">
1356
+ {(selectedTimesheet.entries ?? []).length ? (
1357
+ (selectedTimesheet.entries ?? []).map((entry) => (
1358
+ <div key={entry.id} className="rounded-lg border p-3">
1359
+ <div className="flex flex-wrap items-start justify-between gap-3">
1360
+ <div>
1361
+ <div className="font-medium">
1362
+ {entry.taskName ||
1363
+ entry.activityLabel ||
1364
+ commonT('labels.unassigned')}
1365
+ </div>
1366
+ <div className="text-xs text-muted-foreground">
1367
+ {[entry.projectName, entry.roleLabel]
1368
+ .filter(Boolean)
1369
+ .join(' • ') || commonT('labels.unassigned')}
1370
+ </div>
1371
+ </div>
1372
+ <div className="text-sm text-muted-foreground">
1373
+ {formatEntryDuration(
1374
+ entry.durationMinutes,
1375
+ entry.hours
1376
+ )}
1377
+ </div>
1378
+ </div>
1379
+ <div className="mt-2 text-xs text-muted-foreground">
1380
+ {formatDateLabel(entry.workDate)}
1381
+ </div>
1382
+ <div className="mt-2 text-sm">
1383
+ {entry.description || commonT('labels.noNotes')}
1384
+ </div>
1385
+ </div>
1386
+ ))
1387
+ ) : (
1388
+ <div className="rounded-lg border p-3 text-sm text-muted-foreground">
1389
+ {t('details.noEntries')}
1390
+ </div>
1391
+ )}
1392
+ </div>
1393
+ </div>
1394
+ </div>
1395
+ ) : null}
1396
+ </SheetContent>
1397
+ </Sheet>
1398
+
1094
1399
  <AlertDialog
1095
1400
  open={Boolean(entryToDelete)}
1096
1401
  onOpenChange={(open) => {