@hed-hog/operations 0.0.294 → 0.0.296

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 (126) hide show
  1. package/dist/operations.controller.d.ts +415 -0
  2. package/dist/operations.controller.d.ts.map +1 -0
  3. package/dist/operations.controller.js +333 -0
  4. package/dist/operations.controller.js.map +1 -0
  5. package/dist/operations.module.d.ts.map +1 -1
  6. package/dist/operations.module.js +4 -3
  7. package/dist/operations.module.js.map +1 -1
  8. package/dist/operations.service.d.ts +589 -153
  9. package/dist/operations.service.d.ts.map +1 -1
  10. package/dist/operations.service.js +2229 -100
  11. package/dist/operations.service.js.map +1 -1
  12. package/hedhog/data/menu.yaml +198 -251
  13. package/hedhog/data/role.yaml +23 -14
  14. package/hedhog/data/route.yaml +317 -143
  15. package/hedhog/frontend/app/_components/collaborator-details-screen.tsx.ejs +310 -0
  16. package/hedhog/frontend/app/_components/collaborator-form-screen.tsx.ejs +631 -0
  17. package/hedhog/frontend/app/_components/contract-details-screen.tsx.ejs +132 -0
  18. package/hedhog/frontend/app/_components/contract-form-screen.tsx.ejs +558 -0
  19. package/hedhog/frontend/app/_components/project-details-screen.tsx.ejs +291 -0
  20. package/hedhog/frontend/app/_components/project-form-screen.tsx.ejs +689 -0
  21. package/hedhog/frontend/app/_lib/api.ts.ejs +32 -0
  22. package/hedhog/frontend/app/_lib/hooks/use-operations-access.ts.ejs +44 -0
  23. package/hedhog/frontend/app/_lib/types.ts.ejs +360 -0
  24. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +129 -25
  25. package/hedhog/frontend/app/_lib/utils/forms.ts.ejs +14 -0
  26. package/hedhog/frontend/app/approvals/page.tsx.ejs +386 -147
  27. package/hedhog/frontend/app/collaborators/[id]/edit/page.tsx.ejs +11 -0
  28. package/hedhog/frontend/app/collaborators/[id]/page.tsx.ejs +11 -0
  29. package/hedhog/frontend/app/collaborators/new/page.tsx.ejs +5 -0
  30. package/hedhog/frontend/app/collaborators/page.tsx.ejs +261 -0
  31. package/hedhog/frontend/app/contracts/[id]/edit/page.tsx.ejs +11 -0
  32. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +11 -108
  33. package/hedhog/frontend/app/contracts/new/page.tsx.ejs +17 -0
  34. package/hedhog/frontend/app/contracts/page.tsx.ejs +262 -181
  35. package/hedhog/frontend/app/page.tsx.ejs +319 -177
  36. package/hedhog/frontend/app/projects/[id]/edit/page.tsx.ejs +11 -0
  37. package/hedhog/frontend/app/projects/[id]/page.tsx.ejs +11 -936
  38. package/hedhog/frontend/app/projects/new/page.tsx.ejs +5 -0
  39. package/hedhog/frontend/app/projects/page.tsx.ejs +236 -1074
  40. package/hedhog/frontend/app/schedule-adjustments/page.tsx.ejs +418 -0
  41. package/hedhog/frontend/app/team/page.tsx.ejs +339 -0
  42. package/hedhog/frontend/app/time-off/page.tsx.ejs +328 -0
  43. package/hedhog/frontend/app/timesheets/page.tsx.ejs +636 -126
  44. package/hedhog/frontend/messages/en.json +648 -454
  45. package/hedhog/frontend/messages/pt.json +647 -454
  46. package/hedhog/table/operations_approval.yaml +49 -0
  47. package/hedhog/table/operations_approval_history.yaml +29 -0
  48. package/hedhog/table/{operations_employee.yaml → operations_collaborator.yaml} +67 -64
  49. package/hedhog/table/operations_collaborator_schedule_day.yaml +34 -0
  50. package/hedhog/table/operations_contract.yaml +100 -48
  51. package/hedhog/table/operations_contract_document.yaml +39 -0
  52. package/hedhog/table/operations_contract_financial_term.yaml +40 -0
  53. package/hedhog/table/operations_contract_history.yaml +27 -0
  54. package/hedhog/table/operations_contract_party.yaml +46 -0
  55. package/hedhog/table/operations_contract_revision.yaml +38 -0
  56. package/hedhog/table/operations_contract_signature.yaml +38 -0
  57. package/hedhog/table/operations_project.yaml +54 -50
  58. package/hedhog/table/{operations_allocation.yaml → operations_project_assignment.yaml} +55 -52
  59. package/hedhog/table/operations_schedule_adjustment_day.yaml +34 -0
  60. package/hedhog/table/operations_schedule_adjustment_request.yaml +53 -0
  61. package/hedhog/table/operations_time_off_request.yaml +57 -0
  62. package/hedhog/table/operations_timesheet.yaml +41 -36
  63. package/hedhog/table/operations_timesheet_entry.yaml +40 -50
  64. package/package.json +7 -6
  65. package/src/operations.controller.ts +182 -0
  66. package/src/operations.module.ts +22 -21
  67. package/src/operations.service.ts +3595 -137
  68. package/hedhog/data/operations_career_level.yaml +0 -102
  69. package/hedhog/data/operations_career_track.yaml +0 -8
  70. package/hedhog/data/operations_certification.yaml +0 -38
  71. package/hedhog/data/operations_evaluation_cycle.yaml +0 -18
  72. package/hedhog/data/operations_performance_criterion.yaml +0 -48
  73. package/hedhog/frontend/app/_components/allocation-calendar.tsx.ejs +0 -56
  74. package/hedhog/frontend/app/_components/kanban-board.tsx.ejs +0 -626
  75. package/hedhog/frontend/app/_components/timesheet-entry-dialog.tsx.ejs +0 -142
  76. package/hedhog/frontend/app/_lib/hooks/use-operations-data.ts.ejs +0 -41
  77. package/hedhog/frontend/app/_lib/hooks/use-operations-growth-data.ts.ejs +0 -63
  78. package/hedhog/frontend/app/_lib/mocks/allocations.mock.ts.ejs +0 -74
  79. package/hedhog/frontend/app/_lib/mocks/contracts.mock.ts.ejs +0 -74
  80. package/hedhog/frontend/app/_lib/mocks/operations-growth.mock.ts.ejs +0 -824
  81. package/hedhog/frontend/app/_lib/mocks/projects.mock.ts.ejs +0 -455
  82. package/hedhog/frontend/app/_lib/mocks/tasks.mock.ts.ejs +0 -117
  83. package/hedhog/frontend/app/_lib/mocks/timesheets.mock.ts.ejs +0 -84
  84. package/hedhog/frontend/app/_lib/mocks/users.mock.ts.ejs +0 -67
  85. package/hedhog/frontend/app/_lib/services/contracts.service.ts.ejs +0 -10
  86. package/hedhog/frontend/app/_lib/services/operations-growth.service.ts.ejs +0 -31
  87. package/hedhog/frontend/app/_lib/services/projects.service.ts.ejs +0 -10
  88. package/hedhog/frontend/app/_lib/services/tasks.service.ts.ejs +0 -10
  89. package/hedhog/frontend/app/_lib/services/timesheets.service.ts.ejs +0 -10
  90. package/hedhog/frontend/app/_lib/types/operations-growth.ts.ejs +0 -209
  91. package/hedhog/frontend/app/_lib/types/operations.ts.ejs +0 -156
  92. package/hedhog/frontend/app/_lib/utils/growth.ts.ejs +0 -62
  93. package/hedhog/frontend/app/_lib/utils/metrics.ts.ejs +0 -103
  94. package/hedhog/frontend/app/_lib/utils/status.ts.ejs +0 -80
  95. package/hedhog/frontend/app/allocations/page.tsx.ejs +0 -155
  96. package/hedhog/frontend/app/career/page.tsx.ejs +0 -143
  97. package/hedhog/frontend/app/certifications/page.tsx.ejs +0 -202
  98. package/hedhog/frontend/app/evaluations/page.tsx.ejs +0 -278
  99. package/hedhog/frontend/app/goals/page.tsx.ejs +0 -171
  100. package/hedhog/frontend/app/growth/page.tsx.ejs +0 -288
  101. package/hedhog/frontend/app/manager/page.tsx.ejs +0 -175
  102. package/hedhog/frontend/app/rewards/page.tsx.ejs +0 -196
  103. package/hedhog/frontend/app/tasks/page.tsx.ejs +0 -999
  104. package/hedhog/table/operations_calibration_item.yaml +0 -61
  105. package/hedhog/table/operations_calibration_session.yaml +0 -25
  106. package/hedhog/table/operations_career_level.yaml +0 -75
  107. package/hedhog/table/operations_career_track.yaml +0 -21
  108. package/hedhog/table/operations_certification.yaml +0 -48
  109. package/hedhog/table/operations_employee_certification.yaml +0 -43
  110. package/hedhog/table/operations_employee_connect.yaml +0 -61
  111. package/hedhog/table/operations_employee_evaluation.yaml +0 -113
  112. package/hedhog/table/operations_employee_evaluation_item.yaml +0 -39
  113. package/hedhog/table/operations_employee_profile.yaml +0 -80
  114. package/hedhog/table/operations_employee_skill_matrix.yaml +0 -30
  115. package/hedhog/table/operations_evaluation_cycle.yaml +0 -31
  116. package/hedhog/table/operations_goal.yaml +0 -67
  117. package/hedhog/table/operations_goal_progress.yaml +0 -31
  118. package/hedhog/table/operations_performance_criterion.yaml +0 -29
  119. package/hedhog/table/operations_promotion_readiness.yaml +0 -49
  120. package/hedhog/table/operations_promotion_recommendation.yaml +0 -63
  121. package/hedhog/table/operations_public_recognition.yaml +0 -46
  122. package/hedhog/table/operations_reward.yaml +0 -100
  123. package/hedhog/table/operations_score_event.yaml +0 -81
  124. package/hedhog/table/operations_task.yaml +0 -60
  125. package/src/operations-data.controller.ts +0 -54
  126. package/src/operations-growth.controller.ts +0 -44
@@ -1,999 +0,0 @@
1
- 'use client';
2
-
3
- import { Page } from '@/components/entity-list';
4
- import { Badge } from '@/components/ui/badge';
5
- import { Button } from '@/components/ui/button';
6
- import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
7
- import {
8
- CommandDialog,
9
- CommandEmpty,
10
- CommandGroup,
11
- CommandInput,
12
- CommandItem,
13
- CommandList,
14
- CommandShortcut,
15
- } from '@/components/ui/command';
16
- import { Input } from '@/components/ui/input';
17
- import {
18
- Select,
19
- SelectContent,
20
- SelectItem,
21
- SelectTrigger,
22
- SelectValue,
23
- } from '@/components/ui/select';
24
- import {
25
- Sheet,
26
- SheetContent,
27
- SheetDescription,
28
- SheetHeader,
29
- SheetTitle,
30
- } from '@/components/ui/sheet';
31
- import { cn } from '@/lib/utils';
32
- import { Plus, Rows3, Search, Sparkles, Trash2 } from 'lucide-react';
33
- import { useTranslations } from 'next-intl';
34
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
35
- import {
36
- KanbanBoard,
37
- TASK_UNASSIGNED_LANE,
38
- type MoveTasksPayload,
39
- } from '../_components/kanban-board';
40
- import { OperationsHeader } from '../_components/operations-header';
41
- import { useOperationsData } from '../_lib/hooks/use-operations-data';
42
- import type { Task, TaskPriority, TaskStatus } from '../_lib/types/operations';
43
-
44
- const BOARD_PREFERENCES_KEY = 'operations:tasks:board-preferences';
45
- const TASK_STATUSES: TaskStatus[] = [
46
- 'backlog',
47
- 'todo',
48
- 'in-progress',
49
- 'review',
50
- 'done',
51
- ];
52
-
53
- type ColumnMeta = {
54
- laneId: string;
55
- status: TaskStatus;
56
- rowIndex: number;
57
- };
58
-
59
- function getLaneId(task: Task) {
60
- return task.assignedUserId || TASK_UNASSIGNED_LANE;
61
- }
62
-
63
- function isTypingElement(target: EventTarget | null) {
64
- if (!(target instanceof HTMLElement)) {
65
- return false;
66
- }
67
-
68
- const tag = target.tagName;
69
- return (
70
- tag === 'INPUT' ||
71
- tag === 'TEXTAREA' ||
72
- tag === 'SELECT' ||
73
- Boolean(target.closest('[contenteditable=true]'))
74
- );
75
- }
76
-
77
- function buildContainerKey(laneId: string, status: TaskStatus) {
78
- return `${laneId}::${status}`;
79
- }
80
-
81
- function parseContainerKey(
82
- key: string
83
- ): { laneId: string; status: TaskStatus } | null {
84
- const [laneId, status] = key.split('::');
85
-
86
- if (!laneId || !status || !TASK_STATUSES.includes(status as TaskStatus)) {
87
- return null;
88
- }
89
-
90
- return {
91
- laneId,
92
- status: status as TaskStatus,
93
- };
94
- }
95
-
96
- function getPriorityValue(priority: TaskPriority) {
97
- return {
98
- low: 1,
99
- medium: 2,
100
- high: 3,
101
- critical: 4,
102
- }[priority];
103
- }
104
-
105
- export default function TasksPage() {
106
- const t = useTranslations('operations.TasksPage');
107
- const { tasks, users } = useOperationsData();
108
- const searchInputRef = useRef<HTMLInputElement>(null);
109
-
110
- const [boardTasks, setBoardTasks] = useState<Task[]>(() => tasks);
111
- const [searchDraft, setSearchDraft] = useState('');
112
- const [searchQuery, setSearchQuery] = useState('');
113
- const [assigneeFilter, setAssigneeFilter] = useState('all');
114
- const [projectFilter, setProjectFilter] = useState('all');
115
- const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([]);
116
- const [focusedTaskId, setFocusedTaskId] = useState<string | null>(null);
117
- const [keyboardDragMode, setKeyboardDragMode] = useState(false);
118
- const [commandOpen, setCommandOpen] = useState(false);
119
- const [detailTaskId, setDetailTaskId] = useState<string | null>(null);
120
- const [collapsedLaneIds, setCollapsedLaneIds] = useState<string[]>([]);
121
-
122
- useEffect(() => {
123
- setBoardTasks(tasks);
124
- }, [tasks]);
125
-
126
- useEffect(() => {
127
- if (typeof window === 'undefined') {
128
- return;
129
- }
130
-
131
- const raw = window.localStorage.getItem(BOARD_PREFERENCES_KEY);
132
- if (!raw) {
133
- return;
134
- }
135
-
136
- try {
137
- const parsed = JSON.parse(raw) as {
138
- assigneeFilter?: string;
139
- projectFilter?: string;
140
- collapsedLaneIds?: string[];
141
- };
142
-
143
- if (parsed.assigneeFilter) {
144
- setAssigneeFilter(parsed.assigneeFilter);
145
- }
146
-
147
- if (parsed.projectFilter) {
148
- setProjectFilter(parsed.projectFilter);
149
- }
150
-
151
- if (Array.isArray(parsed.collapsedLaneIds)) {
152
- setCollapsedLaneIds(parsed.collapsedLaneIds);
153
- }
154
- } catch {
155
- window.localStorage.removeItem(BOARD_PREFERENCES_KEY);
156
- }
157
- }, []);
158
-
159
- useEffect(() => {
160
- if (typeof window === 'undefined') {
161
- return;
162
- }
163
-
164
- window.localStorage.setItem(
165
- BOARD_PREFERENCES_KEY,
166
- JSON.stringify({
167
- assigneeFilter,
168
- projectFilter,
169
- collapsedLaneIds,
170
- })
171
- );
172
- }, [assigneeFilter, collapsedLaneIds, projectFilter]);
173
-
174
- const activeTasks = useMemo(
175
- () => boardTasks.filter((task) => !task.archived),
176
- [boardTasks]
177
- );
178
-
179
- const projectOptions = useMemo(
180
- () =>
181
- Array.from(new Set(activeTasks.map((task) => task.projectName))).sort(
182
- (a, b) => a.localeCompare(b)
183
- ),
184
- [activeTasks]
185
- );
186
-
187
- const totalSelected = selectedTaskIds.length;
188
-
189
- const filteredTasks = useMemo(() => {
190
- const normalizedSearch = searchQuery.trim().toLowerCase();
191
-
192
- return activeTasks
193
- .filter((task) => {
194
- if (assigneeFilter !== 'all') {
195
- if (assigneeFilter === TASK_UNASSIGNED_LANE && task.assignedUserId) {
196
- return false;
197
- }
198
-
199
- if (
200
- assigneeFilter !== TASK_UNASSIGNED_LANE &&
201
- task.assignedUserId !== assigneeFilter
202
- ) {
203
- return false;
204
- }
205
- }
206
-
207
- if (projectFilter !== 'all' && task.projectName !== projectFilter) {
208
- return false;
209
- }
210
-
211
- if (!normalizedSearch) {
212
- return true;
213
- }
214
-
215
- return `${task.title} ${task.projectName} ${task.labels.join(' ')} ${task.description}`
216
- .toLowerCase()
217
- .includes(normalizedSearch);
218
- })
219
- .sort((a, b) => a.order - b.order);
220
- }, [activeTasks, assigneeFilter, projectFilter, searchQuery]);
221
-
222
- const boardNavigationModel = useMemo(() => {
223
- const laneOrder = [
224
- ...users.map((user) => user.id),
225
- ...(filteredTasks.some((task) => !task.assignedUserId)
226
- ? [TASK_UNASSIGNED_LANE]
227
- : []),
228
- ];
229
-
230
- const byColumn = new Map<string, string[]>();
231
- const byTask = new Map<string, ColumnMeta>();
232
- const linearTaskOrder: string[] = [];
233
-
234
- laneOrder.forEach((laneId) => {
235
- TASK_STATUSES.forEach((status) => {
236
- const ids = filteredTasks
237
- .filter(
238
- (task) => getLaneId(task) === laneId && task.status === status
239
- )
240
- .sort((a, b) => a.order - b.order)
241
- .map((task) => task.id);
242
-
243
- byColumn.set(buildContainerKey(laneId, status), ids);
244
-
245
- ids.forEach((taskId, rowIndex) => {
246
- byTask.set(taskId, {
247
- laneId,
248
- status,
249
- rowIndex,
250
- });
251
- linearTaskOrder.push(taskId);
252
- });
253
- });
254
- });
255
-
256
- return {
257
- laneOrder,
258
- byColumn,
259
- byTask,
260
- linearTaskOrder,
261
- };
262
- }, [filteredTasks, users]);
263
-
264
- const taskById = useMemo(
265
- () => new Map(boardTasks.map((task) => [task.id, task])),
266
- [boardTasks]
267
- );
268
-
269
- const metrics = useMemo(() => {
270
- const inProgress = activeTasks.filter(
271
- (task) => task.status === 'in-progress'
272
- ).length;
273
-
274
- const review = activeTasks.filter(
275
- (task) => task.status === 'review'
276
- ).length;
277
- const done = activeTasks.filter((task) => task.status === 'done').length;
278
- const critical = activeTasks.filter(
279
- (task) => getPriorityValue(task.priority) >= getPriorityValue('high')
280
- ).length;
281
-
282
- return [
283
- {
284
- title: t('stats.totalTasks'),
285
- value: activeTasks.length,
286
- },
287
- {
288
- title: t('stats.inProgress'),
289
- value: inProgress,
290
- },
291
- {
292
- title: t('stats.review'),
293
- value: review,
294
- },
295
- {
296
- title: t('stats.done'),
297
- value: done,
298
- },
299
- {
300
- title: t('stats.critical'),
301
- value: critical,
302
- },
303
- ];
304
- }, [activeTasks, t]);
305
-
306
- const createTask = useCallback(() => {
307
- const generatedId = `tsk-${Date.now()}`;
308
- const now = new Date();
309
- const suggestedUserId =
310
- assigneeFilter !== 'all' && assigneeFilter !== TASK_UNASSIGNED_LANE
311
- ? assigneeFilter
312
- : '';
313
-
314
- setBoardTasks((previous) => {
315
- const nextOrder =
316
- previous
317
- .filter(
318
- (task) =>
319
- !task.archived &&
320
- getLaneId(task) === (suggestedUserId || TASK_UNASSIGNED_LANE) &&
321
- task.status === 'backlog'
322
- )
323
- .reduce((maxOrder, task) => Math.max(maxOrder, task.order), 0) + 1;
324
-
325
- return [
326
- {
327
- id: generatedId,
328
- title: t('newTaskTitle'),
329
- projectId: 'prj-orion-web',
330
- projectName:
331
- projectFilter === 'all' ? 'Orion Commerce Revamp' : projectFilter,
332
- status: 'backlog',
333
- priority: 'medium',
334
- labels: ['Planning'],
335
- assignedUserId: suggestedUserId,
336
- dueDate: new Date(now.setDate(now.getDate() + 7))
337
- .toISOString()
338
- .slice(0, 10),
339
- estimatedHours: 6,
340
- order: nextOrder,
341
- description: t('newTaskDescription'),
342
- archived: false,
343
- },
344
- ...previous,
345
- ];
346
- });
347
-
348
- setFocusedTaskId(generatedId);
349
- setSelectedTaskIds([generatedId]);
350
- }, [assigneeFilter, projectFilter, t]);
351
-
352
- const archiveCurrentSelection = useCallback(() => {
353
- const idsToArchive = selectedTaskIds.length
354
- ? selectedTaskIds
355
- : focusedTaskId
356
- ? [focusedTaskId]
357
- : [];
358
-
359
- if (!idsToArchive.length) {
360
- return;
361
- }
362
-
363
- const archiveSet = new Set(idsToArchive);
364
-
365
- setBoardTasks((previous) =>
366
- previous.map((task) =>
367
- archiveSet.has(task.id) ? { ...task, archived: true } : task
368
- )
369
- );
370
- setSelectedTaskIds([]);
371
- setFocusedTaskId(null);
372
- setDetailTaskId(null);
373
- }, [focusedTaskId, selectedTaskIds]);
374
-
375
- const moveFocus = useCallback(
376
- (direction: 'up' | 'down' | 'left' | 'right') => {
377
- if (!boardNavigationModel.linearTaskOrder.length) {
378
- return;
379
- }
380
-
381
- if (!focusedTaskId || !boardNavigationModel.byTask.has(focusedTaskId)) {
382
- const firstTaskId = boardNavigationModel.linearTaskOrder[0];
383
-
384
- if (!firstTaskId) {
385
- return;
386
- }
387
-
388
- setFocusedTaskId(firstTaskId);
389
- setSelectedTaskIds([firstTaskId]);
390
- return;
391
- }
392
-
393
- const currentMeta = boardNavigationModel.byTask.get(focusedTaskId);
394
-
395
- if (!currentMeta) {
396
- return;
397
- }
398
-
399
- if (direction === 'up' || direction === 'down') {
400
- const columnIds =
401
- boardNavigationModel.byColumn.get(
402
- buildContainerKey(currentMeta.laneId, currentMeta.status)
403
- ) || [];
404
- const delta = direction === 'up' ? -1 : 1;
405
- const nextId = columnIds[currentMeta.rowIndex + delta];
406
-
407
- if (nextId) {
408
- setFocusedTaskId(nextId);
409
- setSelectedTaskIds([nextId]);
410
- }
411
-
412
- return;
413
- }
414
-
415
- const statusIndex = TASK_STATUSES.findIndex(
416
- (status) => status === currentMeta.status
417
- );
418
- const nextStatusIndex =
419
- direction === 'left' ? statusIndex - 1 : statusIndex + 1;
420
-
421
- if (nextStatusIndex < 0 || nextStatusIndex >= TASK_STATUSES.length) {
422
- return;
423
- }
424
-
425
- const targetStatus = TASK_STATUSES[nextStatusIndex];
426
-
427
- if (!targetStatus) {
428
- return;
429
- }
430
-
431
- const targetColumn =
432
- boardNavigationModel.byColumn.get(
433
- buildContainerKey(currentMeta.laneId, targetStatus)
434
- ) || [];
435
-
436
- const nextId =
437
- targetColumn[Math.min(currentMeta.rowIndex, targetColumn.length - 1)] ||
438
- targetColumn[0];
439
-
440
- if (nextId) {
441
- setFocusedTaskId(nextId);
442
- setSelectedTaskIds([nextId]);
443
- }
444
- },
445
- [boardNavigationModel, focusedTaskId]
446
- );
447
-
448
- const toggleLane = useCallback((laneId: string) => {
449
- setCollapsedLaneIds((previous) =>
450
- previous.includes(laneId)
451
- ? previous.filter((item) => item !== laneId)
452
- : [...previous, laneId]
453
- );
454
- }, []);
455
-
456
- const handleSelectionChange = useCallback(
457
- (taskId: string, multi: boolean) => {
458
- setFocusedTaskId(taskId);
459
-
460
- setSelectedTaskIds((previous) => {
461
- if (!multi) {
462
- return [taskId];
463
- }
464
-
465
- if (previous.includes(taskId)) {
466
- return previous.filter((id) => id !== taskId);
467
- }
468
-
469
- return [...previous, taskId];
470
- });
471
- },
472
- []
473
- );
474
-
475
- const handleMoveTasks = useCallback(
476
- ({ draggedTaskId, toLaneId, toStatus, beforeTaskId }: MoveTasksPayload) => {
477
- setBoardTasks((previous) => {
478
- const taskMap = new Map(previous.map((task) => [task.id, task]));
479
- const grouped = new Map<string, string[]>();
480
-
481
- previous
482
- .filter((task) => !task.archived)
483
- .sort((a, b) => a.order - b.order)
484
- .forEach((task) => {
485
- const key = buildContainerKey(getLaneId(task), task.status);
486
- const list = grouped.get(key) || [];
487
- list.push(task.id);
488
- grouped.set(key, list);
489
- });
490
-
491
- const selectedSet = new Set(
492
- selectedTaskIds.includes(draggedTaskId)
493
- ? selectedTaskIds
494
- : [draggedTaskId]
495
- );
496
-
497
- const movingTaskIds = Array.from(selectedSet)
498
- .filter((taskId) => {
499
- const task = taskMap.get(taskId);
500
- return Boolean(task && !task.archived);
501
- })
502
- .sort(
503
- (left, right) =>
504
- (taskMap.get(left)?.order || 0) - (taskMap.get(right)?.order || 0)
505
- );
506
-
507
- if (!movingTaskIds.length) {
508
- return previous;
509
- }
510
-
511
- grouped.forEach((ids, key) => {
512
- grouped.set(
513
- key,
514
- ids.filter((id) => !selectedSet.has(id))
515
- );
516
- });
517
-
518
- let targetContainerKey = buildContainerKey(toLaneId, toStatus);
519
-
520
- if (beforeTaskId && !selectedSet.has(beforeTaskId)) {
521
- const beforeTask = taskMap.get(beforeTaskId);
522
-
523
- if (beforeTask) {
524
- targetContainerKey = buildContainerKey(
525
- getLaneId(beforeTask),
526
- beforeTask.status
527
- );
528
- }
529
- }
530
-
531
- const targetContainer = grouped.get(targetContainerKey) || [];
532
- const insertionIndex =
533
- beforeTaskId && targetContainer.includes(beforeTaskId)
534
- ? targetContainer.indexOf(beforeTaskId)
535
- : targetContainer.length;
536
-
537
- targetContainer.splice(insertionIndex, 0, ...movingTaskIds);
538
- grouped.set(targetContainerKey, targetContainer);
539
-
540
- const updates = new Map<
541
- string,
542
- {
543
- laneId: string;
544
- status: TaskStatus;
545
- order: number;
546
- }
547
- >();
548
-
549
- grouped.forEach((ids, key) => {
550
- const parsed = parseContainerKey(key);
551
-
552
- if (!parsed) {
553
- return;
554
- }
555
-
556
- ids.forEach((taskId, index) => {
557
- updates.set(taskId, {
558
- laneId: parsed.laneId,
559
- status: parsed.status,
560
- order: index + 1,
561
- });
562
- });
563
- });
564
-
565
- return previous.map((task) => {
566
- const update = updates.get(task.id);
567
-
568
- if (!update) {
569
- return task;
570
- }
571
-
572
- return {
573
- ...task,
574
- assignedUserId:
575
- update.laneId === TASK_UNASSIGNED_LANE ? '' : update.laneId,
576
- status: update.status,
577
- order: update.order,
578
- };
579
- });
580
- });
581
- },
582
- [selectedTaskIds]
583
- );
584
-
585
- useEffect(() => {
586
- const onKeyDown = (event: KeyboardEvent) => {
587
- const key = event.key.toLowerCase();
588
- const typing = isTypingElement(event.target);
589
-
590
- if ((event.ctrlKey || event.metaKey) && key === 'f') {
591
- event.preventDefault();
592
- searchInputRef.current?.focus();
593
- return;
594
- }
595
-
596
- if ((event.ctrlKey || event.metaKey) && event.shiftKey && key === 'n') {
597
- event.preventDefault();
598
- createTask();
599
- return;
600
- }
601
-
602
- if ((event.ctrlKey || event.metaKey) && key === 'k') {
603
- event.preventDefault();
604
- setCommandOpen((open) => !open);
605
- return;
606
- }
607
-
608
- if (typing || commandOpen) {
609
- return;
610
- }
611
-
612
- if (event.key === 'Delete') {
613
- event.preventDefault();
614
- archiveCurrentSelection();
615
- return;
616
- }
617
-
618
- if (event.key === 'Enter') {
619
- if (focusedTaskId) {
620
- event.preventDefault();
621
- setDetailTaskId(focusedTaskId);
622
- }
623
- return;
624
- }
625
-
626
- if (event.key === ' ') {
627
- event.preventDefault();
628
- setKeyboardDragMode((enabled) => !enabled);
629
- return;
630
- }
631
-
632
- if (event.key === 'ArrowUp') {
633
- event.preventDefault();
634
- moveFocus('up');
635
- return;
636
- }
637
-
638
- if (event.key === 'ArrowDown') {
639
- event.preventDefault();
640
- moveFocus('down');
641
- return;
642
- }
643
-
644
- if (event.key === 'ArrowLeft') {
645
- event.preventDefault();
646
- moveFocus('left');
647
- return;
648
- }
649
-
650
- if (event.key === 'ArrowRight') {
651
- event.preventDefault();
652
- moveFocus('right');
653
- }
654
- };
655
-
656
- window.addEventListener('keydown', onKeyDown);
657
-
658
- return () => {
659
- window.removeEventListener('keydown', onKeyDown);
660
- };
661
- }, [
662
- archiveCurrentSelection,
663
- commandOpen,
664
- createTask,
665
- focusedTaskId,
666
- moveFocus,
667
- ]);
668
-
669
- const detailTask = detailTaskId ? taskById.get(detailTaskId) || null : null;
670
-
671
- const clearFilters = () => {
672
- setSearchDraft('');
673
- setSearchQuery('');
674
- setAssigneeFilter('all');
675
- setProjectFilter('all');
676
- };
677
-
678
- const openFocusedTask = () => {
679
- if (focusedTaskId) {
680
- setDetailTaskId(focusedTaskId);
681
- }
682
- };
683
-
684
- const shortcuts = [
685
- { key: 'Ctrl + Shift + N', label: t('shortcuts.create') },
686
- { key: 'Ctrl + F', label: t('shortcuts.focusSearch') },
687
- { key: 'Space', label: t('shortcuts.toggleKeyboardDrag') },
688
- { key: 'Enter', label: t('shortcuts.openTask') },
689
- { key: 'Delete', label: t('shortcuts.archive') },
690
- { key: 'Arrows', label: t('shortcuts.navigate') },
691
- ];
692
-
693
- return (
694
- <Page>
695
- <OperationsHeader
696
- title={t('title')}
697
- description={t('description')}
698
- current={t('breadcrumb')}
699
- actions={
700
- <div className="flex flex-wrap items-center gap-2">
701
- <Button variant="outline" onClick={() => setCommandOpen(true)}>
702
- <Sparkles className="mr-2 size-4" />
703
- {t('openCommands')}
704
- </Button>
705
- <Button onClick={createTask}>
706
- <Plus className="mr-2 size-4" />
707
- {t('newTask')}
708
- </Button>
709
- </div>
710
- }
711
- />
712
-
713
- <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
714
- {metrics.map((metric) => (
715
- <Card key={metric.title} className="shadow-none">
716
- <CardHeader className="pb-2">
717
- <CardTitle className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
718
- {metric.title}
719
- </CardTitle>
720
- </CardHeader>
721
- <CardContent>
722
- <div className="text-2xl font-semibold">{metric.value}</div>
723
- </CardContent>
724
- </Card>
725
- ))}
726
- </div>
727
-
728
- <div className="rounded-2xl border bg-background p-3 shadow-xs">
729
- <div className="grid gap-2 md:grid-cols-[minmax(0,2fr)_minmax(180px,1fr)_minmax(200px,1fr)_auto_auto]">
730
- <Input
731
- ref={searchInputRef}
732
- value={searchDraft}
733
- onChange={(event) => setSearchDraft(event.target.value)}
734
- placeholder={t('searchPlaceholder')}
735
- />
736
- <Select value={assigneeFilter} onValueChange={setAssigneeFilter}>
737
- <SelectTrigger>
738
- <SelectValue placeholder={t('filters.assignee')} />
739
- </SelectTrigger>
740
- <SelectContent>
741
- <SelectItem value="all">{t('filters.allAssignees')}</SelectItem>
742
- <SelectItem value={TASK_UNASSIGNED_LANE}>
743
- {t('filters.unassigned')}
744
- </SelectItem>
745
- {users.map((user) => (
746
- <SelectItem key={user.id} value={user.id}>
747
- {user.name}
748
- </SelectItem>
749
- ))}
750
- </SelectContent>
751
- </Select>
752
-
753
- <Select value={projectFilter} onValueChange={setProjectFilter}>
754
- <SelectTrigger>
755
- <SelectValue placeholder={t('filters.project')} />
756
- </SelectTrigger>
757
- <SelectContent>
758
- <SelectItem value="all">{t('filters.allProjects')}</SelectItem>
759
- {projectOptions.map((projectName) => (
760
- <SelectItem key={projectName} value={projectName}>
761
- {projectName}
762
- </SelectItem>
763
- ))}
764
- </SelectContent>
765
- </Select>
766
-
767
- <Button onClick={() => setSearchQuery(searchDraft)}>
768
- <Search className="mr-2 size-4" />
769
- {t('applyFilters')}
770
- </Button>
771
-
772
- <Button variant="outline" onClick={clearFilters}>
773
- {t('clearFilters')}
774
- </Button>
775
- </div>
776
-
777
- <div className="mt-3 flex flex-wrap items-center gap-2">
778
- <Badge
779
- className={cn(
780
- 'border-transparent',
781
- keyboardDragMode
782
- ? 'bg-blue-100 text-blue-700'
783
- : 'bg-slate-200 text-slate-700'
784
- )}
785
- >
786
- {keyboardDragMode
787
- ? t('keyboardMode.enabled')
788
- : t('keyboardMode.disabled')}
789
- </Badge>
790
-
791
- <Badge variant="secondary" className="bg-slate-100 text-slate-700">
792
- {t('selectedCount', { count: totalSelected })}
793
- </Badge>
794
-
795
- {totalSelected ? (
796
- <Button
797
- size="sm"
798
- variant="outline"
799
- onClick={archiveCurrentSelection}
800
- >
801
- <Trash2 className="mr-2 size-4" />
802
- {t('archiveSelection')}
803
- </Button>
804
- ) : null}
805
-
806
- <Button size="sm" variant="outline" onClick={openFocusedTask}>
807
- <Rows3 className="mr-2 size-4" />
808
- {t('openFocused')}
809
- </Button>
810
- </div>
811
-
812
- <div className="mt-3 flex flex-wrap gap-1.5">
813
- {shortcuts.map((shortcut) => (
814
- <Badge
815
- key={shortcut.key}
816
- variant="outline"
817
- className="border-slate-300 text-[11px]"
818
- >
819
- {shortcut.key} - {shortcut.label}
820
- </Badge>
821
- ))}
822
- </div>
823
- </div>
824
-
825
- {filteredTasks.length ? (
826
- <KanbanBoard
827
- tasks={filteredTasks}
828
- users={users}
829
- selectedTaskIds={selectedTaskIds}
830
- focusedTaskId={focusedTaskId}
831
- keyboardDragMode={keyboardDragMode}
832
- collapsedLaneIds={collapsedLaneIds}
833
- onToggleLane={toggleLane}
834
- onTaskFocus={setFocusedTaskId}
835
- onTaskOpen={setDetailTaskId}
836
- onSelectionChange={handleSelectionChange}
837
- onMoveTasks={handleMoveTasks}
838
- />
839
- ) : (
840
- <div className="rounded-2xl border border-dashed border-slate-300 bg-background p-8 text-center">
841
- <p className="text-base font-semibold">{t('empty.title')}</p>
842
- <p className="mx-auto mt-1 max-w-xl text-sm text-muted-foreground">
843
- {t('empty.description')}
844
- </p>
845
- <div className="mt-4 flex justify-center gap-2">
846
- <Button onClick={clearFilters} variant="outline">
847
- {t('clearFilters')}
848
- </Button>
849
- <Button onClick={createTask}>{t('newTask')}</Button>
850
- </div>
851
- </div>
852
- )}
853
-
854
- <CommandDialog
855
- open={commandOpen}
856
- onOpenChange={setCommandOpen}
857
- title={t('commands.title')}
858
- description={t('commands.description')}
859
- >
860
- <CommandInput placeholder={t('commands.placeholder')} />
861
- <CommandList>
862
- <CommandEmpty>{t('commands.empty')}</CommandEmpty>
863
- <CommandGroup heading={t('commands.actions')}>
864
- <CommandItem
865
- onSelect={() => {
866
- createTask();
867
- setCommandOpen(false);
868
- }}
869
- >
870
- {t('commands.newTask')}
871
- <CommandShortcut>Ctrl+Shift+N</CommandShortcut>
872
- </CommandItem>
873
- <CommandItem
874
- onSelect={() => {
875
- setKeyboardDragMode((enabled) => !enabled);
876
- setCommandOpen(false);
877
- }}
878
- >
879
- {keyboardDragMode
880
- ? t('commands.disableKeyboardDrag')
881
- : t('commands.enableKeyboardDrag')}
882
- <CommandShortcut>Space</CommandShortcut>
883
- </CommandItem>
884
- <CommandItem
885
- onSelect={() => {
886
- archiveCurrentSelection();
887
- setCommandOpen(false);
888
- }}
889
- >
890
- {t('commands.archiveSelected')}
891
- <CommandShortcut>Del</CommandShortcut>
892
- </CommandItem>
893
- <CommandItem
894
- onSelect={() => {
895
- openFocusedTask();
896
- setCommandOpen(false);
897
- }}
898
- >
899
- {t('commands.openFocused')}
900
- <CommandShortcut>Enter</CommandShortcut>
901
- </CommandItem>
902
- </CommandGroup>
903
- </CommandList>
904
- </CommandDialog>
905
-
906
- <Sheet
907
- open={Boolean(detailTask)}
908
- onOpenChange={(open) => {
909
- if (!open) {
910
- setDetailTaskId(null);
911
- }
912
- }}
913
- >
914
- <SheetContent className="w-full sm:max-w-xl">
915
- {detailTask ? (
916
- <>
917
- <SheetHeader>
918
- <SheetTitle>{detailTask.title}</SheetTitle>
919
- <SheetDescription>{detailTask.projectName}</SheetDescription>
920
- </SheetHeader>
921
-
922
- <div className="grid gap-4 px-4 pb-4">
923
- <div className="rounded-lg border p-3">
924
- <p className="text-xs font-medium text-muted-foreground uppercase">
925
- {t('detail.descriptionLabel')}
926
- </p>
927
- <p className="mt-2 text-sm">{detailTask.description}</p>
928
- </div>
929
-
930
- <div className="grid gap-3 sm:grid-cols-2">
931
- <div className="rounded-lg border p-3">
932
- <p className="text-xs font-medium text-muted-foreground uppercase">
933
- {t('detail.priority')}
934
- </p>
935
- <p className="mt-1 text-sm font-semibold capitalize">
936
- {detailTask.priority}
937
- </p>
938
- </div>
939
- <div className="rounded-lg border p-3">
940
- <p className="text-xs font-medium text-muted-foreground uppercase">
941
- {t('detail.estimate')}
942
- </p>
943
- <p className="mt-1 text-sm font-semibold">
944
- {detailTask.estimatedHours}h
945
- </p>
946
- </div>
947
- </div>
948
-
949
- <div className="rounded-lg border p-3">
950
- <p className="text-xs font-medium text-muted-foreground uppercase">
951
- {t('detail.labels')}
952
- </p>
953
- <div className="mt-2 flex flex-wrap gap-1.5">
954
- {detailTask.labels.map((label) => (
955
- <Badge
956
- key={label}
957
- variant="secondary"
958
- className="bg-slate-100"
959
- >
960
- {label}
961
- </Badge>
962
- ))}
963
- </div>
964
- </div>
965
-
966
- <div className="rounded-lg border p-3">
967
- <p className="text-xs font-medium text-muted-foreground uppercase">
968
- {t('detail.quickActions')}
969
- </p>
970
- <div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-5">
971
- {TASK_STATUSES.map((status) => (
972
- <Button
973
- key={status}
974
- size="sm"
975
- variant={
976
- detailTask.status === status ? 'default' : 'outline'
977
- }
978
- onClick={() => {
979
- handleMoveTasks({
980
- draggedTaskId: detailTask.id,
981
- toLaneId: getLaneId(detailTask),
982
- toStatus: status,
983
- });
984
- setDetailTaskId(detailTask.id);
985
- }}
986
- >
987
- {status}
988
- </Button>
989
- ))}
990
- </div>
991
- </div>
992
- </div>
993
- </>
994
- ) : null}
995
- </SheetContent>
996
- </Sheet>
997
- </Page>
998
- );
999
- }