@hed-hog/operations 0.0.303 → 0.0.305

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 (178) hide show
  1. package/README.md +200 -43
  2. package/dist/controllers/operations-approvals.controller.d.ts +9 -0
  3. package/dist/controllers/operations-approvals.controller.d.ts.map +1 -0
  4. package/dist/controllers/operations-approvals.controller.js +64 -0
  5. package/dist/controllers/operations-approvals.controller.js.map +1 -0
  6. package/dist/controllers/operations-collaborators.controller.d.ts +223 -0
  7. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -0
  8. package/dist/controllers/operations-collaborators.controller.js +96 -0
  9. package/dist/controllers/operations-collaborators.controller.js.map +1 -0
  10. package/dist/controllers/operations-contracts.controller.d.ts +683 -0
  11. package/dist/controllers/operations-contracts.controller.d.ts.map +1 -0
  12. package/dist/controllers/operations-contracts.controller.js +198 -0
  13. package/dist/controllers/operations-contracts.controller.js.map +1 -0
  14. package/dist/controllers/operations-org-structure.controller.d.ts +108 -0
  15. package/dist/controllers/operations-org-structure.controller.d.ts.map +1 -0
  16. package/dist/controllers/operations-org-structure.controller.js +143 -0
  17. package/dist/controllers/operations-org-structure.controller.js.map +1 -0
  18. package/dist/controllers/operations-projects.controller.d.ts +184 -0
  19. package/dist/controllers/operations-projects.controller.d.ts.map +1 -0
  20. package/dist/controllers/operations-projects.controller.js +87 -0
  21. package/dist/controllers/operations-projects.controller.js.map +1 -0
  22. package/dist/controllers/operations-tasks.controller.d.ts +85 -0
  23. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -0
  24. package/dist/controllers/operations-tasks.controller.js +90 -0
  25. package/dist/controllers/operations-tasks.controller.js.map +1 -0
  26. package/dist/controllers/operations-timesheets.controller.d.ts +99 -0
  27. package/dist/controllers/operations-timesheets.controller.d.ts.map +1 -0
  28. package/dist/controllers/operations-timesheets.controller.js +154 -0
  29. package/dist/controllers/operations-timesheets.controller.js.map +1 -0
  30. package/dist/dto/create-collaborator-type.dto.d.ts +10 -0
  31. package/dist/dto/create-collaborator-type.dto.d.ts.map +1 -0
  32. package/dist/dto/create-collaborator-type.dto.js +56 -0
  33. package/dist/dto/create-collaborator-type.dto.js.map +1 -0
  34. package/dist/dto/create-collaborator.dto.d.ts +42 -0
  35. package/dist/dto/create-collaborator.dto.d.ts.map +1 -0
  36. package/dist/dto/create-collaborator.dto.js +228 -0
  37. package/dist/dto/create-collaborator.dto.js.map +1 -0
  38. package/dist/dto/create-schedule-adjustment-request.dto.d.ts +17 -0
  39. package/dist/dto/create-schedule-adjustment-request.dto.d.ts.map +1 -0
  40. package/dist/dto/create-schedule-adjustment-request.dto.js +89 -0
  41. package/dist/dto/create-schedule-adjustment-request.dto.js.map +1 -0
  42. package/dist/dto/create-task.dto.d.ts +14 -0
  43. package/dist/dto/create-task.dto.d.ts.map +1 -0
  44. package/dist/dto/create-task.dto.js +83 -0
  45. package/dist/dto/create-task.dto.js.map +1 -0
  46. package/dist/dto/create-time-off-request.dto.d.ts +9 -0
  47. package/dist/dto/create-time-off-request.dto.d.ts.map +1 -0
  48. package/dist/dto/create-time-off-request.dto.js +54 -0
  49. package/dist/dto/create-time-off-request.dto.js.map +1 -0
  50. package/dist/dto/create-timesheet-entry.dto.d.ts +12 -0
  51. package/dist/dto/create-timesheet-entry.dto.d.ts.map +1 -0
  52. package/dist/dto/create-timesheet-entry.dto.js +75 -0
  53. package/dist/dto/create-timesheet-entry.dto.js.map +1 -0
  54. package/dist/dto/list-collaborator-types.dto.d.ts +4 -0
  55. package/dist/dto/list-collaborator-types.dto.d.ts.map +1 -0
  56. package/dist/dto/list-collaborator-types.dto.js +29 -0
  57. package/dist/dto/list-collaborator-types.dto.js.map +1 -0
  58. package/dist/dto/list-collaborators.dto.d.ts +8 -0
  59. package/dist/dto/list-collaborators.dto.d.ts.map +1 -0
  60. package/dist/dto/list-collaborators.dto.js +42 -0
  61. package/dist/dto/list-collaborators.dto.js.map +1 -0
  62. package/dist/dto/list-project-options.dto.d.ts +4 -0
  63. package/dist/dto/list-project-options.dto.d.ts.map +1 -0
  64. package/dist/dto/list-project-options.dto.js +8 -0
  65. package/dist/dto/list-project-options.dto.js.map +1 -0
  66. package/dist/dto/list-tasks.dto.d.ts +7 -0
  67. package/dist/dto/list-tasks.dto.d.ts.map +1 -0
  68. package/dist/dto/list-tasks.dto.js +38 -0
  69. package/dist/dto/list-tasks.dto.js.map +1 -0
  70. package/dist/dto/list-timesheet-entries.dto.d.ts +10 -0
  71. package/dist/dto/list-timesheet-entries.dto.d.ts.map +1 -0
  72. package/dist/dto/list-timesheet-entries.dto.js +54 -0
  73. package/dist/dto/list-timesheet-entries.dto.js.map +1 -0
  74. package/dist/dto/update-collaborator-type.dto.d.ts +4 -0
  75. package/dist/dto/update-collaborator-type.dto.d.ts.map +1 -0
  76. package/dist/dto/update-collaborator-type.dto.js +8 -0
  77. package/dist/dto/update-collaborator-type.dto.js.map +1 -0
  78. package/dist/dto/update-collaborator.dto.d.ts +4 -0
  79. package/dist/dto/update-collaborator.dto.d.ts.map +1 -0
  80. package/dist/dto/update-collaborator.dto.js +8 -0
  81. package/dist/dto/update-collaborator.dto.js.map +1 -0
  82. package/dist/dto/update-task.dto.d.ts +14 -0
  83. package/dist/dto/update-task.dto.d.ts.map +1 -0
  84. package/dist/dto/update-task.dto.js +84 -0
  85. package/dist/dto/update-task.dto.js.map +1 -0
  86. package/dist/operations.controller.d.ts +0 -1045
  87. package/dist/operations.controller.d.ts.map +1 -1
  88. package/dist/operations.controller.js +0 -429
  89. package/dist/operations.controller.js.map +1 -1
  90. package/dist/operations.module.d.ts.map +1 -1
  91. package/dist/operations.module.js +23 -2
  92. package/dist/operations.module.js.map +1 -1
  93. package/dist/operations.service.d.ts +429 -8
  94. package/dist/operations.service.d.ts.map +1 -1
  95. package/dist/operations.service.js +1931 -165
  96. package/dist/operations.service.js.map +1 -1
  97. package/dist/operations.service.spec.js +315 -1
  98. package/dist/operations.service.spec.js.map +1 -1
  99. package/dist/services/shared/operations-access.service.d.ts +16 -0
  100. package/dist/services/shared/operations-access.service.d.ts.map +1 -0
  101. package/dist/services/shared/operations-access.service.js +48 -0
  102. package/dist/services/shared/operations-access.service.js.map +1 -0
  103. package/hedhog/data/dashboard.yaml +20 -0
  104. package/hedhog/data/dashboard_component.yaml +274 -0
  105. package/hedhog/data/dashboard_component_role.yaml +174 -0
  106. package/hedhog/data/dashboard_item.yaml +299 -0
  107. package/hedhog/data/dashboard_role.yaml +20 -0
  108. package/hedhog/data/menu.yaml +30 -13
  109. package/hedhog/data/operations_collaborator_type.yaml +76 -0
  110. package/hedhog/data/route.yaml +196 -0
  111. package/hedhog/frontend/app/_components/async-options-combobox.tsx.ejs +231 -0
  112. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +125 -40
  113. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +740 -106
  114. package/hedhog/frontend/app/_components/collaborator-select-with-create.tsx.ejs +256 -256
  115. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +7 -7
  116. package/hedhog/frontend/app/_components/contract-template-form-screen.tsx.ejs +306 -306
  117. package/hedhog/frontend/app/_components/contract-template-select-with-create.tsx.ejs +247 -247
  118. package/hedhog/frontend/app/_components/contract-wizard-sheet.tsx.ejs +3520 -3520
  119. package/hedhog/frontend/app/_components/department-select-with-create.tsx.ejs +38 -16
  120. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +1504 -52
  121. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +1017 -649
  122. package/hedhog/frontend/app/_components/section-card.tsx.ejs +25 -18
  123. package/hedhog/frontend/app/_components/system-user-select-with-create.tsx.ejs +609 -0
  124. package/hedhog/frontend/app/_components/timesheet-task-create-sheet.tsx.ejs +213 -0
  125. package/hedhog/frontend/app/_lib/api.ts.ejs +30 -1
  126. package/hedhog/frontend/app/_lib/types.ts.ejs +147 -39
  127. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +40 -9
  128. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +48 -1
  129. package/hedhog/frontend/app/approvals/page.tsx.ejs +116 -98
  130. package/hedhog/frontend/app/collaborator-types/page.tsx.ejs +502 -0
  131. package/hedhog/frontend/app/collaborators/page.tsx.ejs +116 -72
  132. package/hedhog/frontend/app/contracts/page.tsx.ejs +938 -938
  133. package/hedhog/frontend/app/contracts/templates/page.tsx.ejs +11 -9
  134. package/hedhog/frontend/app/departments/page.tsx.ejs +1 -1
  135. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +1 -1
  136. package/hedhog/frontend/app/projects/page.tsx.ejs +364 -133
  137. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +244 -120
  138. package/hedhog/frontend/app/team/page.tsx.ejs +15 -2
  139. package/hedhog/frontend/app/time-off/page.tsx.ejs +158 -82
  140. package/hedhog/frontend/app/timesheets/page.tsx.ejs +814 -357
  141. package/hedhog/frontend/messages/en.json +268 -53
  142. package/hedhog/frontend/messages/pt.json +484 -271
  143. package/hedhog/table/operations_collaborator.yaml +26 -13
  144. package/hedhog/table/operations_collaborator_equity_participation.yaml +43 -0
  145. package/hedhog/table/operations_collaborator_type.yaml +33 -0
  146. package/hedhog/table/operations_job_title.yaml +24 -0
  147. package/hedhog/table/operations_project.yaml +9 -0
  148. package/hedhog/table/operations_project_assignment.yaml +9 -0
  149. package/hedhog/table/operations_project_role.yaml +39 -0
  150. package/hedhog/table/operations_task.yaml +69 -0
  151. package/hedhog/table/operations_timesheet_entry.yaml +12 -0
  152. package/package.json +6 -6
  153. package/src/controllers/operations-approvals.controller.ts +24 -0
  154. package/src/controllers/operations-collaborators.controller.ts +60 -0
  155. package/src/controllers/operations-contracts.controller.ts +138 -0
  156. package/src/controllers/operations-org-structure.controller.ts +92 -0
  157. package/src/controllers/operations-projects.controller.ts +50 -0
  158. package/src/controllers/operations-tasks.controller.ts +63 -0
  159. package/src/controllers/operations-timesheets.controller.ts +100 -0
  160. package/src/dto/create-collaborator-type.dto.ts +43 -0
  161. package/src/dto/create-collaborator.dto.ts +223 -0
  162. package/src/dto/create-schedule-adjustment-request.dto.ts +91 -0
  163. package/src/dto/create-task.dto.ts +75 -0
  164. package/src/dto/create-time-off-request.dto.ts +53 -0
  165. package/src/dto/create-timesheet-entry.dto.ts +67 -0
  166. package/src/dto/list-collaborator-types.dto.ts +15 -0
  167. package/src/dto/list-collaborators.dto.ts +30 -0
  168. package/src/dto/list-project-options.dto.ts +3 -0
  169. package/src/dto/list-tasks.dto.ts +25 -0
  170. package/src/dto/list-timesheet-entries.dto.ts +40 -0
  171. package/src/dto/update-collaborator-type.dto.ts +3 -0
  172. package/src/dto/update-collaborator.dto.ts +3 -0
  173. package/src/dto/update-task.dto.ts +76 -0
  174. package/src/operations.controller.ts +1 -278
  175. package/src/operations.module.ts +23 -2
  176. package/src/operations.service.spec.ts +450 -0
  177. package/src/operations.service.ts +4507 -1561
  178. package/src/services/shared/operations-access.service.ts +52 -0
@@ -1,16 +1,35 @@
1
1
  'use client';
2
2
 
3
3
  import { EmptyState, Page, SearchBar } from '@/components/entity-list';
4
+ import {
5
+ AlertDialog,
6
+ AlertDialogAction,
7
+ AlertDialogCancel,
8
+ AlertDialogContent,
9
+ AlertDialogDescription,
10
+ AlertDialogFooter,
11
+ AlertDialogHeader,
12
+ AlertDialogTitle,
13
+ } from '@/components/ui/alert-dialog';
4
14
  import { Button } from '@/components/ui/button';
15
+ import {
16
+ Card,
17
+ CardContent,
18
+ CardDescription,
19
+ CardHeader,
20
+ CardTitle,
21
+ } from '@/components/ui/card';
22
+ import {
23
+ Form,
24
+ FormControl,
25
+ FormField,
26
+ FormItem,
27
+ FormLabel,
28
+ FormMessage,
29
+ } from '@/components/ui/form';
30
+ import { FormActions } from '@/components/ui/form-actions';
5
31
  import { Input } from '@/components/ui/input';
6
32
  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
33
  import {
15
34
  Sheet,
16
35
  SheetContent,
@@ -18,6 +37,7 @@ import {
18
37
  SheetHeader,
19
38
  SheetTitle,
20
39
  } from '@/components/ui/sheet';
40
+ import { Switch } from '@/components/ui/switch';
21
41
  import {
22
42
  Table,
23
43
  TableBody,
@@ -27,82 +47,151 @@ import {
27
47
  TableRow,
28
48
  } from '@/components/ui/table';
29
49
  import { Textarea } from '@/components/ui/textarea';
50
+ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
30
51
  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';
52
+ import { zodResolver } from '@hookform/resolvers/zod';
53
+ import {
54
+ ClipboardList,
55
+ Clock3,
56
+ Loader2,
57
+ Plus,
58
+ Send,
59
+ Sparkles,
60
+ Trash2,
61
+ } from 'lucide-react';
33
62
  import { useTranslations } from 'next-intl';
63
+ import { useEffect, useMemo, useState } from 'react';
64
+ import { useForm, useWatch } from 'react-hook-form';
65
+ import { z } from 'zod';
66
+
67
+ import { AsyncOptionsCombobox } from '../_components/async-options-combobox';
34
68
  import { OperationsHeader } from '../_components/operations-header';
35
69
  import { StatusBadge } from '../_components/status-badge';
36
- import { fetchOperations, mutateOperations } from '../_lib/api';
70
+ import { TimesheetTaskCreateSheet } from '../_components/timesheet-task-create-sheet';
71
+ import {
72
+ fetchOperations,
73
+ getOperationsErrorMessage,
74
+ mutateOperations,
75
+ } from '../_lib/api';
37
76
  import { useOperationsAccess } from '../_lib/hooks/use-operations-access';
38
77
  import type {
39
78
  OperationsCollaborator,
40
- OperationsProject,
79
+ OperationsProjectOption,
80
+ OperationsTaskOption,
41
81
  OperationsTimesheet,
82
+ OperationsTimesheetEntry,
83
+ PaginatedResponse,
42
84
  } from '../_lib/types';
43
- import { parseNumberInput, trimToNull } from '../_lib/utils/forms';
44
85
  import {
45
86
  formatDateRange,
46
87
  formatEnumLabel,
47
88
  formatHours,
48
89
  getStatusBadgeClass,
49
90
  } from '../_lib/utils/format';
91
+ import { trimToNull } from '../_lib/utils/forms';
50
92
 
51
- type TimesheetEntryFormState = {
52
- projectAssignmentId: string;
53
- activityLabel: string;
93
+ type QuickEntryFormValues = {
94
+ projectId?: number;
95
+ taskId?: number;
54
96
  workDate: string;
55
- hours: string;
97
+ duration?: number;
98
+ unit: 'hours' | 'minutes';
56
99
  description: string;
57
100
  };
58
101
 
59
- type TimesheetFormState = {
60
- weekStartDate: string;
61
- weekEndDate: string;
62
- notes: string;
63
- entries: TimesheetEntryFormState[];
64
- };
102
+ const getTodayDate = () => new Date().toISOString().slice(0, 10);
65
103
 
66
- const createEmptyEntry = (): TimesheetEntryFormState => ({
67
- projectAssignmentId: 'none',
68
- activityLabel: '',
69
- workDate: '',
70
- hours: '',
104
+ const buildDefaultFormValues = (): QuickEntryFormValues => ({
105
+ projectId: undefined,
106
+ taskId: undefined,
107
+ workDate: getTodayDate(),
108
+ duration: undefined,
109
+ unit: 'hours',
71
110
  description: '',
72
111
  });
73
112
 
74
- const emptyForm: TimesheetFormState = {
75
- weekStartDate: '',
76
- weekEndDate: '',
77
- notes: '',
78
- entries: [createEmptyEntry()],
79
- };
113
+ const positiveNumberField = (message: string) =>
114
+ z.preprocess(
115
+ (value) => {
116
+ if (value === '' || value === null || value === undefined) {
117
+ return undefined;
118
+ }
119
+
120
+ if (typeof value === 'number') {
121
+ return value;
122
+ }
123
+
124
+ const parsed = Number(value);
125
+ return Number.isNaN(parsed) ? value : parsed;
126
+ },
127
+ z.number({ required_error: message }).positive(message)
128
+ );
129
+
130
+ function appendUniqueById<T extends { id: number | string }>(
131
+ current: T[],
132
+ next: T[]
133
+ ) {
134
+ const unique = new Map<string, T>();
135
+
136
+ [...current, ...next].forEach((item) => {
137
+ unique.set(String(item.id), item);
138
+ });
139
+
140
+ return Array.from(unique.values());
141
+ }
142
+
143
+ function formatDateLabel(value?: string | null) {
144
+ if (!value) {
145
+ return '—';
146
+ }
147
+
148
+ const date = new Date(`${value}T00:00:00`);
149
+
150
+ if (Number.isNaN(date.getTime())) {
151
+ return value;
152
+ }
153
+
154
+ return new Intl.DateTimeFormat(undefined, {
155
+ day: '2-digit',
156
+ month: 'short',
157
+ year: 'numeric',
158
+ }).format(date);
159
+ }
160
+
161
+ function formatEntryDuration(minutes?: number | null, hours?: number | null) {
162
+ if (typeof minutes === 'number' && Number.isFinite(minutes) && minutes > 0) {
163
+ const wholeHours = Math.floor(minutes / 60);
164
+ const remainingMinutes = minutes % 60;
165
+
166
+ if (!wholeHours) {
167
+ return `${remainingMinutes}m`;
168
+ }
80
169
 
81
- function toFormState(
82
- timesheet?: OperationsTimesheet | null
83
- ): TimesheetFormState {
84
- if (!timesheet) {
85
- return emptyForm;
170
+ if (!remainingMinutes) {
171
+ return `${wholeHours}h`;
172
+ }
173
+
174
+ return `${wholeHours}h ${remainingMinutes}m`;
175
+ }
176
+
177
+ return formatHours(Number(hours ?? 0));
178
+ }
179
+
180
+ function buildFollowUpFormValues(
181
+ current: QuickEntryFormValues,
182
+ keepContextOnSave: boolean
183
+ ): QuickEntryFormValues {
184
+ if (!keepContextOnSave) {
185
+ return buildDefaultFormValues();
86
186
  }
87
187
 
88
188
  return {
89
- weekStartDate: timesheet.weekStartDate ?? '',
90
- weekEndDate: timesheet.weekEndDate ?? '',
91
- notes: timesheet.notes ?? '',
92
- entries: timesheet.entries?.length
93
- ? timesheet.entries.map((entry) => ({
94
- projectAssignmentId: entry.projectAssignmentId
95
- ? String(entry.projectAssignmentId)
96
- : 'none',
97
- activityLabel: entry.activityLabel ?? '',
98
- workDate: entry.workDate ?? '',
99
- hours:
100
- entry.hours !== null && entry.hours !== undefined
101
- ? String(entry.hours)
102
- : '',
103
- description: entry.description ?? '',
104
- }))
105
- : [createEmptyEntry()],
189
+ projectId: current.projectId,
190
+ taskId: current.taskId,
191
+ workDate: current.workDate || getTodayDate(),
192
+ duration: 0,
193
+ unit: current.unit ?? 'hours',
194
+ description: '',
106
195
  };
107
196
  }
108
197
 
@@ -114,14 +203,62 @@ export default function OperationsTimesheetsPage() {
114
203
  const [search, setSearch] = useState('');
115
204
  const [statusFilter, setStatusFilter] = useState('all');
116
205
  const [isSheetOpen, setIsSheetOpen] = useState(false);
117
- const [editingTimesheet, setEditingTimesheet] =
118
- useState<OperationsTimesheet | null>(null);
119
- const [form, setForm] = useState<TimesheetFormState>(emptyForm);
206
+ const [isTaskCreateSheetOpen, setIsTaskCreateSheetOpen] = useState(false);
207
+ const [keepContextOnSave, setKeepContextOnSave] = useState(true);
208
+ const [entryToDelete, setEntryToDelete] =
209
+ useState<OperationsTimesheetEntry | null>(null);
210
+ const [isDeletingEntry, setIsDeletingEntry] = useState(false);
211
+ const [projectQuery, setProjectQuery] = useState({ search: '', page: 1 });
212
+ const [projectOptions, setProjectOptions] = useState<
213
+ OperationsProjectOption[]
214
+ >([]);
215
+ const [taskQuery, setTaskQuery] = useState({ search: '', page: 1 });
216
+ const [taskRefreshKey, setTaskRefreshKey] = useState(0);
217
+ const [taskOptions, setTaskOptions] = useState<OperationsTaskOption[]>([]);
218
+
219
+ const quickEntrySchema = useMemo(
220
+ () =>
221
+ z.object({
222
+ projectId: positiveNumberField(t('messages.selectProjectRequired')),
223
+ taskId: positiveNumberField(t('messages.selectTaskRequired')),
224
+ workDate: z.string().min(1, t('messages.requiredFields')),
225
+ duration: positiveNumberField(t('messages.entryValidation')),
226
+ unit: z.enum(['hours', 'minutes']),
227
+ description: z
228
+ .string()
229
+ .trim()
230
+ .max(500, t('sheet.validation.descriptionMaxLength'))
231
+ .optional()
232
+ .or(z.literal('')),
233
+ }),
234
+ [t]
235
+ );
236
+
237
+ const form = useForm<QuickEntryFormValues>({
238
+ resolver: zodResolver(quickEntrySchema),
239
+ defaultValues: buildDefaultFormValues(),
240
+ });
241
+
242
+ const selectedProjectId = useWatch({
243
+ control: form.control,
244
+ name: 'projectId',
245
+ });
246
+
247
+ const selectedTaskId = useWatch({
248
+ control: form.control,
249
+ name: 'taskId',
250
+ });
251
+
252
+ const unitValue = useWatch({
253
+ control: form.control,
254
+ name: 'unit',
255
+ });
120
256
 
121
257
  const { data: timesheets = [], refetch } = useQuery<OperationsTimesheet[]>({
122
258
  queryKey: ['operations-timesheets', currentLocaleCode],
123
259
  queryFn: () =>
124
260
  fetchOperations<OperationsTimesheet[]>(request, '/operations/timesheets'),
261
+ placeholderData: (previous) => previous ?? [],
125
262
  });
126
263
 
127
264
  const { data: me } = useQuery<OperationsCollaborator>({
@@ -134,25 +271,137 @@ export default function OperationsTimesheetsPage() {
134
271
  ),
135
272
  });
136
273
 
137
- const { data: projects = [] } = useQuery<OperationsProject[]>({
138
- queryKey: ['operations-timesheet-project-options', currentLocaleCode],
274
+ const {
275
+ data: recentEntriesResponse,
276
+ isLoading: isRecentEntriesLoading,
277
+ refetch: refetchRecentEntries,
278
+ } = useQuery<PaginatedResponse<OperationsTimesheetEntry>>({
279
+ queryKey: [
280
+ 'operations-timesheet-recent-entries',
281
+ currentLocaleCode,
282
+ search,
283
+ statusFilter,
284
+ ],
139
285
  enabled: access.isCollaborator,
140
- queryFn: () =>
141
- fetchOperations<OperationsProject[]>(request, '/operations/projects'),
286
+ queryFn: () => {
287
+ const params = new URLSearchParams({
288
+ page: '1',
289
+ pageSize: '8',
290
+ sortField: 'workDate',
291
+ sortOrder: 'desc',
292
+ });
293
+
294
+ if (search.trim()) {
295
+ params.set('search', search.trim());
296
+ }
297
+
298
+ if (statusFilter !== 'all') {
299
+ params.set('status', statusFilter);
300
+ }
301
+
302
+ return fetchOperations<PaginatedResponse<OperationsTimesheetEntry>>(
303
+ request,
304
+ `/operations/timesheet-entries?${params.toString()}`
305
+ );
306
+ },
307
+ placeholderData: (previous) => previous,
142
308
  });
143
309
 
144
- const projectOptions = useMemo(
145
- () =>
146
- projects
147
- .filter((project) => project.myAssignmentId)
148
- .map((project) => ({
149
- value: String(project.myAssignmentId),
150
- label: [project.name, project.myRoleLabel]
151
- .filter(Boolean)
152
- .join(' '),
153
- })),
154
- [projects]
155
- );
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
+ }
328
+
329
+ return fetchOperations<PaginatedResponse<OperationsProjectOption>>(
330
+ request,
331
+ `/operations/projects/options?${params.toString()}`
332
+ );
333
+ },
334
+ placeholderData: (previous) => previous,
335
+ });
336
+
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
+ }
358
+
359
+ if (selectedProjectId) {
360
+ params.set('projectId', String(selectedProjectId));
361
+ }
362
+
363
+ return fetchOperations<PaginatedResponse<OperationsTaskOption>>(
364
+ request,
365
+ `/operations/tasks?${params.toString()}`
366
+ );
367
+ },
368
+ placeholderData: (previous) => previous,
369
+ });
370
+
371
+ useEffect(() => {
372
+ if (!projectOptionsResponse) {
373
+ return;
374
+ }
375
+
376
+ setProjectOptions((current) =>
377
+ projectQuery.page === 1
378
+ ? (projectOptionsResponse.data ?? [])
379
+ : appendUniqueById(current, projectOptionsResponse.data ?? [])
380
+ );
381
+ }, [projectOptionsResponse, projectQuery.page]);
382
+
383
+ useEffect(() => {
384
+ if (!selectedProjectId) {
385
+ setTaskOptions([]);
386
+ return;
387
+ }
388
+
389
+ if (!taskOptionsResponse) {
390
+ return;
391
+ }
392
+
393
+ setTaskOptions((current) =>
394
+ taskQuery.page === 1
395
+ ? (taskOptionsResponse.data ?? [])
396
+ : appendUniqueById(current, taskOptionsResponse.data ?? [])
397
+ );
398
+ }, [selectedProjectId, taskOptionsResponse, taskQuery.page]);
399
+
400
+ const recentEntries = recentEntriesResponse?.data ?? [];
401
+ const selectedProject =
402
+ projectOptions.find((option) => option.id === selectedProjectId) ?? null;
403
+ const selectedTask =
404
+ taskOptions.find((option) => option.id === selectedTaskId) ?? null;
156
405
 
157
406
  const filteredRows = useMemo(
158
407
  () =>
@@ -165,6 +414,8 @@ export default function OperationsTimesheetsPage() {
165
414
  item.notes,
166
415
  ...((item.entries ?? []).flatMap((entry) => [
167
416
  entry.projectName,
417
+ entry.taskName,
418
+ entry.activityLabel,
168
419
  entry.description,
169
420
  ]) as Array<string | undefined>),
170
421
  ]
@@ -185,16 +436,19 @@ export default function OperationsTimesheetsPage() {
185
436
  {
186
437
  key: 'all',
187
438
  title: t('cards.visible'),
439
+ description: t('cards.visibleDescription'),
188
440
  value: timesheets.length,
189
441
  },
190
442
  {
191
443
  key: 'pending',
192
444
  title: t('cards.pending'),
445
+ description: t('cards.pendingDescription'),
193
446
  value: timesheets.filter((item) => item.status === 'submitted').length,
194
447
  },
195
448
  {
196
449
  key: 'hours',
197
450
  title: t('cards.hours'),
451
+ description: t('cards.hoursDescription'),
198
452
  value: formatHours(
199
453
  timesheets.reduce(
200
454
  (total, item) => total + Number(item.totalHours ?? 0),
@@ -205,15 +459,21 @@ export default function OperationsTimesheetsPage() {
205
459
  ];
206
460
 
207
461
  const openCreate = () => {
208
- setEditingTimesheet(null);
209
- setForm(emptyForm);
462
+ form.reset(buildDefaultFormValues());
463
+ setProjectQuery({ search: '', page: 1 });
464
+ setTaskQuery({ search: '', page: 1 });
465
+ setTaskOptions([]);
466
+ setIsTaskCreateSheetOpen(false);
210
467
  setIsSheetOpen(true);
211
468
  };
212
469
 
213
- const openEdit = (timesheet: OperationsTimesheet) => {
214
- setEditingTimesheet(timesheet);
215
- setForm(toFormState(timesheet));
216
- setIsSheetOpen(true);
470
+ const closeCreateSheet = () => {
471
+ setIsSheetOpen(false);
472
+ setIsTaskCreateSheetOpen(false);
473
+ form.reset(buildDefaultFormValues());
474
+ setProjectQuery({ search: '', page: 1 });
475
+ setTaskQuery({ search: '', page: 1 });
476
+ setTaskOptions([]);
217
477
  };
218
478
 
219
479
  const canManageRow = (timesheet: OperationsTimesheet) => {
@@ -224,88 +484,43 @@ export default function OperationsTimesheetsPage() {
224
484
  );
225
485
  };
226
486
 
227
- const updateEntry = (
228
- index: number,
229
- patch: Partial<TimesheetEntryFormState>
230
- ) => {
231
- setForm((current) => ({
232
- ...current,
233
- entries: current.entries.map((entry, entryIndex) =>
234
- entryIndex === index ? { ...entry, ...patch } : entry
235
- ),
236
- }));
237
- };
238
-
239
- const addEntry = () => {
240
- setForm((current) => ({
241
- ...current,
242
- entries: [...current.entries, createEmptyEntry()],
243
- }));
244
- };
245
-
246
- const removeEntry = (index: number) => {
247
- setForm((current) => ({
248
- ...current,
249
- entries:
250
- current.entries.length === 1
251
- ? [createEmptyEntry()]
252
- : current.entries.filter((_, entryIndex) => entryIndex !== index),
253
- }));
487
+ const canManageEntry = (entry: OperationsTimesheetEntry) => {
488
+ return Boolean(
489
+ me?.id &&
490
+ entry.collaboratorId === me.id &&
491
+ ['draft', 'rejected'].includes(entry.status ?? '')
492
+ );
254
493
  };
255
494
 
256
- const onSubmit = async () => {
257
- if (!form.weekStartDate || !form.weekEndDate) {
258
- showToastHandler?.('error', t('messages.requiredFields'));
259
- return;
260
- }
495
+ const handleQuickEntrySubmit = async (values: QuickEntryFormValues) => {
496
+ 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
+ });
261
505
 
262
- const payload = {
263
- weekStartDate: form.weekStartDate,
264
- weekEndDate: form.weekEndDate,
265
- notes: trimToNull(form.notes),
266
- entries: form.entries
267
- .filter((entry) => entry.workDate || entry.hours || entry.description)
268
- .map((entry) => ({
269
- projectAssignmentId:
270
- entry.projectAssignmentId === 'none'
271
- ? null
272
- : parseNumberInput(entry.projectAssignmentId),
273
- activityLabel: trimToNull(entry.activityLabel),
274
- workDate: entry.workDate,
275
- hours: parseNumberInput(entry.hours) ?? 0,
276
- description: trimToNull(entry.description),
277
- })),
278
- };
279
-
280
- if (payload.entries.some((entry) => !entry.workDate || !entry.hours)) {
281
- showToastHandler?.('error', t('messages.entryValidation'));
282
- return;
283
- }
506
+ showToastHandler?.('success', t('messages.saveSuccess'));
284
507
 
285
- try {
286
- if (editingTimesheet) {
287
- await mutateOperations(
288
- request,
289
- `/operations/timesheets/${editingTimesheet.id}`,
290
- 'PATCH',
291
- payload
292
- );
508
+ if (keepContextOnSave) {
509
+ form.reset(buildFollowUpFormValues(values, true));
510
+ setProjectQuery({ search: '', page: 1 });
511
+ setTaskQuery({ search: '', page: 1 });
512
+ setIsTaskCreateSheetOpen(false);
513
+ setTimeout(() => form.setFocus('duration'), 0);
293
514
  } else {
294
- await mutateOperations(
295
- request,
296
- '/operations/timesheets',
297
- 'POST',
298
- payload
299
- );
515
+ closeCreateSheet();
300
516
  }
301
517
 
302
- showToastHandler?.('success', t('messages.saveSuccess'));
303
- setIsSheetOpen(false);
304
- setEditingTimesheet(null);
305
- setForm(emptyForm);
306
- await refetch();
307
- } catch {
308
- showToastHandler?.('error', t('messages.saveError'));
518
+ await Promise.all([refetch(), refetchRecentEntries()]);
519
+ } catch (error) {
520
+ showToastHandler?.(
521
+ 'error',
522
+ getOperationsErrorMessage(error, t('messages.saveError'))
523
+ );
309
524
  }
310
525
  };
311
526
 
@@ -316,9 +531,60 @@ export default function OperationsTimesheetsPage() {
316
531
  method: 'POST',
317
532
  });
318
533
  showToastHandler?.('success', t('messages.submitSuccess'));
319
- await refetch();
320
- } catch {
321
- showToastHandler?.('error', t('messages.submitError'));
534
+ await Promise.all([refetch(), refetchRecentEntries()]);
535
+ } catch (error) {
536
+ showToastHandler?.(
537
+ 'error',
538
+ getOperationsErrorMessage(error, t('messages.submitError'))
539
+ );
540
+ }
541
+ };
542
+
543
+ const openTaskCreateSheet = () => {
544
+ if (!selectedProjectId || !selectedProject) {
545
+ showToastHandler?.('error', t('messages.selectProjectFirst'));
546
+ return;
547
+ }
548
+
549
+ setIsTaskCreateSheetOpen(true);
550
+ };
551
+
552
+ const handleTaskCreated = (task: OperationsTaskOption) => {
553
+ setTaskOptions((current) => [
554
+ task,
555
+ ...current.filter((item) => item.id !== task.id),
556
+ ]);
557
+ form.setValue('taskId', task.id, {
558
+ shouldDirty: true,
559
+ shouldTouch: true,
560
+ shouldValidate: true,
561
+ });
562
+ setTaskQuery({ search: '', page: 1 });
563
+ setTaskRefreshKey((current) => current + 1);
564
+ };
565
+
566
+ const handleDeleteEntry = async () => {
567
+ if (!entryToDelete?.id) {
568
+ return;
569
+ }
570
+
571
+ try {
572
+ setIsDeletingEntry(true);
573
+ await mutateOperations(
574
+ request,
575
+ `/operations/timesheet-entries/${entryToDelete.id}`,
576
+ 'DELETE'
577
+ );
578
+ showToastHandler?.('success', t('messages.deleteSuccess'));
579
+ setEntryToDelete(null);
580
+ await Promise.all([refetch(), refetchRecentEntries()]);
581
+ } catch (error) {
582
+ showToastHandler?.(
583
+ 'error',
584
+ getOperationsErrorMessage(error, t('messages.deleteError'))
585
+ );
586
+ } finally {
587
+ setIsDeletingEntry(false);
322
588
  }
323
589
  };
324
590
 
@@ -363,6 +629,110 @@ export default function OperationsTimesheetsPage() {
363
629
 
364
630
  <KpiCardsGrid items={cards} columns={3} />
365
631
 
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" />
639
+ </div>
640
+
641
+ <div className="space-y-1">
642
+ <CardTitle>{t('recentEntries.title')}</CardTitle>
643
+ <CardDescription>
644
+ {t('recentEntries.description')}
645
+ </CardDescription>
646
+ </div>
647
+ </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>
681
+ </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 ? (
686
+ <>
687
+ <span>•</span>
688
+ <span className="truncate">
689
+ {entry.description}
690
+ </span>
691
+ </>
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
708
+ )}
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>
725
+ ))}
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
+
366
736
  {filteredRows.length > 0 ? (
367
737
  <div className="overflow-x-auto rounded-md border">
368
738
  <Table>
@@ -373,14 +743,14 @@ export default function OperationsTimesheetsPage() {
373
743
  <TableHead>{commonT('labels.entries')}</TableHead>
374
744
  <TableHead>{commonT('labels.totalHours')}</TableHead>
375
745
  <TableHead>{commonT('labels.approver')}</TableHead>
376
- <TableHead>{commonT('labels.timeline')}</TableHead>
746
+ <TableHead>{commonT('labels.decisionNote')}</TableHead>
377
747
  <TableHead>{commonT('labels.status')}</TableHead>
378
748
  <TableHead>{commonT('labels.actions')}</TableHead>
379
749
  </TableRow>
380
750
  </TableHeader>
381
751
  <TableBody>
382
752
  {filteredRows.map((timesheet) => (
383
- <TableRow key={timesheet.id}>
753
+ <TableRow key={timesheet.id} className="hover:bg-muted/30">
384
754
  <TableCell>
385
755
  <div className="font-medium">
386
756
  {timesheet.collaboratorName}
@@ -404,7 +774,10 @@ export default function OperationsTimesheetsPage() {
404
774
  .slice(0, 2)
405
775
  .map(
406
776
  (entry) =>
407
- [entry.projectName, entry.activityLabel]
777
+ [
778
+ entry.projectName,
779
+ entry.taskName || entry.activityLabel,
780
+ ]
408
781
  .filter(Boolean)
409
782
  .join(' • ') || commonT('labels.unassigned')
410
783
  )
@@ -416,13 +789,7 @@ export default function OperationsTimesheetsPage() {
416
789
  {timesheet.approverName || commonT('labels.notAssigned')}
417
790
  </TableCell>
418
791
  <TableCell>
419
- <div>
420
- {formatDateRange(
421
- timesheet.weekStartDate,
422
- timesheet.weekEndDate
423
- )}
424
- </div>
425
- <div className="text-xs text-muted-foreground">
792
+ <div className="text-sm text-muted-foreground">
426
793
  {timesheet.decisionNote || commonT('labels.noNotes')}
427
794
  </div>
428
795
  </TableCell>
@@ -435,21 +802,12 @@ export default function OperationsTimesheetsPage() {
435
802
  <TableCell>
436
803
  <div className="flex justify-end gap-2">
437
804
  {canManageRow(timesheet) ? (
438
- <>
439
- <Button
440
- variant="outline"
441
- size="icon"
442
- onClick={() => openEdit(timesheet)}
443
- >
444
- <Pencil className="size-4" />
445
- </Button>
446
- <Button
447
- size="icon"
448
- onClick={() => void submitTimesheet(timesheet.id)}
449
- >
450
- <Send className="size-4" />
451
- </Button>
452
- </>
805
+ <Button
806
+ size="icon"
807
+ onClick={() => void submitTimesheet(timesheet.id)}
808
+ >
809
+ <Send className="size-4" />
810
+ </Button>
453
811
  ) : (
454
812
  <span className="text-xs text-muted-foreground">
455
813
  {commonT('labels.viewOnly')}
@@ -479,201 +837,300 @@ export default function OperationsTimesheetsPage() {
479
837
  <Sheet
480
838
  open={isSheetOpen}
481
839
  onOpenChange={(open) => {
482
- setIsSheetOpen(open);
840
+ if (form.formState.isSubmitting && !open) {
841
+ return;
842
+ }
843
+
483
844
  if (!open) {
484
- setEditingTimesheet(null);
485
- setForm(emptyForm);
845
+ closeCreateSheet();
846
+ return;
486
847
  }
848
+
849
+ setIsSheetOpen(true);
487
850
  }}
488
851
  >
489
- <SheetContent className="w-full overflow-y-auto sm:max-w-3xl">
490
- <SheetHeader>
491
- <SheetTitle>
492
- {editingTimesheet ? t('sheet.editTitle') : t('sheet.createTitle')}
493
- </SheetTitle>
494
- <SheetDescription>{t('sheet.description')}</SheetDescription>
495
- </SheetHeader>
496
-
497
- <div className="mt-6 grid gap-4 px-4">
498
- <div className="grid gap-4 md:grid-cols-2">
499
- <div className="space-y-2">
500
- <label className="text-sm font-medium">
501
- {commonT('labels.weekStart')}
502
- </label>
503
- <Input
504
- type="date"
505
- value={form.weekStartDate}
506
- onChange={(event) =>
507
- setForm((current) => ({
508
- ...current,
509
- weekStartDate: event.target.value,
510
- }))
511
- }
512
- />
852
+ <SheetContent className="flex h-full w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
853
+ <SheetHeader className="border-b px-4 py-4 text-left">
854
+ <div className="flex items-start gap-3">
855
+ <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
856
+ <Clock3 className="size-5" />
513
857
  </div>
514
- <div className="space-y-2">
515
- <label className="text-sm font-medium">
516
- {commonT('labels.weekEnd')}
517
- </label>
518
- <Input
519
- type="date"
520
- value={form.weekEndDate}
521
- onChange={(event) =>
522
- setForm((current) => ({
523
- ...current,
524
- weekEndDate: event.target.value,
525
- }))
526
- }
527
- />
858
+
859
+ <div className="space-y-1">
860
+ <SheetTitle>{t('sheet.createTitle')}</SheetTitle>
861
+ <SheetDescription>{t('sheet.description')}</SheetDescription>
528
862
  </div>
529
863
  </div>
864
+ </SheetHeader>
530
865
 
531
- <div className="space-y-2">
532
- <label className="text-sm font-medium">
533
- {commonT('labels.notes')}
534
- </label>
535
- <Textarea
536
- rows={3}
537
- value={form.notes}
538
- onChange={(event) =>
539
- setForm((current) => ({
540
- ...current,
541
- notes: event.target.value,
542
- }))
543
- }
544
- />
545
- </div>
866
+ <Form {...form}>
867
+ <form
868
+ onSubmit={form.handleSubmit(handleQuickEntrySubmit)}
869
+ className="flex min-h-0 flex-1 flex-col"
870
+ >
871
+ <div className="flex-1 space-y-5 overflow-y-auto px-4 py-4">
872
+ <div className="grid gap-4 lg:grid-cols-2">
873
+ <FormField
874
+ control={form.control}
875
+ name="projectId"
876
+ render={({ field }) => (
877
+ <FormItem>
878
+ <FormLabel>{t('sheet.fields.project')}</FormLabel>
879
+ <FormControl>
880
+ <AsyncOptionsCombobox
881
+ value={field.value}
882
+ selectedOption={selectedProject}
883
+ options={projectOptions}
884
+ onSelect={(option) => {
885
+ field.onChange(option?.id);
886
+ form.setValue('taskId', undefined, {
887
+ shouldDirty: true,
888
+ shouldTouch: true,
889
+ });
890
+ setTaskQuery({ search: '', page: 1 });
891
+ setTaskOptions([]);
892
+ }}
893
+ searchValue={projectQuery.search}
894
+ onSearchValueChange={(value) =>
895
+ setProjectQuery({ search: value, page: 1 })
896
+ }
897
+ placeholder={t('sheet.placeholders.project')}
898
+ searchPlaceholder={t(
899
+ 'sheet.placeholders.projectSearch'
900
+ )}
901
+ emptyLabel={t('entries.projectHint')}
902
+ loadMoreLabel={commonT('actions.loadMore')}
903
+ clearLabel={commonT('actions.clearSelection')}
904
+ isLoading={isProjectOptionsLoading}
905
+ hasMore={Boolean(projectOptionsResponse?.next)}
906
+ onLoadMore={() =>
907
+ setProjectQuery((current) => ({
908
+ ...current,
909
+ page: current.page + 1,
910
+ }))
911
+ }
912
+ />
913
+ </FormControl>
914
+ <FormMessage />
915
+ </FormItem>
916
+ )}
917
+ />
546
918
 
547
- <div className="space-y-3">
548
- <div className="flex items-center justify-between">
549
- <div>
550
- <div className="text-sm font-medium">
551
- {t('entries.title')}
552
- </div>
553
- <div className="text-xs text-muted-foreground">
554
- {projectOptions.length > 0
555
- ? t('entries.description')
556
- : t('entries.projectHint')}
557
- </div>
919
+ <FormField
920
+ control={form.control}
921
+ name="taskId"
922
+ render={({ field }) => (
923
+ <FormItem>
924
+ <FormLabel>{t('sheet.fields.task')}</FormLabel>
925
+ <FormControl>
926
+ <AsyncOptionsCombobox
927
+ value={field.value}
928
+ selectedOption={selectedTask}
929
+ options={taskOptions}
930
+ onSelect={(option) => field.onChange(option?.id)}
931
+ searchValue={taskQuery.search}
932
+ onSearchValueChange={(value) =>
933
+ setTaskQuery({ search: value, page: 1 })
934
+ }
935
+ placeholder={t('sheet.placeholders.task')}
936
+ searchPlaceholder={t(
937
+ 'sheet.placeholders.taskSearch'
938
+ )}
939
+ emptyLabel={
940
+ selectedProjectId
941
+ ? t('sheet.helper.task')
942
+ : t('sheet.helper.selectProjectFirst')
943
+ }
944
+ loadMoreLabel={commonT('actions.loadMore')}
945
+ clearLabel={commonT('actions.clearSelection')}
946
+ createLabel={t('sheet.task.createAction')}
947
+ onCreateClick={openTaskCreateSheet}
948
+ isLoading={isTaskOptionsLoading}
949
+ hasMore={Boolean(taskOptionsResponse?.next)}
950
+ onLoadMore={() =>
951
+ setTaskQuery((current) => ({
952
+ ...current,
953
+ page: current.page + 1,
954
+ }))
955
+ }
956
+ disabled={!selectedProjectId}
957
+ />
958
+ </FormControl>
959
+
960
+ <FormMessage />
961
+ </FormItem>
962
+ )}
963
+ />
558
964
  </div>
559
- <Button variant="outline" size="sm" onClick={addEntry}>
560
- <Plus className="size-4" />
561
- {commonT('actions.addLine')}
562
- </Button>
563
- </div>
564
965
 
565
- <div className="space-y-3">
566
- {form.entries.map((entry, index) => (
567
- <div
568
- key={index}
569
- className="grid gap-3 rounded-lg border p-4 lg:grid-cols-[1.4fr_1fr_1fr_0.8fr_auto]"
570
- >
571
- <div className="space-y-3">
572
- <div className="space-y-2">
573
- <label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
574
- {commonT('labels.projectAssignment')}
575
- </label>
576
- <Select
577
- value={entry.projectAssignmentId}
578
- onValueChange={(value) =>
579
- updateEntry(index, { projectAssignmentId: value })
580
- }
581
- >
582
- <SelectTrigger>
583
- <SelectValue />
584
- </SelectTrigger>
585
- <SelectContent>
586
- <SelectItem value="none">
587
- {commonT('labels.unassigned')}
588
- </SelectItem>
589
- {projectOptions.map((option) => (
590
- <SelectItem
591
- key={option.value}
592
- value={option.value}
593
- >
594
- {option.label}
595
- </SelectItem>
596
- ))}
597
- </SelectContent>
598
- </Select>
599
- </div>
966
+ <div className="grid gap-4 md:grid-cols-[1fr_1fr_220px]">
967
+ <FormField
968
+ control={form.control}
969
+ name="workDate"
970
+ render={({ field }) => (
971
+ <FormItem>
972
+ <FormLabel>{commonT('labels.workDate')}</FormLabel>
973
+ <FormControl>
974
+ <Input type="date" {...field} />
975
+ </FormControl>
976
+ <FormMessage />
977
+ </FormItem>
978
+ )}
979
+ />
600
980
 
601
- <div className="space-y-2">
602
- <label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
603
- {commonT('labels.activity')}
604
- </label>
605
- <Input
606
- value={entry.activityLabel}
607
- onChange={(event) =>
608
- updateEntry(index, {
609
- activityLabel: event.target.value,
610
- })
611
- }
612
- placeholder={t('entries.activityPlaceholder')}
613
- />
614
- </div>
981
+ <FormField
982
+ control={form.control}
983
+ name="duration"
984
+ render={({ field }) => (
985
+ <FormItem>
986
+ <FormLabel>{t('sheet.fields.duration')}</FormLabel>
987
+ <FormControl>
988
+ <Input
989
+ type="number"
990
+ min="0"
991
+ step={unitValue === 'minutes' ? '5' : '0.25'}
992
+ placeholder={t('sheet.placeholders.duration')}
993
+ value={field.value ?? ''}
994
+ onChange={(event) => {
995
+ const nextValue = event.target.value;
996
+ field.onChange(
997
+ nextValue === '' ? undefined : Number(nextValue)
998
+ );
999
+ }}
1000
+ />
1001
+ </FormControl>
1002
+ <FormMessage />
1003
+ </FormItem>
1004
+ )}
1005
+ />
615
1006
 
616
- <div className="space-y-2">
617
- <label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
618
- {commonT('labels.description')}
619
- </label>
1007
+ <FormField
1008
+ control={form.control}
1009
+ name="unit"
1010
+ render={({ field }) => (
1011
+ <FormItem>
1012
+ <FormLabel>{t('sheet.fields.unit')}</FormLabel>
1013
+ <FormControl>
1014
+ <ToggleGroup
1015
+ type="single"
1016
+ value={field.value}
1017
+ className="w-full"
1018
+ onValueChange={(value) => {
1019
+ if (value === 'hours' || value === 'minutes') {
1020
+ field.onChange(value);
1021
+ }
1022
+ }}
1023
+ >
1024
+ <ToggleGroupItem value="hours" className="flex-1">
1025
+ {t('sheet.fields.hours')}
1026
+ </ToggleGroupItem>
1027
+ <ToggleGroupItem value="minutes" className="flex-1">
1028
+ {t('sheet.fields.minutes')}
1029
+ </ToggleGroupItem>
1030
+ </ToggleGroup>
1031
+ </FormControl>
1032
+ <FormMessage />
1033
+ </FormItem>
1034
+ )}
1035
+ />
1036
+ </div>
1037
+
1038
+ <FormField
1039
+ control={form.control}
1040
+ name="description"
1041
+ render={({ field }) => (
1042
+ <FormItem>
1043
+ <FormLabel>{commonT('labels.description')}</FormLabel>
1044
+ <FormControl>
620
1045
  <Textarea
621
- rows={2}
622
- value={entry.description}
623
- onChange={(event) =>
624
- updateEntry(index, {
625
- description: event.target.value,
626
- })
627
- }
1046
+ {...field}
1047
+ rows={4}
1048
+ placeholder={t('sheet.placeholders.description')}
628
1049
  />
629
- </div>
630
- </div>
631
-
632
- <div className="space-y-2">
633
- <label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
634
- {commonT('labels.workDate')}
635
- </label>
636
- <Input
637
- type="date"
638
- value={entry.workDate}
639
- onChange={(event) =>
640
- updateEntry(index, { workDate: event.target.value })
641
- }
642
- />
643
- </div>
644
-
645
- <div className="space-y-2">
646
- <label className="text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
647
- {commonT('labels.hours')}
648
- </label>
649
- <Input
650
- type="number"
651
- step="0.5"
652
- value={entry.hours}
653
- onChange={(event) =>
654
- updateEntry(index, { hours: event.target.value })
655
- }
656
- />
657
- </div>
1050
+ </FormControl>
1051
+ <FormMessage />
1052
+ </FormItem>
1053
+ )}
1054
+ />
658
1055
 
659
- <div className="flex items-start justify-end">
660
- <Button
661
- variant="outline"
662
- size="icon"
663
- onClick={() => removeEntry(index)}
664
- >
665
- <Trash2 className="size-4" />
666
- </Button>
667
- </div>
1056
+ <div className="flex items-center justify-between rounded-lg border bg-background p-3">
1057
+ <div className="space-y-1 pr-3">
1058
+ <p className="text-sm font-medium text-foreground">
1059
+ {t('sheet.helper.keepContext')}
1060
+ </p>
1061
+ <p className="text-xs text-muted-foreground">
1062
+ {t('sheet.helper.keepContextDescription')}
1063
+ </p>
668
1064
  </div>
669
- ))}
1065
+
1066
+ <Switch
1067
+ checked={keepContextOnSave}
1068
+ onCheckedChange={setKeepContextOnSave}
1069
+ aria-label={t('sheet.helper.keepContext')}
1070
+ />
1071
+ </div>
670
1072
  </div>
671
- </div>
672
1073
 
673
- <Button onClick={onSubmit}>{commonT('actions.save')}</Button>
674
- </div>
1074
+ <FormActions
1075
+ sheet
1076
+ onCancel={closeCreateSheet}
1077
+ cancelLabel={commonT('actions.cancel')}
1078
+ submitType="submit"
1079
+ submitLabel={commonT('actions.save')}
1080
+ submitDisabled={form.formState.isSubmitting}
1081
+ />
1082
+ </form>
1083
+ </Form>
675
1084
  </SheetContent>
676
1085
  </Sheet>
1086
+
1087
+ <TimesheetTaskCreateSheet
1088
+ open={isTaskCreateSheetOpen}
1089
+ onOpenChange={setIsTaskCreateSheetOpen}
1090
+ project={selectedProject}
1091
+ onCreated={handleTaskCreated}
1092
+ />
1093
+
1094
+ <AlertDialog
1095
+ open={Boolean(entryToDelete)}
1096
+ onOpenChange={(open) => {
1097
+ if (!open && !isDeletingEntry) {
1098
+ setEntryToDelete(null);
1099
+ }
1100
+ }}
1101
+ >
1102
+ <AlertDialogContent>
1103
+ <AlertDialogHeader>
1104
+ <AlertDialogTitle>
1105
+ {t('messages.confirmDeleteTitle')}
1106
+ </AlertDialogTitle>
1107
+ <AlertDialogDescription>
1108
+ {t('messages.confirmDeleteDescription', {
1109
+ name:
1110
+ entryToDelete?.taskName ||
1111
+ entryToDelete?.activityLabel ||
1112
+ entryToDelete?.projectName ||
1113
+ `#${entryToDelete?.id ?? ''}`,
1114
+ })}
1115
+ </AlertDialogDescription>
1116
+ </AlertDialogHeader>
1117
+
1118
+ <AlertDialogFooter>
1119
+ <AlertDialogCancel disabled={isDeletingEntry}>
1120
+ {commonT('actions.cancel')}
1121
+ </AlertDialogCancel>
1122
+ <AlertDialogAction
1123
+ onClick={() => void handleDeleteEntry()}
1124
+ disabled={isDeletingEntry}
1125
+ >
1126
+ {isDeletingEntry ? (
1127
+ <Loader2 className="size-4 animate-spin" />
1128
+ ) : null}
1129
+ {commonT('actions.delete')}
1130
+ </AlertDialogAction>
1131
+ </AlertDialogFooter>
1132
+ </AlertDialogContent>
1133
+ </AlertDialog>
677
1134
  </Page>
678
1135
  );
679
1136
  }