@hed-hog/operations 0.0.318 → 0.0.319

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 (137) hide show
  1. package/dist/controllers/operations-collaborator-costs.controller.d.ts +144 -0
  2. package/dist/controllers/operations-collaborator-costs.controller.d.ts.map +1 -0
  3. package/dist/controllers/operations-collaborator-costs.controller.js +162 -0
  4. package/dist/controllers/operations-collaborator-costs.controller.js.map +1 -0
  5. package/dist/controllers/operations-collaborators.controller.d.ts +14 -0
  6. package/dist/controllers/operations-collaborators.controller.d.ts.map +1 -1
  7. package/dist/controllers/operations-collaborators.controller.js +11 -0
  8. package/dist/controllers/operations-collaborators.controller.js.map +1 -1
  9. package/dist/controllers/operations-projects.controller.d.ts +31 -0
  10. package/dist/controllers/operations-projects.controller.d.ts.map +1 -1
  11. package/dist/controllers/operations-projects.controller.js +23 -0
  12. package/dist/controllers/operations-projects.controller.js.map +1 -1
  13. package/dist/controllers/operations-reports.controller.d.ts +199 -0
  14. package/dist/controllers/operations-reports.controller.d.ts.map +1 -0
  15. package/dist/controllers/operations-reports.controller.js +53 -0
  16. package/dist/controllers/operations-reports.controller.js.map +1 -0
  17. package/dist/controllers/operations-tasks.controller.d.ts +41 -2
  18. package/dist/controllers/operations-tasks.controller.d.ts.map +1 -1
  19. package/dist/controllers/operations-tasks.controller.js +17 -5
  20. package/dist/controllers/operations-tasks.controller.js.map +1 -1
  21. package/dist/dto/create-collaborator-cost.dto.d.ts +16 -0
  22. package/dist/dto/create-collaborator-cost.dto.d.ts.map +1 -0
  23. package/dist/dto/create-collaborator-cost.dto.js +88 -0
  24. package/dist/dto/create-collaborator-cost.dto.js.map +1 -0
  25. package/dist/dto/create-collaborator.dto.d.ts +0 -1
  26. package/dist/dto/create-collaborator.dto.d.ts.map +1 -1
  27. package/dist/dto/create-collaborator.dto.js +0 -6
  28. package/dist/dto/create-collaborator.dto.js.map +1 -1
  29. package/dist/dto/create-cost-type.dto.d.ts +13 -0
  30. package/dist/dto/create-cost-type.dto.d.ts.map +1 -0
  31. package/dist/dto/create-cost-type.dto.js +87 -0
  32. package/dist/dto/create-cost-type.dto.js.map +1 -0
  33. package/dist/dto/list-approvals.dto.d.ts +2 -0
  34. package/dist/dto/list-approvals.dto.d.ts.map +1 -1
  35. package/dist/dto/list-approvals.dto.js +10 -0
  36. package/dist/dto/list-approvals.dto.js.map +1 -1
  37. package/dist/dto/list-collaborator-costs.dto.d.ts +5 -0
  38. package/dist/dto/list-collaborator-costs.dto.d.ts.map +1 -0
  39. package/dist/dto/list-collaborator-costs.dto.js +23 -0
  40. package/dist/dto/list-collaborator-costs.dto.js.map +1 -0
  41. package/dist/dto/list-cost-types.dto.d.ts +6 -0
  42. package/dist/dto/list-cost-types.dto.d.ts.map +1 -0
  43. package/dist/dto/list-cost-types.dto.js +35 -0
  44. package/dist/dto/list-cost-types.dto.js.map +1 -0
  45. package/dist/dto/list-my-projects.dto.d.ts +5 -0
  46. package/dist/dto/list-my-projects.dto.d.ts.map +1 -0
  47. package/dist/dto/list-my-projects.dto.js +23 -0
  48. package/dist/dto/list-my-projects.dto.js.map +1 -0
  49. package/dist/dto/list-my-tasks.dto.d.ts +6 -0
  50. package/dist/dto/list-my-tasks.dto.d.ts.map +1 -0
  51. package/dist/dto/list-my-tasks.dto.js +33 -0
  52. package/dist/dto/list-my-tasks.dto.js.map +1 -0
  53. package/dist/dto/list-projects.dto.d.ts +1 -0
  54. package/dist/dto/list-projects.dto.d.ts.map +1 -1
  55. package/dist/dto/list-projects.dto.js +7 -0
  56. package/dist/dto/list-projects.dto.js.map +1 -1
  57. package/dist/dto/list-reports.dto.d.ts +16 -0
  58. package/dist/dto/list-reports.dto.d.ts.map +1 -0
  59. package/dist/dto/list-reports.dto.js +75 -0
  60. package/dist/dto/list-reports.dto.js.map +1 -0
  61. package/dist/dto/list-tasks.dto.d.ts +2 -0
  62. package/dist/dto/list-tasks.dto.d.ts.map +1 -1
  63. package/dist/dto/list-tasks.dto.js +12 -0
  64. package/dist/dto/list-tasks.dto.js.map +1 -1
  65. package/dist/dto/list-timesheets.dto.d.ts +2 -0
  66. package/dist/dto/list-timesheets.dto.d.ts.map +1 -1
  67. package/dist/dto/list-timesheets.dto.js +10 -0
  68. package/dist/dto/list-timesheets.dto.js.map +1 -1
  69. package/dist/dto/update-collaborator-cost.dto.d.ts +6 -0
  70. package/dist/dto/update-collaborator-cost.dto.d.ts.map +1 -0
  71. package/dist/dto/update-collaborator-cost.dto.js +9 -0
  72. package/dist/dto/update-collaborator-cost.dto.js.map +1 -0
  73. package/dist/dto/update-task.dto.d.ts +1 -0
  74. package/dist/dto/update-task.dto.d.ts.map +1 -1
  75. package/dist/dto/update-task.dto.js +6 -0
  76. package/dist/dto/update-task.dto.js.map +1 -1
  77. package/dist/operations.module.d.ts.map +1 -1
  78. package/dist/operations.module.js +4 -0
  79. package/dist/operations.module.js.map +1 -1
  80. package/dist/operations.service.d.ts +457 -3
  81. package/dist/operations.service.d.ts.map +1 -1
  82. package/dist/operations.service.js +1445 -208
  83. package/dist/operations.service.js.map +1 -1
  84. package/dist/operations.service.spec.js +31 -7
  85. package/dist/operations.service.spec.js.map +1 -1
  86. package/hedhog/data/menu.yaml +112 -7
  87. package/hedhog/data/operations_cost_type.yaml +166 -0
  88. package/hedhog/data/route.yaml +185 -0
  89. package/hedhog/frontend/app/_components/collaborator-costs-section.tsx.ejs +884 -0
  90. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +94 -15
  91. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +219 -94
  92. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +21 -32
  93. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +178 -89
  94. package/hedhog/frontend/app/_components/my-project-summary-screen.tsx.ejs +1185 -0
  95. package/hedhog/frontend/app/_components/operations-calendar-view.tsx.ejs +306 -0
  96. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +943 -782
  97. package/hedhog/frontend/app/_components/task-detail-sheet.tsx.ejs +223 -0
  98. package/hedhog/frontend/app/_lib/api.ts.ejs +162 -0
  99. package/hedhog/frontend/app/_lib/types.ts.ejs +229 -3
  100. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +11 -3
  101. package/hedhog/frontend/app/approvals/page.tsx.ejs +191 -46
  102. package/hedhog/frontend/app/collaborators/page.tsx.ejs +133 -25
  103. package/hedhog/frontend/app/my-projects/[id]/page.tsx.ejs +11 -0
  104. package/hedhog/frontend/app/my-projects/page.tsx.ejs +440 -0
  105. package/hedhog/frontend/app/my-tasks/page.tsx.ejs +1304 -0
  106. package/hedhog/frontend/app/reports/collaborators/page.tsx.ejs +771 -0
  107. package/hedhog/frontend/app/reports/projects/page.tsx.ejs +809 -0
  108. package/hedhog/frontend/app/timesheets/page.tsx.ejs +322 -58
  109. package/hedhog/frontend/messages/en.json +234 -25
  110. package/hedhog/frontend/messages/pt.json +234 -25
  111. package/hedhog/table/operations_collaborator.yaml +0 -4
  112. package/hedhog/table/operations_collaborator_compensation_history.yaml +28 -0
  113. package/hedhog/table/operations_collaborator_cost.yaml +56 -0
  114. package/hedhog/table/operations_cost_type.yaml +38 -0
  115. package/package.json +6 -6
  116. package/src/controllers/operations-collaborator-costs.controller.ts +147 -0
  117. package/src/controllers/operations-collaborators.controller.ts +19 -8
  118. package/src/controllers/operations-projects.controller.ts +19 -8
  119. package/src/controllers/operations-reports.controller.ts +32 -0
  120. package/src/controllers/operations-tasks.controller.ts +32 -12
  121. package/src/dto/create-collaborator-cost.dto.ts +78 -0
  122. package/src/dto/create-collaborator.dto.ts +9 -14
  123. package/src/dto/create-cost-type.dto.ts +62 -0
  124. package/src/dto/list-approvals.dto.ts +8 -0
  125. package/src/dto/list-collaborator-costs.dto.ts +8 -0
  126. package/src/dto/list-cost-types.dto.ts +19 -0
  127. package/src/dto/list-my-projects.dto.ts +8 -0
  128. package/src/dto/list-my-tasks.dto.ts +17 -0
  129. package/src/dto/list-projects.dto.ts +7 -1
  130. package/src/dto/list-reports.dto.ts +51 -0
  131. package/src/dto/list-tasks.dto.ts +11 -1
  132. package/src/dto/list-timesheets.dto.ts +8 -0
  133. package/src/dto/update-collaborator-cost.dto.ts +4 -0
  134. package/src/dto/update-task.dto.ts +6 -0
  135. package/src/operations.module.ts +7 -3
  136. package/src/operations.service.spec.ts +45 -7
  137. package/src/operations.service.ts +1992 -225
@@ -0,0 +1,1185 @@
1
+ 'use client';
2
+
3
+ import { EmptyState, Page } from '@/components/entity-list';
4
+ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
5
+ import { Button } from '@/components/ui/button';
6
+ import {
7
+ Dialog,
8
+ DialogContent,
9
+ DialogFooter,
10
+ DialogHeader,
11
+ DialogTitle,
12
+ } from '@/components/ui/dialog';
13
+ import { Input } from '@/components/ui/input';
14
+ import { Label } from '@/components/ui/label';
15
+ import {
16
+ Select,
17
+ SelectContent,
18
+ SelectItem,
19
+ SelectTrigger,
20
+ SelectValue,
21
+ } from '@/components/ui/select';
22
+ import {
23
+ Table,
24
+ TableBody,
25
+ TableCell,
26
+ TableHead,
27
+ TableHeader,
28
+ TableRow,
29
+ } from '@/components/ui/table';
30
+ import { Textarea } from '@/components/ui/textarea';
31
+ import {
32
+ closestCenter,
33
+ DndContext,
34
+ PointerSensor,
35
+ useDraggable,
36
+ useDroppable,
37
+ useSensor,
38
+ useSensors,
39
+ type DragEndEvent,
40
+ type UniqueIdentifier,
41
+ } from '@dnd-kit/core';
42
+ import { CSS } from '@dnd-kit/utilities';
43
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
44
+ import {
45
+ AlarmClock,
46
+ Archive,
47
+ ArchiveRestore,
48
+ FolderKanban,
49
+ Pencil,
50
+ Plus,
51
+ Rows3,
52
+ Trash2,
53
+ } from 'lucide-react';
54
+ import { useTranslations } from 'next-intl';
55
+ import { useCallback, useMemo, useState } from 'react';
56
+ import { fetchOperations, mutateOperations } from '../_lib/api';
57
+ import type {
58
+ OperationsMyProjectSummary,
59
+ OperationsTaskOption,
60
+ PaginatedResponse,
61
+ } from '../_lib/types';
62
+ import {
63
+ formatDate,
64
+ formatEnumLabel,
65
+ getStatusBadgeClass,
66
+ } from '../_lib/utils/format';
67
+ import { OperationsHeader } from './operations-header';
68
+ import { SectionCard } from './section-card';
69
+ import { StatusBadge } from './status-badge';
70
+ import { TaskDetailSheet, type TaskDetailSheetData } from './task-detail-sheet';
71
+
72
+ type BoardColumnId = 'todo' | 'doing' | 'review' | 'done';
73
+
74
+ type BoardTask = {
75
+ id: number;
76
+ name: string;
77
+ description: string | null;
78
+ status: BoardColumnId;
79
+ priority: string;
80
+ dueDate: string | null;
81
+ estimateHours: number | null;
82
+ tags: string | null;
83
+ assigneeCollaboratorId: number | null;
84
+ assigneeName: string | null;
85
+ assigneeUserPhotoId: number | null;
86
+ assigneePersonAvatarId: number | null;
87
+ };
88
+
89
+ type TaskFormState = {
90
+ name: string;
91
+ description: string;
92
+ priority: 'low' | 'medium' | 'high';
93
+ status: BoardColumnId;
94
+ dueDate: string;
95
+ estimateHours: string;
96
+ tags: string;
97
+ };
98
+
99
+ const EMPTY_TASK_FORM: TaskFormState = {
100
+ name: '',
101
+ description: '',
102
+ priority: 'medium',
103
+ status: 'todo',
104
+ dueDate: '',
105
+ estimateHours: '',
106
+ tags: '',
107
+ };
108
+
109
+ type BoardColumns = Record<BoardColumnId, BoardTask[]>;
110
+
111
+ type BoardState = {
112
+ projectId: number;
113
+ columns: BoardColumns;
114
+ };
115
+
116
+ const KANBAN_COLUMNS: Array<{ id: BoardColumnId; label: string }> = [
117
+ { id: 'todo', label: 'Backlog' },
118
+ { id: 'doing', label: 'Em execução' },
119
+ { id: 'review', label: 'Revisão' },
120
+ { id: 'done', label: 'Concluído' },
121
+ ];
122
+
123
+ function taskDragId(taskId: number) {
124
+ return `task-${taskId}`;
125
+ }
126
+
127
+ function columnDropId(columnId: BoardColumnId) {
128
+ return `col-${columnId}`;
129
+ }
130
+
131
+ function parseTaskId(value: UniqueIdentifier | null | undefined) {
132
+ if (!value) return null;
133
+ const match = String(value).match(/^task-(\d+)$/);
134
+ return match ? Number(match[1]) : null;
135
+ }
136
+
137
+ function parseColumnId(
138
+ value: UniqueIdentifier | null | undefined
139
+ ): BoardColumnId | null {
140
+ if (!value) return null;
141
+ const match = String(value).match(/^col-(.+)$/);
142
+ const id = match?.[1];
143
+ return KANBAN_COLUMNS.some((c) => c.id === id) ? (id as BoardColumnId) : null;
144
+ }
145
+
146
+ function apiTaskToBoardTask(
147
+ row: OperationsMyProjectSummary['tasks'][number]
148
+ ): BoardTask {
149
+ const status = KANBAN_COLUMNS.some((c) => c.id === row.status)
150
+ ? (row.status as BoardColumnId)
151
+ : 'todo';
152
+ return {
153
+ id: row.id,
154
+ name: row.name,
155
+ description: row.description ?? null,
156
+ status,
157
+ priority: row.priority ?? 'medium',
158
+ dueDate: row.dueDate ?? null,
159
+ estimateHours: row.estimateHours ?? null,
160
+ tags: row.tags ?? null,
161
+ assigneeCollaboratorId: row.assigneeCollaboratorId ?? null,
162
+ assigneeName: row.assigneeName ?? null,
163
+ assigneeUserPhotoId: row.assigneeUserPhotoId ?? null,
164
+ assigneePersonAvatarId: row.assigneePersonAvatarId ?? null,
165
+ };
166
+ }
167
+
168
+ function splitTasksByColumn(tasks: BoardTask[]): BoardColumns {
169
+ return {
170
+ todo: tasks.filter((t) => t.status === 'todo'),
171
+ doing: tasks.filter((t) => t.status === 'doing'),
172
+ review: tasks.filter((t) => t.status === 'review'),
173
+ done: tasks.filter((t) => t.status === 'done'),
174
+ };
175
+ }
176
+
177
+ function getInitials(value?: string | null) {
178
+ const parts = String(value ?? '')
179
+ .trim()
180
+ .split(/\s+/)
181
+ .filter(Boolean)
182
+ .slice(0, 2);
183
+ if (!parts.length) return '??';
184
+ return parts.map((part) => part[0]?.toUpperCase() ?? '').join('');
185
+ }
186
+
187
+ function getPersonAvatarUrl(avatarId?: number | null) {
188
+ return typeof avatarId === 'number' && avatarId > 0
189
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/person/avatar/${avatarId}`
190
+ : '/placeholder.png';
191
+ }
192
+
193
+ function getUserPhotoUrl(photoId?: number | null) {
194
+ return typeof photoId === 'number' && photoId > 0
195
+ ? `${process.env.NEXT_PUBLIC_API_BASE_URL}/file/open/${photoId}`
196
+ : null;
197
+ }
198
+
199
+ function normalizeDateInputValue(value?: string | null) {
200
+ if (!value) {
201
+ return '';
202
+ }
203
+
204
+ const normalizedValue = String(value).trim();
205
+ const directMatch = normalizedValue.match(/^\d{4}-\d{2}-\d{2}/);
206
+
207
+ if (directMatch?.[0]) {
208
+ return directMatch[0];
209
+ }
210
+
211
+ const parsedDate = new Date(normalizedValue);
212
+ if (Number.isNaN(parsedDate.getTime())) {
213
+ return '';
214
+ }
215
+
216
+ return parsedDate.toISOString().slice(0, 10);
217
+ }
218
+
219
+ function getTaskPriorityLabel(value?: string | null) {
220
+ const labels: Record<string, string> = {
221
+ low: 'Baixa',
222
+ medium: 'Média',
223
+ high: 'Alta',
224
+ };
225
+ return labels[value ?? ''] ?? String(value ?? '');
226
+ }
227
+
228
+ function DraggableTaskCard({
229
+ task,
230
+ children,
231
+ }: {
232
+ task: BoardTask;
233
+ children: (isDragging: boolean) => React.ReactNode;
234
+ }) {
235
+ const { attributes, listeners, setNodeRef, transform, isDragging } =
236
+ useDraggable({
237
+ id: taskDragId(task.id),
238
+ });
239
+ return (
240
+ <div
241
+ ref={setNodeRef}
242
+ style={{ transform: CSS.Translate.toString(transform) }}
243
+ {...listeners}
244
+ {...attributes}
245
+ className={isDragging ? 'z-20' : undefined}
246
+ >
247
+ {children(isDragging)}
248
+ </div>
249
+ );
250
+ }
251
+
252
+ function DroppableColumn({
253
+ columnId,
254
+ children,
255
+ }: {
256
+ columnId: BoardColumnId;
257
+ children: (isOver: boolean) => React.ReactNode;
258
+ }) {
259
+ const { setNodeRef, isOver } = useDroppable({ id: columnDropId(columnId) });
260
+ return <div ref={setNodeRef}>{children(isOver)}</div>;
261
+ }
262
+
263
+ export function MyProjectSummaryScreen({ projectId }: { projectId: number }) {
264
+ const t = useTranslations('operations.ProjectDetailsPage');
265
+ const commonT = useTranslations('operations.Common');
266
+ const formT = useTranslations('operations.ProjectFormPage');
267
+ const { request, currentLocaleCode, getSettingValue } = useApp();
268
+
269
+ const getProjectStatusLabel = (value?: string | null) => {
270
+ if (!value) return commonT('labels.notAvailable');
271
+ try {
272
+ return formT(`options.statuses.${value}`);
273
+ } catch {
274
+ return value;
275
+ }
276
+ };
277
+
278
+ const { data: project, refetch } = useQuery<OperationsMyProjectSummary>({
279
+ queryKey: ['operations-my-project-summary', projectId],
280
+ queryFn: () =>
281
+ fetchOperations<OperationsMyProjectSummary>(
282
+ request,
283
+ `/operations/my-projects/${projectId}/summary`
284
+ ),
285
+ });
286
+ const { data: archivedTasksResponse, refetch: refetchArchivedTasks } =
287
+ useQuery<PaginatedResponse<OperationsTaskOption>>({
288
+ queryKey: [
289
+ 'operations-my-project-archived-tasks',
290
+ projectId,
291
+ currentLocaleCode,
292
+ ],
293
+ queryFn: () =>
294
+ fetchOperations<PaginatedResponse<OperationsTaskOption>>(
295
+ request,
296
+ `/operations/tasks?projectId=${projectId}&archived=true&pageSize=200&sortField=createdAt&sortOrder=desc`
297
+ ),
298
+ placeholderData: (previous) => previous,
299
+ });
300
+
301
+ const [boardState, setBoardState] = useState<BoardState | null>(null);
302
+ const [selectedTask, setSelectedTask] = useState<TaskDetailSheetData | null>(
303
+ null
304
+ );
305
+ const [deletePromptTask, setDeletePromptTask] =
306
+ useState<TaskDetailSheetData | null>(null);
307
+ const [taskFormOpen, setTaskFormOpen] = useState(false);
308
+ const [editingTaskId, setEditingTaskId] = useState<number | null>(null);
309
+ const [taskFormData, setTaskFormData] =
310
+ useState<TaskFormState>(EMPTY_TASK_FORM);
311
+ const [taskFormLoading, setTaskFormLoading] = useState(false);
312
+
313
+ const apiTasks = useMemo(
314
+ () => (project?.tasks ?? []).map(apiTaskToBoardTask),
315
+ [project]
316
+ );
317
+ const archivedTasks = useMemo(
318
+ () =>
319
+ (archivedTasksResponse?.data ?? []).filter((task) =>
320
+ Boolean(task.deletedAt)
321
+ ),
322
+ [archivedTasksResponse]
323
+ );
324
+ const currentCollaboratorId = useMemo(() => {
325
+ if (!project?.myAssignmentId) {
326
+ return null;
327
+ }
328
+
329
+ return (
330
+ project.assignments.find(
331
+ (assignment) => assignment.id === project.myAssignmentId
332
+ )?.collaboratorId ?? null
333
+ );
334
+ }, [project]);
335
+
336
+ const taskColumns: BoardColumns = useMemo(() => {
337
+ if (project && boardState?.projectId === project.id) {
338
+ return boardState.columns;
339
+ }
340
+ return splitTasksByColumn(apiTasks);
341
+ }, [project, boardState, apiTasks]);
342
+
343
+ const sensors = useSensors(
344
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } })
345
+ );
346
+
347
+ const findColumnByTask = (taskId: number) => {
348
+ const match = KANBAN_COLUMNS.find((column) =>
349
+ taskColumns[column.id].some((task) => task.id === taskId)
350
+ );
351
+ return match?.id ?? null;
352
+ };
353
+
354
+ const moveTaskToColumn = useCallback(
355
+ (taskId: number, targetColumn: BoardColumnId) => {
356
+ const originColumn = findColumnByTask(taskId);
357
+ if (!originColumn || originColumn === targetColumn || !project) return;
358
+
359
+ const sourceTask = taskColumns[originColumn].find(
360
+ (task) => task.id === taskId
361
+ );
362
+ if (!sourceTask) return;
363
+
364
+ setBoardState({
365
+ projectId: project.id,
366
+ columns: {
367
+ ...taskColumns,
368
+ [originColumn]: taskColumns[originColumn].filter(
369
+ (task) => task.id !== taskId
370
+ ),
371
+ [targetColumn]: [
372
+ { ...sourceTask, status: targetColumn },
373
+ ...taskColumns[targetColumn],
374
+ ],
375
+ },
376
+ });
377
+
378
+ mutateOperations(request, `/operations/tasks/${taskId}`, 'PATCH', {
379
+ status: targetColumn,
380
+ }).catch(() => {
381
+ setBoardState(null);
382
+ void refetch();
383
+ });
384
+ },
385
+ [taskColumns, project, request, refetch] // eslint-disable-line react-hooks/exhaustive-deps
386
+ );
387
+
388
+ const onBoardDragEnd = (event: DragEndEvent) => {
389
+ const taskId = parseTaskId(event.active.id);
390
+ const targetColumn = parseColumnId(event.over?.id);
391
+ if (!taskId || !targetColumn) return;
392
+ moveTaskToColumn(taskId, targetColumn);
393
+ };
394
+
395
+ const openCreateTaskForm = useCallback(
396
+ (defaultStatus: BoardColumnId = 'todo') => {
397
+ setEditingTaskId(null);
398
+ setTaskFormData({ ...EMPTY_TASK_FORM, status: defaultStatus });
399
+ setSelectedTask(null);
400
+ setTaskFormOpen(true);
401
+ },
402
+ []
403
+ );
404
+
405
+ const openEditTaskForm = useCallback((task: BoardTask) => {
406
+ setEditingTaskId(task.id);
407
+ setTaskFormData({
408
+ name: task.name,
409
+ description: task.description ?? '',
410
+ priority: task.priority as TaskFormState['priority'],
411
+ status: task.status,
412
+ dueDate: normalizeDateInputValue(task.dueDate),
413
+ estimateHours:
414
+ task.estimateHours != null ? String(task.estimateHours) : '',
415
+ tags: task.tags ?? '',
416
+ });
417
+ setSelectedTask(null);
418
+ setTaskFormOpen(true);
419
+ }, []);
420
+
421
+ const refetchAll = useCallback(async () => {
422
+ await Promise.all([refetch(), refetchArchivedTasks()]);
423
+ }, [refetch, refetchArchivedTasks]);
424
+
425
+ const handleTaskFormSubmit = useCallback(async () => {
426
+ if (!taskFormData.name.trim()) return;
427
+
428
+ setTaskFormLoading(true);
429
+ try {
430
+ const payload: Record<string, unknown> = {
431
+ projectId,
432
+ projectAssignmentId: project?.myAssignmentId ?? null,
433
+ assigneeCollaboratorId: currentCollaboratorId,
434
+ name: taskFormData.name.trim(),
435
+ description: taskFormData.description || null,
436
+ priority: taskFormData.priority,
437
+ status: taskFormData.status,
438
+ dueDate: taskFormData.dueDate || null,
439
+ estimateHours: taskFormData.estimateHours
440
+ ? Number(taskFormData.estimateHours)
441
+ : null,
442
+ tags: taskFormData.tags || null,
443
+ };
444
+
445
+ if (editingTaskId) {
446
+ await mutateOperations(
447
+ request,
448
+ `/operations/tasks/${editingTaskId}`,
449
+ 'PATCH',
450
+ payload
451
+ );
452
+ } else {
453
+ await mutateOperations(request, '/operations/tasks', 'POST', payload);
454
+ }
455
+
456
+ setBoardState(null);
457
+ setTaskFormOpen(false);
458
+ setEditingTaskId(null);
459
+ setTaskFormData(EMPTY_TASK_FORM);
460
+ await refetchAll();
461
+ } finally {
462
+ setTaskFormLoading(false);
463
+ }
464
+ }, [
465
+ currentCollaboratorId,
466
+ editingTaskId,
467
+ project,
468
+ projectId,
469
+ refetchAll,
470
+ request,
471
+ taskFormData,
472
+ ]);
473
+
474
+ const handleArchiveTask = useCallback(
475
+ async (taskId: number) => {
476
+ await mutateOperations(request, `/operations/tasks/${taskId}`, 'PATCH', {
477
+ archived: true,
478
+ });
479
+ setBoardState(null);
480
+ setSelectedTask(null);
481
+ await refetchAll();
482
+ },
483
+ [request, refetchAll]
484
+ );
485
+
486
+ const handleRestoreTask = useCallback(
487
+ async (taskId: number) => {
488
+ await mutateOperations(request, `/operations/tasks/${taskId}`, 'PATCH', {
489
+ archived: false,
490
+ });
491
+ setBoardState(null);
492
+ setSelectedTask(null);
493
+ setDeletePromptTask(null);
494
+ await refetchAll();
495
+ },
496
+ [request, refetchAll]
497
+ );
498
+
499
+ const handleDeleteTask = useCallback(
500
+ async (taskId: number) => {
501
+ await mutateOperations(
502
+ request,
503
+ `/operations/tasks/${taskId}?permanent=true`,
504
+ 'DELETE'
505
+ );
506
+ setBoardState(null);
507
+ setSelectedTask(null);
508
+ setDeletePromptTask(null);
509
+ await refetchAll();
510
+ },
511
+ [request, refetchAll]
512
+ );
513
+
514
+ if (!project) {
515
+ return (
516
+ <Page>
517
+ <OperationsHeader
518
+ title="—"
519
+ description=""
520
+ current={commonT('labels.loading') || 'Carregando...'}
521
+ />
522
+ <EmptyState
523
+ icon={<FolderKanban className="size-12" />}
524
+ title={commonT('states.emptyTitle')}
525
+ description={t('notFound')}
526
+ actionLabel={commonT('actions.refresh')}
527
+ onAction={() => void refetch()}
528
+ />
529
+ </Page>
530
+ );
531
+ }
532
+
533
+ return (
534
+ <Page>
535
+ <OperationsHeader
536
+ title={project.name}
537
+ description={
538
+ [project.code, project.myRoleLabel].filter(Boolean).join(' • ') || ''
539
+ }
540
+ current={project.name}
541
+ />
542
+
543
+ <div className="rounded-xl border bg-muted/20 p-4">
544
+ <dl className="grid gap-3 text-sm sm:grid-cols-2 xl:grid-cols-4">
545
+ <div>
546
+ <dt className="text-muted-foreground">
547
+ {commonT('labels.status')}
548
+ </dt>
549
+ <dd className="mt-0.5 font-medium">
550
+ <StatusBadge
551
+ label={getProjectStatusLabel(project.status)}
552
+ className={getStatusBadgeClass(project.status)}
553
+ />
554
+ </dd>
555
+ </div>
556
+ <div>
557
+ <dt className="text-muted-foreground">
558
+ {commonT('labels.startDate')}
559
+ </dt>
560
+ <dd className="mt-0.5 font-medium">
561
+ {formatDate(
562
+ project.startDate,
563
+ getSettingValue,
564
+ currentLocaleCode
565
+ )}
566
+ </dd>
567
+ </div>
568
+ <div>
569
+ <dt className="text-muted-foreground">
570
+ {commonT('labels.endDate')}
571
+ </dt>
572
+ <dd className="mt-0.5 font-medium">
573
+ {formatDate(project.endDate, getSettingValue, currentLocaleCode)}
574
+ </dd>
575
+ </div>
576
+ {project.myRoleLabel ? (
577
+ <div>
578
+ <dt className="text-muted-foreground">
579
+ {commonT('labels.role')}
580
+ </dt>
581
+ <dd className="mt-0.5 font-medium">{project.myRoleLabel}</dd>
582
+ </div>
583
+ ) : null}
584
+ </dl>
585
+ {project.summary ? (
586
+ <p className="mt-3 rounded-lg border border-border/70 bg-background/60 p-3 text-sm text-muted-foreground">
587
+ {project.summary}
588
+ </p>
589
+ ) : null}
590
+ </div>
591
+
592
+ <SectionCard
593
+ title={t('sections.taskBoard')}
594
+ description={t('sections.taskBoardDescription')}
595
+ className="rounded-xl border bg-card p-4 shadow-sm"
596
+ actions={
597
+ <Button
598
+ size="sm"
599
+ className="gap-2"
600
+ onClick={() => openCreateTaskForm()}
601
+ >
602
+ <Plus className="size-4" />
603
+ {commonT('actions.create')}
604
+ </Button>
605
+ }
606
+ >
607
+ <DndContext
608
+ sensors={sensors}
609
+ collisionDetection={closestCenter}
610
+ onDragEnd={onBoardDragEnd}
611
+ >
612
+ <div className="grid gap-4 xl:grid-cols-4">
613
+ {KANBAN_COLUMNS.map((column) => (
614
+ <DroppableColumn key={column.id} columnId={column.id}>
615
+ {(isOver) => (
616
+ <div
617
+ className={[
618
+ 'rounded-xl border bg-muted/20 p-3 transition-colors',
619
+ isOver ? 'border-primary bg-primary/5' : 'border-border',
620
+ ].join(' ')}
621
+ >
622
+ <div className="mb-3 flex items-center justify-between">
623
+ <div className="flex items-center gap-2 text-sm font-semibold">
624
+ <Rows3 className="size-4 text-muted-foreground" />
625
+ {column.label}
626
+ </div>
627
+ <span className="rounded-full bg-background px-2 py-0.5 text-xs text-muted-foreground">
628
+ {taskColumns[column.id].length}
629
+ </span>
630
+ </div>
631
+
632
+ <div className="space-y-2">
633
+ {taskColumns[column.id].map((task) => (
634
+ <DraggableTaskCard key={task.id} task={task}>
635
+ {(isDragging) => (
636
+ <div
637
+ onClick={() =>
638
+ !isDragging && setSelectedTask(task)
639
+ }
640
+ className={[
641
+ 'w-full cursor-grab rounded-lg border bg-card p-3 text-left shadow-xs transition',
642
+ isDragging
643
+ ? 'border-primary/50 opacity-75'
644
+ : 'hover:border-primary/40 hover:shadow-sm',
645
+ ].join(' ')}
646
+ >
647
+ <div className="mb-2 flex items-start justify-between gap-2">
648
+ <p className="text-sm font-medium leading-snug">
649
+ {task.name}
650
+ </p>
651
+ <div className="flex items-start gap-2">
652
+ <span
653
+ className={[
654
+ 'shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide',
655
+ task.priority === 'high'
656
+ ? 'bg-rose-100 text-rose-700'
657
+ : task.priority === 'medium'
658
+ ? 'bg-amber-100 text-amber-700'
659
+ : 'bg-emerald-100 text-emerald-700',
660
+ ].join(' ')}
661
+ >
662
+ {getTaskPriorityLabel(task.priority)}
663
+ </span>
664
+ <Button
665
+ type="button"
666
+ variant="ghost"
667
+ size="icon"
668
+ className="size-7 shrink-0 rounded-full"
669
+ onPointerDown={(event) =>
670
+ event.stopPropagation()
671
+ }
672
+ onClick={(event) => {
673
+ event.stopPropagation();
674
+ openEditTaskForm(task);
675
+ }}
676
+ >
677
+ <Pencil className="size-3.5" />
678
+ </Button>
679
+ </div>
680
+ </div>
681
+
682
+ {task.tags ? (
683
+ <div className="mb-2 flex flex-wrap gap-1">
684
+ {task.tags.split(',').map((tag) => (
685
+ <span
686
+ key={`${task.id}-${tag.trim()}`}
687
+ className="rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
688
+ >
689
+ {tag.trim()}
690
+ </span>
691
+ ))}
692
+ </div>
693
+ ) : null}
694
+
695
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
696
+ <span className="inline-flex items-center gap-1">
697
+ <AlarmClock className="size-3.5" />
698
+ {formatDate(
699
+ task.dueDate,
700
+ getSettingValue,
701
+ currentLocaleCode
702
+ )}
703
+ </span>
704
+ <span>
705
+ {task.estimateHours != null
706
+ ? `${task.estimateHours}h`
707
+ : ''}
708
+ </span>
709
+ </div>
710
+
711
+ {task.assigneeName ? (
712
+ <div className="mt-2 flex items-center gap-1.5">
713
+ <div className="flex size-5 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted text-[9px] font-semibold uppercase text-muted-foreground ring-1 ring-border">
714
+ {(() => {
715
+ const photoUrl = getUserPhotoUrl(
716
+ task.assigneeUserPhotoId
717
+ );
718
+ const avatarUrl =
719
+ task.assigneePersonAvatarId
720
+ ? getPersonAvatarUrl(
721
+ task.assigneePersonAvatarId
722
+ )
723
+ : null;
724
+ const imgSrc = photoUrl ?? avatarUrl;
725
+ return imgSrc ? (
726
+ // eslint-disable-next-line @next/next/no-img-element
727
+ <img
728
+ src={imgSrc}
729
+ alt={task.assigneeName}
730
+ className="size-full object-cover"
731
+ />
732
+ ) : (
733
+ getInitials(task.assigneeName)
734
+ );
735
+ })()}
736
+ </div>
737
+ <span className="truncate text-[11px] text-muted-foreground">
738
+ {task.assigneeName}
739
+ </span>
740
+ </div>
741
+ ) : null}
742
+ </div>
743
+ )}
744
+ </DraggableTaskCard>
745
+ ))}
746
+ </div>
747
+ </div>
748
+ )}
749
+ </DroppableColumn>
750
+ ))}
751
+ </div>
752
+ </DndContext>
753
+ </SectionCard>
754
+
755
+ <SectionCard
756
+ title={t('sections.archivedTasks')}
757
+ description={t('sections.archivedTasksDescription')}
758
+ className="rounded-xl border bg-card p-4 shadow-sm"
759
+ >
760
+ {archivedTasks.length > 0 ? (
761
+ <div className="overflow-x-auto rounded-lg border bg-muted/10">
762
+ <Table>
763
+ <TableHeader>
764
+ <TableRow>
765
+ <TableHead>{commonT('labels.name')}</TableHead>
766
+ <TableHead>{commonT('labels.status')}</TableHead>
767
+ <TableHead>{commonT('labels.deadline')}</TableHead>
768
+ <TableHead className="text-right">
769
+ {commonT('labels.actions')}
770
+ </TableHead>
771
+ </TableRow>
772
+ </TableHeader>
773
+ <TableBody>
774
+ {archivedTasks.map((task) => (
775
+ <TableRow
776
+ key={task.id}
777
+ className="cursor-pointer hover:bg-muted/30"
778
+ onClick={() => setSelectedTask(task)}
779
+ >
780
+ <TableCell>
781
+ <div className="min-w-0">
782
+ <div className="truncate font-medium">{task.name}</div>
783
+ {task.description ? (
784
+ <div className="truncate text-xs text-muted-foreground">
785
+ {task.description}
786
+ </div>
787
+ ) : null}
788
+ </div>
789
+ </TableCell>
790
+ <TableCell>
791
+ <StatusBadge
792
+ label={
793
+ KANBAN_COLUMNS.find(
794
+ (column) => column.id === task.status
795
+ )?.label ?? formatEnumLabel(task.status)
796
+ }
797
+ className={getStatusBadgeClass(task.status)}
798
+ />
799
+ </TableCell>
800
+ <TableCell>
801
+ {formatDate(
802
+ task.dueDate,
803
+ getSettingValue,
804
+ currentLocaleCode
805
+ )}
806
+ </TableCell>
807
+ <TableCell>
808
+ <div className="flex justify-end gap-2">
809
+ <Button
810
+ variant="outline"
811
+ size="sm"
812
+ className="gap-2"
813
+ onClick={(event) => {
814
+ event.stopPropagation();
815
+ void handleRestoreTask(task.id);
816
+ }}
817
+ >
818
+ <ArchiveRestore className="size-4" />
819
+ {commonT('actions.unarchive')}
820
+ </Button>
821
+ <Button
822
+ variant="destructive"
823
+ size="sm"
824
+ className="gap-2"
825
+ onClick={(event) => {
826
+ event.stopPropagation();
827
+ setDeletePromptTask(task);
828
+ }}
829
+ >
830
+ <Trash2 className="size-4" />
831
+ {commonT('actions.delete')}
832
+ </Button>
833
+ </div>
834
+ </TableCell>
835
+ </TableRow>
836
+ ))}
837
+ </TableBody>
838
+ </Table>
839
+ </div>
840
+ ) : (
841
+ <p className="text-sm text-muted-foreground">
842
+ {t('emptyArchivedDescription')}
843
+ </p>
844
+ )}
845
+ </SectionCard>
846
+
847
+ <SectionCard
848
+ title={t('sections.team')}
849
+ description={t('sections.teamDescription')}
850
+ className="rounded-xl border bg-card p-4 shadow-sm"
851
+ >
852
+ {project.assignments.length > 0 ? (
853
+ <div className="overflow-x-auto rounded-lg border bg-muted/10">
854
+ <Table>
855
+ <TableHeader>
856
+ <TableRow>
857
+ <TableHead>{commonT('labels.collaborator')}</TableHead>
858
+ <TableHead>{commonT('labels.role')}</TableHead>
859
+ <TableHead>{commonT('labels.status')}</TableHead>
860
+ </TableRow>
861
+ </TableHeader>
862
+ <TableBody>
863
+ {project.assignments.map((assignment) => (
864
+ <TableRow key={assignment.id}>
865
+ <TableCell>
866
+ <div className="flex items-center gap-2">
867
+ <Avatar className="h-8 w-8 border border-border/60 bg-muted">
868
+ <AvatarImage
869
+ src={
870
+ getUserPhotoUrl(assignment.userPhotoId) ||
871
+ getPersonAvatarUrl(assignment.avatarId)
872
+ }
873
+ alt={assignment.collaboratorName}
874
+ />
875
+ <AvatarFallback className="bg-muted text-xs font-semibold text-foreground">
876
+ {getInitials(assignment.collaboratorName)}
877
+ </AvatarFallback>
878
+ </Avatar>
879
+ <span>{assignment.collaboratorName}</span>
880
+ </div>
881
+ </TableCell>
882
+ <TableCell>
883
+ {assignment.roleLabel || commonT('labels.notAssigned')}
884
+ </TableCell>
885
+ <TableCell>
886
+ <StatusBadge
887
+ label={assignment.status}
888
+ className={getStatusBadgeClass(assignment.status)}
889
+ />
890
+ </TableCell>
891
+ </TableRow>
892
+ ))}
893
+ </TableBody>
894
+ </Table>
895
+ </div>
896
+ ) : (
897
+ <p className="text-sm text-muted-foreground">{t('noAssignments')}</p>
898
+ )}
899
+ </SectionCard>
900
+
901
+ <TaskDetailSheet
902
+ task={selectedTask}
903
+ open={selectedTask !== null}
904
+ onOpenChange={(open) => {
905
+ if (!open) setSelectedTask(null);
906
+ }}
907
+ statusLabel={(status) => {
908
+ const map: Record<string, string> = {
909
+ todo: 'Backlog',
910
+ doing: 'Em execução',
911
+ review: 'Revisão',
912
+ done: 'Concluído',
913
+ };
914
+ return map[status] ?? status;
915
+ }}
916
+ footer={
917
+ selectedTask ? (
918
+ <div className="grid grid-cols-2 gap-3">
919
+ {archivedTasks.some((task) => task.id === selectedTask.id) ? (
920
+ <>
921
+ <Button
922
+ variant="outline"
923
+ size="sm"
924
+ className="h-10 gap-2"
925
+ onClick={() => void handleRestoreTask(selectedTask.id)}
926
+ >
927
+ <ArchiveRestore className="size-3.5" />
928
+ {commonT('actions.unarchive')}
929
+ </Button>
930
+ <Button
931
+ variant="destructive"
932
+ size="sm"
933
+ className="h-10 gap-2"
934
+ onClick={() => setDeletePromptTask(selectedTask)}
935
+ >
936
+ <Trash2 className="size-3.5" />
937
+ {commonT('actions.delete')}
938
+ </Button>
939
+ </>
940
+ ) : (
941
+ <>
942
+ <Button
943
+ variant="outline"
944
+ size="sm"
945
+ className="h-10 gap-2"
946
+ onClick={() => openEditTaskForm(selectedTask as BoardTask)}
947
+ >
948
+ <Pencil className="size-3.5" />
949
+ {commonT('actions.edit')}
950
+ </Button>
951
+ <Button
952
+ variant="destructive"
953
+ size="sm"
954
+ className="h-10 gap-2"
955
+ onClick={() => void handleArchiveTask(selectedTask.id)}
956
+ >
957
+ <Archive className="size-3.5" />
958
+ {commonT('actions.archive')}
959
+ </Button>
960
+ </>
961
+ )}
962
+ </div>
963
+ ) : null
964
+ }
965
+ />
966
+
967
+ <Dialog
968
+ open={taskFormOpen}
969
+ onOpenChange={(open) => {
970
+ if (!open) {
971
+ setTaskFormOpen(false);
972
+ setEditingTaskId(null);
973
+ setTaskFormData(EMPTY_TASK_FORM);
974
+ }
975
+ }}
976
+ >
977
+ <DialogContent className="sm:max-w-lg">
978
+ <DialogHeader>
979
+ <DialogTitle>
980
+ {editingTaskId ? t('taskForm.titleEdit') : t('taskForm.titleNew')}
981
+ </DialogTitle>
982
+ </DialogHeader>
983
+
984
+ <div className="space-y-4">
985
+ <div className="space-y-1.5">
986
+ <Label htmlFor="task-name">{t('taskForm.nameLabel')} *</Label>
987
+ <Input
988
+ id="task-name"
989
+ placeholder={t('taskForm.namePlaceholder')}
990
+ value={taskFormData.name}
991
+ onChange={(e) =>
992
+ setTaskFormData((prev) => ({
993
+ ...prev,
994
+ name: e.target.value,
995
+ }))
996
+ }
997
+ />
998
+ </div>
999
+
1000
+ <div className="space-y-1.5">
1001
+ <Label htmlFor="task-description">
1002
+ {t('taskForm.descriptionLabel')}
1003
+ </Label>
1004
+ <Textarea
1005
+ id="task-description"
1006
+ placeholder={t('taskForm.descriptionPlaceholder')}
1007
+ rows={3}
1008
+ value={taskFormData.description}
1009
+ onChange={(e) =>
1010
+ setTaskFormData((prev) => ({
1011
+ ...prev,
1012
+ description: e.target.value,
1013
+ }))
1014
+ }
1015
+ />
1016
+ </div>
1017
+
1018
+ <div className="grid grid-cols-2 gap-3">
1019
+ <div className="space-y-1.5">
1020
+ <Label>{t('taskForm.priorityLabel')}</Label>
1021
+ <Select
1022
+ value={taskFormData.priority}
1023
+ onValueChange={(v) =>
1024
+ setTaskFormData((prev) => ({
1025
+ ...prev,
1026
+ priority: v as TaskFormState['priority'],
1027
+ }))
1028
+ }
1029
+ >
1030
+ <SelectTrigger className="w-full">
1031
+ <SelectValue />
1032
+ </SelectTrigger>
1033
+ <SelectContent>
1034
+ <SelectItem value="low">
1035
+ {getTaskPriorityLabel('low')}
1036
+ </SelectItem>
1037
+ <SelectItem value="medium">
1038
+ {getTaskPriorityLabel('medium')}
1039
+ </SelectItem>
1040
+ <SelectItem value="high">
1041
+ {getTaskPriorityLabel('high')}
1042
+ </SelectItem>
1043
+ </SelectContent>
1044
+ </Select>
1045
+ </div>
1046
+
1047
+ <div className="space-y-1.5">
1048
+ <Label>{t('taskForm.columnLabel')}</Label>
1049
+ <Select
1050
+ value={taskFormData.status}
1051
+ onValueChange={(v) =>
1052
+ setTaskFormData((prev) => ({
1053
+ ...prev,
1054
+ status: v as BoardColumnId,
1055
+ }))
1056
+ }
1057
+ >
1058
+ <SelectTrigger className="w-full">
1059
+ <SelectValue />
1060
+ </SelectTrigger>
1061
+ <SelectContent>
1062
+ {KANBAN_COLUMNS.map((col) => (
1063
+ <SelectItem key={col.id} value={col.id}>
1064
+ {col.label}
1065
+ </SelectItem>
1066
+ ))}
1067
+ </SelectContent>
1068
+ </Select>
1069
+ </div>
1070
+ </div>
1071
+
1072
+ <div className="grid grid-cols-2 gap-3">
1073
+ <div className="space-y-1.5">
1074
+ <Label htmlFor="task-due-date">
1075
+ {t('taskForm.deadlineLabel')}
1076
+ </Label>
1077
+ <Input
1078
+ id="task-due-date"
1079
+ type="date"
1080
+ value={taskFormData.dueDate}
1081
+ onChange={(e) =>
1082
+ setTaskFormData((prev) => ({
1083
+ ...prev,
1084
+ dueDate: e.target.value,
1085
+ }))
1086
+ }
1087
+ />
1088
+ </div>
1089
+
1090
+ <div className="space-y-1.5">
1091
+ <Label htmlFor="task-estimate">
1092
+ {t('taskForm.estimateLabel')}
1093
+ </Label>
1094
+ <Input
1095
+ id="task-estimate"
1096
+ type="number"
1097
+ min="0"
1098
+ step="0.5"
1099
+ placeholder="0"
1100
+ value={taskFormData.estimateHours}
1101
+ onChange={(e) =>
1102
+ setTaskFormData((prev) => ({
1103
+ ...prev,
1104
+ estimateHours: e.target.value,
1105
+ }))
1106
+ }
1107
+ />
1108
+ </div>
1109
+ </div>
1110
+
1111
+ <div className="space-y-1.5">
1112
+ <Label htmlFor="task-tags">{t('taskForm.tagsLabel')}</Label>
1113
+ <Input
1114
+ id="task-tags"
1115
+ placeholder={t('taskForm.tagsPlaceholder')}
1116
+ value={taskFormData.tags}
1117
+ onChange={(e) =>
1118
+ setTaskFormData((prev) => ({
1119
+ ...prev,
1120
+ tags: e.target.value,
1121
+ }))
1122
+ }
1123
+ />
1124
+ </div>
1125
+ </div>
1126
+
1127
+ <DialogFooter className="mt-4">
1128
+ <Button
1129
+ variant="outline"
1130
+ onClick={() => {
1131
+ setTaskFormOpen(false);
1132
+ setEditingTaskId(null);
1133
+ setTaskFormData(EMPTY_TASK_FORM);
1134
+ }}
1135
+ disabled={taskFormLoading}
1136
+ >
1137
+ {commonT('actions.cancel')}
1138
+ </Button>
1139
+ <Button
1140
+ onClick={() => void handleTaskFormSubmit()}
1141
+ disabled={taskFormLoading || !taskFormData.name.trim()}
1142
+ >
1143
+ {taskFormLoading
1144
+ ? t('taskForm.saving')
1145
+ : editingTaskId
1146
+ ? commonT('actions.save')
1147
+ : commonT('actions.create')}
1148
+ </Button>
1149
+ </DialogFooter>
1150
+ </DialogContent>
1151
+ </Dialog>
1152
+
1153
+ <Dialog
1154
+ open={deletePromptTask !== null}
1155
+ onOpenChange={(open) => {
1156
+ if (!open) {
1157
+ setDeletePromptTask(null);
1158
+ }
1159
+ }}
1160
+ >
1161
+ <DialogContent className="sm:max-w-lg">
1162
+ <DialogHeader>
1163
+ <DialogTitle>{t('dialogs.deleteTitle')}</DialogTitle>
1164
+ </DialogHeader>
1165
+ <p className="text-sm text-muted-foreground">
1166
+ {t('dialogs.deleteDescription')}
1167
+ </p>
1168
+ <DialogFooter className="gap-2 sm:justify-between">
1169
+ <Button variant="ghost" onClick={() => setDeletePromptTask(null)}>
1170
+ {commonT('actions.cancel')}
1171
+ </Button>
1172
+ {deletePromptTask ? (
1173
+ <Button
1174
+ variant="destructive"
1175
+ onClick={() => void handleDeleteTask(deletePromptTask.id)}
1176
+ >
1177
+ {commonT('actions.delete')}
1178
+ </Button>
1179
+ ) : null}
1180
+ </DialogFooter>
1181
+ </DialogContent>
1182
+ </Dialog>
1183
+ </Page>
1184
+ );
1185
+ }