@hed-hog/operations 0.0.285 → 0.0.291
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.
- package/hedhog/frontend/app/_components/kanban-board.tsx.ejs +604 -61
- package/hedhog/frontend/app/_lib/mocks/projects.mock.ts.ejs +398 -3
- package/hedhog/frontend/app/_lib/mocks/tasks.mock.ts.ejs +29 -0
- package/hedhog/frontend/app/_lib/types/operations.ts.ejs +67 -6
- package/hedhog/frontend/app/allocations/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/certifications/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/contracts/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/evaluations/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/goals/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/projects/[id]/page.tsx.ejs +857 -107
- package/hedhog/frontend/app/projects/page.tsx.ejs +1044 -81
- package/hedhog/frontend/app/rewards/page.tsx.ejs +2 -1
- package/hedhog/frontend/app/tasks/page.tsx.ejs +968 -16
- package/hedhog/frontend/messages/en.json +306 -4
- package/hedhog/frontend/messages/pt.json +306 -4
- package/package.json +5 -5
|
@@ -1,11 +1,47 @@
|
|
|
1
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Badge } from '@/components/ui/badge';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
import {
|
|
7
|
+
DndContext,
|
|
8
|
+
DragEndEvent,
|
|
9
|
+
DragOverlay,
|
|
10
|
+
DragStartEvent,
|
|
11
|
+
KeyboardSensor,
|
|
12
|
+
PointerSensor,
|
|
13
|
+
closestCorners,
|
|
14
|
+
useDroppable,
|
|
15
|
+
useSensor,
|
|
16
|
+
useSensors,
|
|
17
|
+
} from '@dnd-kit/core';
|
|
18
|
+
import {
|
|
19
|
+
SortableContext,
|
|
20
|
+
sortableKeyboardCoordinates,
|
|
21
|
+
useSortable,
|
|
22
|
+
verticalListSortingStrategy,
|
|
23
|
+
} from '@dnd-kit/sortable';
|
|
24
|
+
import { CSS } from '@dnd-kit/utilities';
|
|
2
25
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
26
|
+
AlertCircle,
|
|
27
|
+
CalendarClock,
|
|
28
|
+
ChevronDown,
|
|
29
|
+
ChevronRight,
|
|
30
|
+
Clock3,
|
|
31
|
+
UserRound,
|
|
32
|
+
} from 'lucide-react';
|
|
33
|
+
import { memo, useMemo, useState } from 'react';
|
|
34
|
+
import type {
|
|
35
|
+
OperationsUser,
|
|
36
|
+
Task,
|
|
37
|
+
TaskPriority,
|
|
38
|
+
TaskStatus,
|
|
39
|
+
} from '../_lib/types/operations';
|
|
40
|
+
import { getTaskBadgeClasses, getTaskStatusLabel } from '../_lib/utils/status';
|
|
7
41
|
import { StatusBadge } from './status-badge';
|
|
8
42
|
|
|
43
|
+
export const TASK_UNASSIGNED_LANE = 'unassigned';
|
|
44
|
+
|
|
9
45
|
const columns: TaskStatus[] = [
|
|
10
46
|
'backlog',
|
|
11
47
|
'todo',
|
|
@@ -14,70 +50,577 @@ const columns: TaskStatus[] = [
|
|
|
14
50
|
'done',
|
|
15
51
|
];
|
|
16
52
|
|
|
53
|
+
type Lane = {
|
|
54
|
+
id: string;
|
|
55
|
+
name: string;
|
|
56
|
+
role?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
type ColumnMeta = {
|
|
60
|
+
laneId: string;
|
|
61
|
+
status: TaskStatus;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export interface MoveTasksPayload {
|
|
65
|
+
draggedTaskId: string;
|
|
66
|
+
toLaneId: string;
|
|
67
|
+
toStatus: TaskStatus;
|
|
68
|
+
beforeTaskId?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
17
71
|
interface KanbanBoardProps {
|
|
18
72
|
tasks: Task[];
|
|
19
73
|
users: OperationsUser[];
|
|
74
|
+
selectedTaskIds?: string[];
|
|
75
|
+
focusedTaskId?: string | null;
|
|
76
|
+
keyboardDragMode?: boolean;
|
|
77
|
+
collapsedLaneIds?: string[];
|
|
78
|
+
onToggleLane?: (laneId: string) => void;
|
|
79
|
+
onTaskFocus?: (taskId: string) => void;
|
|
80
|
+
onTaskOpen?: (taskId: string) => void;
|
|
81
|
+
onSelectionChange?: (taskId: string, multi: boolean) => void;
|
|
82
|
+
onMoveTasks?: (payload: MoveTasksPayload) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function getLaneId(task: Task) {
|
|
86
|
+
return task.assignedUserId || TASK_UNASSIGNED_LANE;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function getColumnId(laneId: string, status: TaskStatus) {
|
|
90
|
+
return `col:${laneId}:${status}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseColumnId(columnId: string): ColumnMeta | null {
|
|
94
|
+
if (!columnId.startsWith('col:')) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const [, laneId, status] = columnId.split(':');
|
|
99
|
+
|
|
100
|
+
if (!laneId || !status) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!columns.includes(status as TaskStatus)) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
laneId,
|
|
110
|
+
status: status as TaskStatus,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function getPriorityClass(priority: TaskPriority) {
|
|
115
|
+
return {
|
|
116
|
+
low: 'bg-emerald-100 text-emerald-700',
|
|
117
|
+
medium: 'bg-slate-200 text-slate-700',
|
|
118
|
+
high: 'bg-orange-100 text-orange-700',
|
|
119
|
+
critical: 'bg-rose-100 text-rose-700',
|
|
120
|
+
}[priority];
|
|
20
121
|
}
|
|
21
122
|
|
|
22
|
-
|
|
123
|
+
function formatDueDate(dueDate: string) {
|
|
124
|
+
const date = new Date(`${dueDate}T00:00:00`);
|
|
125
|
+
|
|
126
|
+
if (Number.isNaN(date.getTime())) {
|
|
127
|
+
return dueDate;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
131
|
+
month: 'short',
|
|
132
|
+
day: 'numeric',
|
|
133
|
+
}).format(date);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const TaskCard = memo(function TaskCard({
|
|
137
|
+
task,
|
|
138
|
+
selected,
|
|
139
|
+
focused,
|
|
140
|
+
userName,
|
|
141
|
+
keyboardDragMode,
|
|
142
|
+
onFocus,
|
|
143
|
+
onOpen,
|
|
144
|
+
onSelect,
|
|
145
|
+
}: {
|
|
146
|
+
task: Task;
|
|
147
|
+
selected: boolean;
|
|
148
|
+
focused: boolean;
|
|
149
|
+
userName: string;
|
|
150
|
+
keyboardDragMode: boolean;
|
|
151
|
+
onFocus: (taskId: string) => void;
|
|
152
|
+
onOpen: (taskId: string) => void;
|
|
153
|
+
onSelect: (taskId: string, multi: boolean) => void;
|
|
154
|
+
}) {
|
|
155
|
+
const sortable = useSortable({
|
|
156
|
+
id: task.id,
|
|
157
|
+
data: {
|
|
158
|
+
type: 'task',
|
|
159
|
+
laneId: getLaneId(task),
|
|
160
|
+
status: task.status,
|
|
161
|
+
},
|
|
162
|
+
disabled: false,
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
const style = {
|
|
166
|
+
transform: CSS.Transform.toString(sortable.transform),
|
|
167
|
+
transition: sortable.transition,
|
|
168
|
+
};
|
|
169
|
+
|
|
23
170
|
return (
|
|
24
|
-
<
|
|
25
|
-
{
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
171
|
+
<article
|
|
172
|
+
ref={sortable.setNodeRef}
|
|
173
|
+
style={style}
|
|
174
|
+
aria-label={`Task ${task.title}`}
|
|
175
|
+
onFocus={() => onFocus(task.id)}
|
|
176
|
+
onClick={(event) => {
|
|
177
|
+
const useMulti = event.ctrlKey || event.metaKey;
|
|
178
|
+
onSelect(task.id, useMulti);
|
|
179
|
+
}}
|
|
180
|
+
onDoubleClick={() => onOpen(task.id)}
|
|
181
|
+
onKeyDown={(event) => {
|
|
182
|
+
if (event.key === 'Enter') {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
onOpen(task.id);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (
|
|
189
|
+
event.key.toLowerCase() === 'a' &&
|
|
190
|
+
(event.ctrlKey || event.metaKey)
|
|
191
|
+
) {
|
|
192
|
+
event.preventDefault();
|
|
193
|
+
onSelect(task.id, true);
|
|
194
|
+
}
|
|
195
|
+
}}
|
|
196
|
+
className={cn(
|
|
197
|
+
'rounded-xl border bg-background p-3 shadow-xs outline-hidden transition',
|
|
198
|
+
'focus-visible:ring-2 focus-visible:ring-primary/40',
|
|
199
|
+
selected && 'border-primary bg-primary/5',
|
|
200
|
+
focused && 'ring-2 ring-primary/40',
|
|
201
|
+
sortable.isDragging && 'opacity-40',
|
|
202
|
+
sortable.isOver && 'border-primary/60'
|
|
203
|
+
)}
|
|
204
|
+
{...sortable.attributes}
|
|
205
|
+
{...sortable.listeners}
|
|
206
|
+
>
|
|
207
|
+
<div className="mb-2 flex items-start justify-between gap-2">
|
|
208
|
+
<p className="line-clamp-2 text-sm font-semibold">{task.title}</p>
|
|
209
|
+
<StatusBadge
|
|
210
|
+
label={getTaskStatusLabel(task.status)}
|
|
211
|
+
className={getTaskBadgeClasses(task.status)}
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
<div className="mb-2 flex flex-wrap gap-1.5">
|
|
215
|
+
<Badge
|
|
216
|
+
className={cn('border-transparent', getPriorityClass(task.priority))}
|
|
217
|
+
>
|
|
218
|
+
{task.priority}
|
|
219
|
+
</Badge>
|
|
220
|
+
{task.labels.map((label) => (
|
|
221
|
+
<Badge
|
|
222
|
+
key={label}
|
|
223
|
+
variant="secondary"
|
|
224
|
+
className="bg-slate-100 text-[11px] text-slate-700"
|
|
32
225
|
>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
226
|
+
{label}
|
|
227
|
+
</Badge>
|
|
228
|
+
))}
|
|
229
|
+
</div>
|
|
230
|
+
<div className="grid gap-1 text-xs text-muted-foreground">
|
|
231
|
+
<div className="flex items-center gap-1.5">
|
|
232
|
+
<UserRound className="size-3.5" />
|
|
233
|
+
<span>{userName}</span>
|
|
234
|
+
</div>
|
|
235
|
+
<div className="flex items-center gap-1.5">
|
|
236
|
+
<CalendarClock className="size-3.5" />
|
|
237
|
+
<span>{formatDueDate(task.dueDate)}</span>
|
|
238
|
+
</div>
|
|
239
|
+
<div className="flex items-center gap-1.5">
|
|
240
|
+
<Clock3 className="size-3.5" />
|
|
241
|
+
<span>{task.estimatedHours}h</span>
|
|
242
|
+
{keyboardDragMode ? (
|
|
243
|
+
<span className="ml-auto inline-flex items-center gap-1 text-[10px] uppercase text-primary">
|
|
244
|
+
<AlertCircle className="size-3" />
|
|
245
|
+
KBD drag
|
|
246
|
+
</span>
|
|
247
|
+
) : null}
|
|
248
|
+
</div>
|
|
249
|
+
</div>
|
|
250
|
+
</article>
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
const KanbanColumn = memo(function KanbanColumn({
|
|
255
|
+
laneId,
|
|
256
|
+
status,
|
|
257
|
+
tasks,
|
|
258
|
+
selectedTaskIds,
|
|
259
|
+
focusedTaskId,
|
|
260
|
+
usersById,
|
|
261
|
+
keyboardDragMode,
|
|
262
|
+
onTaskFocus,
|
|
263
|
+
onTaskOpen,
|
|
264
|
+
onSelectionChange,
|
|
265
|
+
}: {
|
|
266
|
+
laneId: string;
|
|
267
|
+
status: TaskStatus;
|
|
268
|
+
tasks: Task[];
|
|
269
|
+
selectedTaskIds: Set<string>;
|
|
270
|
+
focusedTaskId: string | null;
|
|
271
|
+
usersById: Map<string, OperationsUser>;
|
|
272
|
+
keyboardDragMode: boolean;
|
|
273
|
+
onTaskFocus: (taskId: string) => void;
|
|
274
|
+
onTaskOpen: (taskId: string) => void;
|
|
275
|
+
onSelectionChange: (taskId: string, multi: boolean) => void;
|
|
276
|
+
}) {
|
|
277
|
+
const columnId = getColumnId(laneId, status);
|
|
278
|
+
|
|
279
|
+
const droppable = useDroppable({
|
|
280
|
+
id: columnId,
|
|
281
|
+
data: {
|
|
282
|
+
type: 'column',
|
|
283
|
+
laneId,
|
|
284
|
+
status,
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
return (
|
|
289
|
+
<section
|
|
290
|
+
ref={droppable.setNodeRef}
|
|
291
|
+
className={cn(
|
|
292
|
+
'rounded-xl border bg-slate-50/80 p-3',
|
|
293
|
+
droppable.isOver && 'border-primary/70 bg-primary/5'
|
|
294
|
+
)}
|
|
295
|
+
aria-label={`${getTaskStatusLabel(status)} column`}
|
|
296
|
+
>
|
|
297
|
+
<header className="mb-3 flex items-center justify-between gap-2">
|
|
298
|
+
<h3 className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
|
|
299
|
+
{getTaskStatusLabel(status)}
|
|
300
|
+
</h3>
|
|
301
|
+
<span className="rounded-md bg-background px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
|
302
|
+
{tasks.length}
|
|
303
|
+
</span>
|
|
304
|
+
</header>
|
|
305
|
+
|
|
306
|
+
<SortableContext
|
|
307
|
+
items={tasks.map((task) => task.id)}
|
|
308
|
+
strategy={verticalListSortingStrategy}
|
|
309
|
+
>
|
|
310
|
+
<div className="space-y-2.5">
|
|
311
|
+
{tasks.map((task) => (
|
|
312
|
+
<TaskCard
|
|
313
|
+
key={task.id}
|
|
314
|
+
task={task}
|
|
315
|
+
selected={selectedTaskIds.has(task.id)}
|
|
316
|
+
focused={focusedTaskId === task.id}
|
|
317
|
+
userName={
|
|
318
|
+
usersById.get(task.assignedUserId)?.name || 'Unassigned'
|
|
319
|
+
}
|
|
320
|
+
keyboardDragMode={keyboardDragMode}
|
|
321
|
+
onFocus={onTaskFocus}
|
|
322
|
+
onOpen={onTaskOpen}
|
|
323
|
+
onSelect={onSelectionChange}
|
|
324
|
+
/>
|
|
325
|
+
))}
|
|
326
|
+
</div>
|
|
327
|
+
</SortableContext>
|
|
328
|
+
</section>
|
|
329
|
+
);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
const LaneSection = memo(function LaneSection({
|
|
333
|
+
lane,
|
|
334
|
+
columnsByStatus,
|
|
335
|
+
collapsed,
|
|
336
|
+
onToggle,
|
|
337
|
+
selectedTaskIds,
|
|
338
|
+
focusedTaskId,
|
|
339
|
+
usersById,
|
|
340
|
+
keyboardDragMode,
|
|
341
|
+
onTaskFocus,
|
|
342
|
+
onTaskOpen,
|
|
343
|
+
onSelectionChange,
|
|
344
|
+
}: {
|
|
345
|
+
lane: Lane;
|
|
346
|
+
columnsByStatus: Record<TaskStatus, Task[]>;
|
|
347
|
+
collapsed: boolean;
|
|
348
|
+
onToggle: (laneId: string) => void;
|
|
349
|
+
selectedTaskIds: Set<string>;
|
|
350
|
+
focusedTaskId: string | null;
|
|
351
|
+
usersById: Map<string, OperationsUser>;
|
|
352
|
+
keyboardDragMode: boolean;
|
|
353
|
+
onTaskFocus: (taskId: string) => void;
|
|
354
|
+
onTaskOpen: (taskId: string) => void;
|
|
355
|
+
onSelectionChange: (taskId: string, multi: boolean) => void;
|
|
356
|
+
}) {
|
|
357
|
+
const total = columns.reduce(
|
|
358
|
+
(acc, status) => acc + columnsByStatus[status].length,
|
|
359
|
+
0
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<div className="rounded-2xl border bg-background p-3 shadow-xs">
|
|
364
|
+
<button
|
|
365
|
+
type="button"
|
|
366
|
+
onClick={() => onToggle(lane.id)}
|
|
367
|
+
className="mb-3 flex w-full items-center justify-between rounded-lg px-2 py-1.5 text-left transition hover:bg-muted/60"
|
|
368
|
+
>
|
|
369
|
+
<span>
|
|
370
|
+
<span className="text-sm font-semibold">{lane.name}</span>
|
|
371
|
+
{lane.role ? (
|
|
372
|
+
<span className="ml-2 text-xs text-muted-foreground">
|
|
373
|
+
{lane.role}
|
|
374
|
+
</span>
|
|
375
|
+
) : null}
|
|
376
|
+
</span>
|
|
377
|
+
<span className="inline-flex items-center gap-2 text-xs text-muted-foreground">
|
|
378
|
+
{total} cards
|
|
379
|
+
{collapsed ? (
|
|
380
|
+
<ChevronRight className="size-4" />
|
|
381
|
+
) : (
|
|
382
|
+
<ChevronDown className="size-4" />
|
|
383
|
+
)}
|
|
384
|
+
</span>
|
|
385
|
+
</button>
|
|
386
|
+
|
|
387
|
+
{collapsed ? null : (
|
|
388
|
+
<div className="overflow-x-auto pb-1">
|
|
389
|
+
<div className="grid min-w-[1120px] grid-cols-5 gap-3">
|
|
390
|
+
{columns.map((status) => (
|
|
391
|
+
<KanbanColumn
|
|
392
|
+
key={`${lane.id}-${status}`}
|
|
393
|
+
laneId={lane.id}
|
|
394
|
+
status={status}
|
|
395
|
+
tasks={columnsByStatus[status]}
|
|
396
|
+
selectedTaskIds={selectedTaskIds}
|
|
397
|
+
focusedTaskId={focusedTaskId}
|
|
398
|
+
usersById={usersById}
|
|
399
|
+
keyboardDragMode={keyboardDragMode}
|
|
400
|
+
onTaskFocus={onTaskFocus}
|
|
401
|
+
onTaskOpen={onTaskOpen}
|
|
402
|
+
onSelectionChange={onSelectionChange}
|
|
403
|
+
/>
|
|
404
|
+
))}
|
|
78
405
|
</div>
|
|
79
|
-
|
|
80
|
-
|
|
406
|
+
</div>
|
|
407
|
+
)}
|
|
81
408
|
</div>
|
|
82
409
|
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
export function KanbanBoard({
|
|
413
|
+
tasks,
|
|
414
|
+
users,
|
|
415
|
+
selectedTaskIds = [],
|
|
416
|
+
focusedTaskId = null,
|
|
417
|
+
keyboardDragMode = false,
|
|
418
|
+
collapsedLaneIds = [],
|
|
419
|
+
onToggleLane = () => {},
|
|
420
|
+
onTaskFocus = () => {},
|
|
421
|
+
onTaskOpen = () => {},
|
|
422
|
+
onSelectionChange = () => {},
|
|
423
|
+
onMoveTasks = () => {},
|
|
424
|
+
}: KanbanBoardProps) {
|
|
425
|
+
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
|
426
|
+
|
|
427
|
+
const selectedSet = useMemo(
|
|
428
|
+
() => new Set(selectedTaskIds),
|
|
429
|
+
[selectedTaskIds]
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
const usersById = useMemo(
|
|
433
|
+
() => new Map(users.map((user) => [user.id, user])),
|
|
434
|
+
[users]
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
const lanes = useMemo(() => {
|
|
438
|
+
const byId = new Map<string, Lane>();
|
|
439
|
+
|
|
440
|
+
users.forEach((user) => {
|
|
441
|
+
byId.set(user.id, {
|
|
442
|
+
id: user.id,
|
|
443
|
+
name: user.name,
|
|
444
|
+
role: user.role,
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
if (tasks.some((task) => !task.assignedUserId)) {
|
|
449
|
+
byId.set(TASK_UNASSIGNED_LANE, {
|
|
450
|
+
id: TASK_UNASSIGNED_LANE,
|
|
451
|
+
name: 'Unassigned',
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
tasks.forEach((task) => {
|
|
456
|
+
const laneId = getLaneId(task);
|
|
457
|
+
if (!byId.has(laneId)) {
|
|
458
|
+
byId.set(laneId, {
|
|
459
|
+
id: laneId,
|
|
460
|
+
name: laneId,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
return Array.from(byId.values());
|
|
466
|
+
}, [tasks, users]);
|
|
467
|
+
|
|
468
|
+
const tasksByLaneAndStatus = useMemo(() => {
|
|
469
|
+
const map = new Map<string, Record<TaskStatus, Task[]>>();
|
|
470
|
+
|
|
471
|
+
lanes.forEach((lane) => {
|
|
472
|
+
map.set(lane.id, {
|
|
473
|
+
backlog: [],
|
|
474
|
+
todo: [],
|
|
475
|
+
'in-progress': [],
|
|
476
|
+
review: [],
|
|
477
|
+
done: [],
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
tasks.forEach((task) => {
|
|
482
|
+
const laneId = getLaneId(task);
|
|
483
|
+
|
|
484
|
+
if (!map.has(laneId)) {
|
|
485
|
+
map.set(laneId, {
|
|
486
|
+
backlog: [],
|
|
487
|
+
todo: [],
|
|
488
|
+
'in-progress': [],
|
|
489
|
+
review: [],
|
|
490
|
+
done: [],
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
map.get(laneId)?.[task.status].push(task);
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
for (const laneTasks of map.values()) {
|
|
498
|
+
columns.forEach((status) => {
|
|
499
|
+
laneTasks[status].sort((a, b) => a.order - b.order);
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return map;
|
|
504
|
+
}, [lanes, tasks]);
|
|
505
|
+
|
|
506
|
+
const tasksById = useMemo(
|
|
507
|
+
() => new Map(tasks.map((task) => [task.id, task])),
|
|
508
|
+
[tasks]
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
const sensors = useSensors(
|
|
512
|
+
useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
|
|
513
|
+
...(keyboardDragMode
|
|
514
|
+
? [
|
|
515
|
+
useSensor(KeyboardSensor, {
|
|
516
|
+
coordinateGetter: sortableKeyboardCoordinates,
|
|
517
|
+
}),
|
|
518
|
+
]
|
|
519
|
+
: [])
|
|
520
|
+
);
|
|
521
|
+
|
|
522
|
+
const handleDragStart = (event: DragStartEvent) => {
|
|
523
|
+
if (typeof event.active.id === 'string') {
|
|
524
|
+
setActiveTaskId(event.active.id);
|
|
525
|
+
onTaskFocus(event.active.id);
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const handleDragEnd = (event: DragEndEvent) => {
|
|
530
|
+
const draggedTaskId =
|
|
531
|
+
typeof event.active.id === 'string' ? event.active.id : null;
|
|
532
|
+
const overId = typeof event.over?.id === 'string' ? event.over.id : null;
|
|
533
|
+
|
|
534
|
+
setActiveTaskId(null);
|
|
535
|
+
|
|
536
|
+
if (!draggedTaskId || !overId) {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (draggedTaskId === overId) {
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const overColumn = parseColumnId(overId);
|
|
545
|
+
|
|
546
|
+
if (overColumn) {
|
|
547
|
+
onMoveTasks({
|
|
548
|
+
draggedTaskId,
|
|
549
|
+
toLaneId: overColumn.laneId,
|
|
550
|
+
toStatus: overColumn.status,
|
|
551
|
+
});
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const overTask = tasksById.get(overId);
|
|
556
|
+
|
|
557
|
+
if (!overTask) {
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
onMoveTasks({
|
|
562
|
+
draggedTaskId,
|
|
563
|
+
toLaneId: getLaneId(overTask),
|
|
564
|
+
toStatus: overTask.status,
|
|
565
|
+
beforeTaskId: overTask.id,
|
|
566
|
+
});
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
return (
|
|
570
|
+
<DndContext
|
|
571
|
+
sensors={sensors}
|
|
572
|
+
collisionDetection={closestCorners}
|
|
573
|
+
onDragStart={handleDragStart}
|
|
574
|
+
onDragEnd={handleDragEnd}
|
|
575
|
+
>
|
|
576
|
+
<div className="space-y-4">
|
|
577
|
+
{lanes.map((lane) => {
|
|
578
|
+
const columnsByStatus = tasksByLaneAndStatus.get(lane.id);
|
|
579
|
+
|
|
580
|
+
if (!columnsByStatus) {
|
|
581
|
+
return null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return (
|
|
585
|
+
<LaneSection
|
|
586
|
+
key={lane.id}
|
|
587
|
+
lane={lane}
|
|
588
|
+
columnsByStatus={columnsByStatus}
|
|
589
|
+
collapsed={collapsedLaneIds.includes(lane.id)}
|
|
590
|
+
onToggle={onToggleLane}
|
|
591
|
+
selectedTaskIds={selectedSet}
|
|
592
|
+
focusedTaskId={focusedTaskId}
|
|
593
|
+
usersById={usersById}
|
|
594
|
+
keyboardDragMode={keyboardDragMode}
|
|
595
|
+
onTaskFocus={onTaskFocus}
|
|
596
|
+
onTaskOpen={onTaskOpen}
|
|
597
|
+
onSelectionChange={onSelectionChange}
|
|
598
|
+
/>
|
|
599
|
+
);
|
|
600
|
+
})}
|
|
601
|
+
</div>
|
|
602
|
+
|
|
603
|
+
<DragOverlay>
|
|
604
|
+
{activeTaskId ? (
|
|
605
|
+
<div className="w-[280px] rounded-xl border bg-background p-3 shadow-lg">
|
|
606
|
+
<p className="mb-1 text-sm font-semibold">
|
|
607
|
+
{tasksById.get(activeTaskId)?.title}
|
|
608
|
+
</p>
|
|
609
|
+
<p className="text-xs text-muted-foreground">
|
|
610
|
+
{tasksById.get(activeTaskId)?.projectName}
|
|
611
|
+
</p>
|
|
612
|
+
</div>
|
|
613
|
+
) : null}
|
|
614
|
+
</DragOverlay>
|
|
615
|
+
|
|
616
|
+
{!tasks.length ? (
|
|
617
|
+
<div className="rounded-xl border border-dashed border-slate-300 p-6 text-center">
|
|
618
|
+
<p className="text-sm text-muted-foreground">No tasks to display.</p>
|
|
619
|
+
<Button variant="outline" className="mt-3" size="sm">
|
|
620
|
+
Adjust filters
|
|
621
|
+
</Button>
|
|
622
|
+
</div>
|
|
623
|
+
) : null}
|
|
624
|
+
</DndContext>
|
|
625
|
+
);
|
|
83
626
|
}
|