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