@hed-hog/operations 0.0.306 → 0.0.310

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 (123) 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 +31 -4
  22. package/dist/controllers/operations-timesheets.controller.d.ts.map +1 -1
  23. package/dist/controllers/operations-timesheets.controller.js +16 -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/operations.service.d.ts +340 -271
  66. package/dist/operations.service.d.ts.map +1 -1
  67. package/dist/operations.service.js +1007 -1043
  68. package/dist/operations.service.js.map +1 -1
  69. package/dist/operations.service.spec.js +0 -22
  70. package/dist/operations.service.spec.js.map +1 -1
  71. package/hedhog/data/menu.yaml +0 -36
  72. package/hedhog/data/route.yaml +42 -73
  73. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +8 -1
  74. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +15 -10
  75. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +108 -213
  76. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +251 -2039
  77. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +167 -60
  78. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +70 -301
  79. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +102 -51
  80. package/hedhog/frontend/app/_lib/types.ts.ejs +19 -24
  81. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +14 -9
  82. package/hedhog/frontend/app/approvals/page.tsx.ejs +842 -150
  83. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +445 -153
  84. package/hedhog/frontend/app/collaborators/page.tsx.ejs +118 -49
  85. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +2 -2
  86. package/hedhog/frontend/app/contracts/page.tsx.ejs +215 -617
  87. package/hedhog/frontend/app/departments/page.tsx.ejs +257 -113
  88. package/hedhog/frontend/app/projects/page.tsx.ejs +90 -51
  89. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +412 -147
  90. package/hedhog/frontend/app/time-off/page.tsx.ejs +400 -123
  91. package/hedhog/frontend/app/timesheets/page.tsx.ejs +460 -365
  92. package/hedhog/frontend/messages/en.json +143 -14
  93. package/hedhog/frontend/messages/pt.json +192 -54
  94. package/hedhog/table/operations_contract.yaml +0 -9
  95. package/package.json +4 -4
  96. package/src/controllers/operations-approvals.controller.ts +9 -3
  97. package/src/controllers/operations-collaborators.controller.ts +15 -2
  98. package/src/controllers/operations-contracts.controller.ts +8 -92
  99. package/src/controllers/operations-org-structure.controller.ts +17 -4
  100. package/src/controllers/operations-projects.controller.ts +10 -4
  101. package/src/controllers/operations-timesheets.controller.ts +17 -8
  102. package/src/dto/list-approvals.dto.ts +12 -0
  103. package/src/dto/list-collaborator-types.dto.ts +7 -2
  104. package/src/dto/list-collaborators.dto.ts +4 -0
  105. package/src/dto/list-contracts.dto.ts +20 -0
  106. package/src/dto/list-departments.dto.ts +8 -0
  107. package/src/dto/list-projects.dto.ts +8 -0
  108. package/src/dto/list-schedule-adjustments.dto.ts +8 -0
  109. package/src/dto/list-time-off-requests.dto.ts +8 -0
  110. package/src/dto/list-timesheets.dto.ts +8 -0
  111. package/src/dto/reorder-collaborator-types.dto.ts +10 -0
  112. package/src/operations.service.spec.ts +0 -30
  113. package/src/operations.service.ts +1557 -1806
  114. package/hedhog/frontend/app/_components/contract-creation-wizard.tsx.ejs +0 -631
  115. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +0 -526
  116. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +0 -247
  117. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +0 -3520
  118. package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +0 -380
  119. package/hedhog/frontend/app/team/page.tsx.ejs +0 -352
  120. package/hedhog/table/operations_contract_financial_term.yaml +0 -40
  121. package/hedhog/table/operations_contract_revision.yaml +0 -38
  122. package/hedhog/table/operations_contract_signature.yaml +0 -38
  123. 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,12 +51,12 @@ 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
- Pencil,
58
58
  Plus,
59
59
  Send,
60
- Sparkles,
61
- Trash2,
62
60
  } from 'lucide-react';
63
61
  import { useTranslations } from 'next-intl';
64
62
  import { useEffect, useMemo, useRef, useState } from 'react';
@@ -100,7 +98,10 @@ type QuickEntryFormValues = {
100
98
  description: string;
101
99
  };
102
100
 
103
- 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
+ };
104
105
 
105
106
  const buildDefaultFormValues = (): QuickEntryFormValues => ({
106
107
  projectId: undefined,
@@ -146,7 +147,13 @@ function formatDateLabel(value?: string | null) {
146
147
  return '—';
147
148
  }
148
149
 
149
- 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`);
150
157
 
151
158
  if (Number.isNaN(date.getTime())) {
152
159
  return value;
@@ -196,69 +203,6 @@ function buildFollowUpFormValues(
196
203
  };
197
204
  }
198
205
 
199
- function buildProjectOptionFromEntry(
200
- entry: OperationsTimesheetEntry
201
- ): OperationsProjectOption | null {
202
- if (!entry.projectId) {
203
- return null;
204
- }
205
-
206
- return {
207
- id: entry.projectId,
208
- label:
209
- [entry.projectCode, entry.projectName, entry.roleLabel]
210
- .filter(Boolean)
211
- .join(' • ') || String(entry.projectId),
212
- name: entry.projectName || String(entry.projectId),
213
- code: entry.projectCode,
214
- projectAssignmentId: entry.projectAssignmentId,
215
- roleLabel: entry.roleLabel,
216
- status: 'active',
217
- };
218
- }
219
-
220
- function buildTaskOptionFromEntry(
221
- entry: OperationsTimesheetEntry
222
- ): OperationsTaskOption | null {
223
- if (!entry.taskId || !entry.projectId || !entry.projectAssignmentId) {
224
- return null;
225
- }
226
-
227
- return {
228
- id: entry.taskId,
229
- label:
230
- [entry.taskName || entry.activityLabel, entry.projectName]
231
- .filter(Boolean)
232
- .join(' • ') || String(entry.taskId),
233
- name: entry.taskName || entry.activityLabel || String(entry.taskId),
234
- status: 'todo',
235
- projectId: entry.projectId,
236
- projectAssignmentId: entry.projectAssignmentId,
237
- projectName: entry.projectName || '',
238
- projectCode: entry.projectCode,
239
- };
240
- }
241
-
242
- function buildEditFormValues(
243
- entry: OperationsTimesheetEntry
244
- ): QuickEntryFormValues {
245
- const durationMinutes =
246
- typeof entry.durationMinutes === 'number' &&
247
- Number.isFinite(entry.durationMinutes)
248
- ? entry.durationMinutes
249
- : Math.round(Number(entry.hours ?? 0) * 60);
250
- const useHours = durationMinutes > 0 && durationMinutes % 60 === 0;
251
-
252
- return {
253
- projectId: entry.projectId ?? undefined,
254
- taskId: entry.taskId ?? undefined,
255
- workDate: entry.workDate ? entry.workDate.slice(0, 10) : '',
256
- duration: useHours ? durationMinutes / 60 : durationMinutes,
257
- unit: useHours ? 'hours' : 'minutes',
258
- description: entry.description ?? '',
259
- };
260
- }
261
-
262
206
  export default function OperationsTimesheetsPage() {
263
207
  const t = useTranslations('operations.TimesheetsPage');
264
208
  const commonT = useTranslations('operations.Common');
@@ -266,13 +210,25 @@ export default function OperationsTimesheetsPage() {
266
210
  const access = useOperationsAccess();
267
211
  const [search, setSearch] = useState('');
268
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
+ });
269
222
  const [isSheetOpen, setIsSheetOpen] = useState(false);
223
+ const [isDetailsOpen, setIsDetailsOpen] = useState(false);
270
224
  const [isTaskCreateSheetOpen, setIsTaskCreateSheetOpen] = useState(false);
271
225
  const [keepContextOnSave, setKeepContextOnSave] = useState(true);
272
226
  const [editingEntry, setEditingEntry] =
273
227
  useState<OperationsTimesheetEntry | null>(null);
274
228
  const [entryToDelete, setEntryToDelete] =
275
229
  useState<OperationsTimesheetEntry | null>(null);
230
+ const [selectedTimesheet, setSelectedTimesheet] =
231
+ useState<OperationsTimesheet | null>(null);
276
232
  const [isDeletingEntry, setIsDeletingEntry] = useState(false);
277
233
  const [projectQuery, setProjectQuery] = useState({ search: '', page: 1 });
278
234
  const [projectOptions, setProjectOptions] = useState<
@@ -321,13 +277,42 @@ export default function OperationsTimesheetsPage() {
321
277
  name: 'unit',
322
278
  });
323
279
 
324
- const { data: timesheets = [], refetch } = useQuery<OperationsTimesheet[]>({
325
- queryKey: ['operations-timesheets', currentLocaleCode],
326
- queryFn: () =>
327
- fetchOperations<OperationsTimesheet[]>(request, '/operations/timesheets'),
328
- 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,
329
304
  });
330
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
+
331
316
  const { data: me } = useQuery<OperationsCollaborator>({
332
317
  queryKey: ['operations-timesheets-me', currentLocaleCode],
333
318
  enabled: access.isCollaborator,
@@ -338,11 +323,9 @@ export default function OperationsTimesheetsPage() {
338
323
  ),
339
324
  });
340
325
 
341
- const {
342
- data: recentEntriesResponse,
343
- isLoading: isRecentEntriesLoading,
344
- refetch: refetchRecentEntries,
345
- } = useQuery<PaginatedResponse<OperationsTimesheetEntry>>({
326
+ const { refetch: refetchRecentEntries } = useQuery<
327
+ PaginatedResponse<OperationsTimesheetEntry>
328
+ >({
346
329
  queryKey: [
347
330
  'operations-timesheet-recent-entries',
348
331
  currentLocaleCode,
@@ -532,7 +515,6 @@ export default function OperationsTimesheetsPage() {
532
515
  );
533
516
  }, [showToastHandler, t, taskOptionsError]);
534
517
 
535
- const recentEntries = recentEntriesResponse?.data ?? [];
536
518
  const filteredTaskOptions = selectedProjectId
537
519
  ? taskOptions.filter((option) => option.projectId === selectedProjectId)
538
520
  : [];
@@ -540,34 +522,7 @@ export default function OperationsTimesheetsPage() {
540
522
  filteredTaskOptions.find((option) => option.id === selectedTaskId) ?? null;
541
523
  const isEditingEntry = Boolean(editingEntry);
542
524
 
543
- const filteredRows = useMemo(
544
- () =>
545
- timesheets.filter((item) => {
546
- const matchesSearch = !search.trim()
547
- ? true
548
- : [
549
- item.collaboratorName,
550
- item.approverName,
551
- item.notes,
552
- ...((item.entries ?? []).flatMap((entry) => [
553
- entry.projectName,
554
- entry.taskName,
555
- entry.activityLabel,
556
- entry.description,
557
- ]) as Array<string | undefined>),
558
- ]
559
- .filter(Boolean)
560
- .some((value) =>
561
- String(value)
562
- .toLowerCase()
563
- .includes(search.trim().toLowerCase())
564
- );
565
- const matchesStatus =
566
- statusFilter === 'all' ? true : item.status === statusFilter;
567
- return matchesSearch && matchesStatus;
568
- }),
569
- [timesheets, search, statusFilter]
570
- );
525
+ const filteredRows = timesheets;
571
526
 
572
527
  const cards = [
573
528
  {
@@ -606,20 +561,6 @@ export default function OperationsTimesheetsPage() {
606
561
  setIsSheetOpen(true);
607
562
  };
608
563
 
609
- const openEditEntry = (entry: OperationsTimesheetEntry) => {
610
- const projectOption = buildProjectOptionFromEntry(entry);
611
- const taskOption = buildTaskOptionFromEntry(entry);
612
-
613
- setEditingEntry(entry);
614
- form.reset(buildEditFormValues(entry));
615
- setProjectQuery({ search: '', page: 1 });
616
- setTaskQuery({ search: '', page: 1 });
617
- setProjectOptions(projectOption ? [projectOption] : []);
618
- setTaskOptions(taskOption ? [taskOption] : []);
619
- setIsTaskCreateSheetOpen(false);
620
- setIsSheetOpen(true);
621
- };
622
-
623
564
  const closeCreateSheet = () => {
624
565
  setIsSheetOpen(false);
625
566
  setEditingEntry(null);
@@ -631,6 +572,11 @@ export default function OperationsTimesheetsPage() {
631
572
  setTaskOptions([]);
632
573
  };
633
574
 
575
+ const openDetails = (timesheet: OperationsTimesheet) => {
576
+ setSelectedTimesheet(timesheet);
577
+ setIsDetailsOpen(true);
578
+ };
579
+
634
580
  const canManageRow = (timesheet: OperationsTimesheet) => {
635
581
  return Boolean(
636
582
  me?.id &&
@@ -639,14 +585,6 @@ export default function OperationsTimesheetsPage() {
639
585
  );
640
586
  };
641
587
 
642
- const canManageEntry = (entry: OperationsTimesheetEntry) => {
643
- return Boolean(
644
- me?.id &&
645
- entry.collaboratorId === me.id &&
646
- ['draft', 'rejected'].includes(entry.status ?? '')
647
- );
648
- };
649
-
650
588
  const handleQuickEntrySubmit = async (values: QuickEntryFormValues) => {
651
589
  const payload = {
652
590
  projectId: values.projectId,
@@ -781,251 +719,260 @@ export default function OperationsTimesheetsPage() {
781
719
  }
782
720
  />
783
721
 
784
- <SearchBar
785
- searchQuery={search}
786
- onSearchChange={setSearch}
787
- onSearch={() => undefined}
788
- placeholder={t('searchPlaceholder')}
789
- controls={[
790
- {
791
- id: 'status',
792
- type: 'select',
793
- value: statusFilter,
794
- onChange: setStatusFilter,
795
- placeholder: commonT('labels.status'),
796
- options: [
797
- { value: 'all', label: commonT('filters.allStatuses') },
798
- { value: 'draft', label: formatEnumLabel('draft') },
799
- { value: 'submitted', label: formatEnumLabel('submitted') },
800
- { value: 'approved', label: formatEnumLabel('approved') },
801
- { value: 'rejected', label: formatEnumLabel('rejected') },
802
- ],
803
- },
804
- ]}
805
- />
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>
806
777
 
807
778
  <KpiCardsGrid items={cards} columns={3} />
808
779
 
809
- {access.isCollaborator ? (
810
- <Card>
811
- <CardHeader className="border-b">
812
- <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
813
- <div className="flex items-start gap-3">
814
- <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
815
- <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
+ />
816
808
  </div>
817
-
818
- <div className="space-y-1">
819
- <CardTitle>{t('recentEntries.title')}</CardTitle>
820
- <CardDescription>
821
- {t('recentEntries.description')}
822
- </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>
823
822
  </div>
824
- </div>
825
-
826
- <Button size="sm" onClick={openCreate}>
827
- <Plus className="size-4" />
828
- {t('sheet.createTitle')}
829
- </Button>
830
- </div>
831
- </CardHeader>
832
-
833
- <CardContent className="px-0">
834
- {isRecentEntriesLoading ? (
835
- <div className="px-6 py-6 text-sm text-muted-foreground">
836
- {t('recentEntries.loading')}
837
- </div>
838
- ) : recentEntries.length > 0 ? (
839
- <div className="divide-y">
840
- {recentEntries.map((entry) => {
841
- const isEditableEntry = canManageEntry(entry);
842
-
843
- return (
844
- <div
845
- key={entry.id}
846
- className={`flex flex-col gap-3 px-6 py-4 sm:flex-row sm:items-center sm:justify-between ${
847
- isEditableEntry
848
- ? 'cursor-pointer hover:bg-muted/30'
849
- : ''
850
- }`}
851
- onClick={
852
- isEditableEntry ? () => openEditEntry(entry) : undefined
853
- }
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)}
854
838
  >
855
- <div className="min-w-0 space-y-1">
856
- <div className="flex flex-wrap items-center gap-2">
857
- <span className="font-medium text-foreground">
858
- {[entry.projectCode, entry.projectName]
859
- .filter(Boolean)
860
- .join(' ') || commonT('labels.unassigned')}
861
- </span>
862
- <span className="text-muted-foreground">•</span>
863
- <span className="text-sm text-muted-foreground">
864
- {entry.taskName ||
865
- entry.activityLabel ||
866
- commonT('labels.noNotes')}
867
- </span>
868
- </div>
869
-
870
- <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
871
- <span>{formatDateLabel(entry.workDate)}</span>
872
- {entry.description ? (
873
- <>
874
- <span>•</span>
875
- <span className="truncate">
876
- {entry.description}
877
- </span>
878
- </>
879
- ) : null}
880
- </div>
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
+ )}
863
+ </div>
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}
881
887
  </div>
882
-
883
- <div className="flex items-center gap-2 self-start sm:self-center">
884
- {entry.status ? (
885
- <StatusBadge
886
- label={formatEnumLabel(entry.status)}
887
- className={getStatusBadgeClass(entry.status)}
888
- />
889
- ) : null}
890
-
891
- <span className="inline-flex items-center rounded-md bg-muted px-2.5 py-1 text-xs font-medium text-foreground">
892
- {formatEntryDuration(
893
- entry.durationMinutes,
894
- entry.hours
895
- )}
896
- </span>
897
-
898
- {isEditableEntry ? (
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) ? (
899
940
  <>
900
941
  <Button
901
- type="button"
902
- variant="ghost"
903
- size="icon"
904
- className="text-muted-foreground"
905
- onClick={(event) => {
906
- event.stopPropagation();
907
- openEditEntry(entry);
908
- }}
909
- aria-label={t('sheet.editTitle')}
942
+ variant="outline"
943
+ size="sm"
944
+ className="cursor-pointer"
945
+ onClick={() => openDetails(timesheet)}
910
946
  >
911
- <Pencil className="size-4" />
947
+ <Eye className="size-4" />
948
+ {t('actions.viewDetails')}
912
949
  </Button>
913
950
  <Button
914
- type="button"
915
- variant="ghost"
916
951
  size="icon"
917
- className="text-muted-foreground hover:text-destructive"
918
- onClick={(event) => {
919
- event.stopPropagation();
920
- setEntryToDelete(entry);
921
- }}
922
- aria-label={t('messages.confirmDeleteTitle')}
952
+ onClick={() => void submitTimesheet(timesheet.id)}
923
953
  >
924
- <Trash2 className="size-4" />
954
+ <Send className="size-4" />
925
955
  </Button>
926
956
  </>
927
- ) : null}
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>
967
+ )}
928
968
  </div>
929
- </div>
930
- );
931
- })}
932
- </div>
933
- ) : (
934
- <div className="px-6 py-6 text-sm text-muted-foreground">
935
- {t('recentEntries.empty')}
936
- </div>
937
- )}
938
- </CardContent>
939
- </Card>
940
- ) : null}
941
-
942
- {filteredRows.length > 0 ? (
943
- <div className="overflow-x-auto rounded-md border">
944
- <Table>
945
- <TableHeader>
946
- <TableRow>
947
- <TableHead>{commonT('labels.collaborator')}</TableHead>
948
- <TableHead>{commonT('labels.week')}</TableHead>
949
- <TableHead>{commonT('labels.entries')}</TableHead>
950
- <TableHead>{commonT('labels.totalHours')}</TableHead>
951
- <TableHead>{commonT('labels.approver')}</TableHead>
952
- <TableHead>{commonT('labels.decisionNote')}</TableHead>
953
- <TableHead>{commonT('labels.status')}</TableHead>
954
- <TableHead>{commonT('labels.actions')}</TableHead>
955
- </TableRow>
956
- </TableHeader>
957
- <TableBody>
958
- {filteredRows.map((timesheet) => (
959
- <TableRow key={timesheet.id} className="hover:bg-muted/30">
960
- <TableCell>
961
- <div className="font-medium">
962
- {timesheet.collaboratorName}
963
- </div>
964
- <div className="text-xs text-muted-foreground">
965
- {timesheet.notes || commonT('labels.noNotes')}
966
- </div>
967
- </TableCell>
968
- <TableCell>
969
- {formatDateRange(
970
- timesheet.weekStartDate,
971
- timesheet.weekEndDate
972
- )}
973
- </TableCell>
974
- <TableCell>
975
- <div className="font-medium">
976
- {timesheet.entries?.length ?? 0} {commonT('labels.lines')}
977
- </div>
978
- <div className="text-xs text-muted-foreground">
979
- {(timesheet.entries ?? [])
980
- .slice(0, 2)
981
- .map(
982
- (entry) =>
983
- [
984
- entry.projectName,
985
- entry.taskName || entry.activityLabel,
986
- ]
987
- .filter(Boolean)
988
- .join(' • ') || commonT('labels.unassigned')
989
- )
990
- .join(', ') || commonT('labels.unassigned')}
991
- </div>
992
- </TableCell>
993
- <TableCell>{formatHours(timesheet.totalHours)}</TableCell>
994
- <TableCell>
995
- {timesheet.approverName || commonT('labels.notAssigned')}
996
- </TableCell>
997
- <TableCell>
998
- <div className="text-sm text-muted-foreground">
999
- {timesheet.decisionNote || commonT('labels.noNotes')}
1000
- </div>
1001
- </TableCell>
1002
- <TableCell>
1003
- <StatusBadge
1004
- label={formatEnumLabel(timesheet.status)}
1005
- className={getStatusBadgeClass(timesheet.status)}
1006
- />
1007
- </TableCell>
1008
- <TableCell>
1009
- <div className="flex justify-end gap-2">
1010
- {canManageRow(timesheet) ? (
1011
- <Button
1012
- size="icon"
1013
- onClick={() => void submitTimesheet(timesheet.id)}
1014
- >
1015
- <Send className="size-4" />
1016
- </Button>
1017
- ) : (
1018
- <span className="text-xs text-muted-foreground">
1019
- {commonT('labels.viewOnly')}
1020
- </span>
1021
- )}
1022
- </div>
1023
- </TableCell>
1024
- </TableRow>
1025
- ))}
1026
- </TableBody>
1027
- </Table>
1028
- </div>
969
+ </TableCell>
970
+ </TableRow>
971
+ ))}
972
+ </TableBody>
973
+ </Table>
974
+ </div>
975
+ )
1029
976
  ) : (
1030
977
  <EmptyState
1031
978
  icon={<ClipboardList className="size-12" />}
@@ -1040,6 +987,18 @@ export default function OperationsTimesheetsPage() {
1040
987
  />
1041
988
  )}
1042
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
+
1043
1002
  <Sheet
1044
1003
  open={isSheetOpen}
1045
1004
  onOpenChange={(open) => {
@@ -1301,6 +1260,142 @@ export default function OperationsTimesheetsPage() {
1301
1260
  onCreated={handleTaskCreated}
1302
1261
  />
1303
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
+
1304
1399
  <AlertDialog
1305
1400
  open={Boolean(entryToDelete)}
1306
1401
  onOpenChange={(open) => {