@hed-hog/operations 0.0.3 → 0.0.286

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 (108) hide show
  1. package/README.md +122 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +1 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/operations-data.controller.d.ts +139 -0
  7. package/dist/operations-data.controller.d.ts.map +1 -0
  8. package/dist/operations-data.controller.js +113 -0
  9. package/dist/operations-data.controller.js.map +1 -0
  10. package/dist/operations-growth.controller.d.ts +48 -0
  11. package/dist/operations-growth.controller.d.ts.map +1 -0
  12. package/dist/operations-growth.controller.js +90 -0
  13. package/dist/operations-growth.controller.js.map +1 -0
  14. package/dist/operations.module.d.ts.map +1 -1
  15. package/dist/operations.module.js +10 -4
  16. package/dist/operations.module.js.map +1 -1
  17. package/dist/operations.service.d.ts +178 -0
  18. package/dist/operations.service.d.ts.map +1 -0
  19. package/dist/operations.service.js +134 -0
  20. package/dist/operations.service.js.map +1 -0
  21. package/hedhog/data/menu.yaml +251 -132
  22. package/hedhog/data/operations_career_level.yaml +102 -0
  23. package/hedhog/data/operations_career_track.yaml +8 -0
  24. package/hedhog/data/operations_certification.yaml +38 -0
  25. package/hedhog/data/operations_evaluation_cycle.yaml +18 -0
  26. package/hedhog/data/operations_performance_criterion.yaml +48 -0
  27. package/hedhog/data/role.yaml +14 -7
  28. package/hedhog/data/route.yaml +143 -80
  29. package/hedhog/frontend/app/_components/allocation-calendar.tsx.ejs +56 -56
  30. package/hedhog/frontend/app/_components/kanban-board.tsx.ejs +626 -83
  31. package/hedhog/frontend/app/_components/operations-header.tsx.ejs +29 -29
  32. package/hedhog/frontend/app/_components/section-card.tsx.ejs +32 -32
  33. package/hedhog/frontend/app/_components/status-badge.tsx.ejs +15 -15
  34. package/hedhog/frontend/app/_components/timesheet-entry-dialog.tsx.ejs +142 -142
  35. package/hedhog/frontend/app/_lib/hooks/use-operations-data.ts.ejs +41 -41
  36. package/hedhog/frontend/app/_lib/hooks/use-operations-growth-data.ts.ejs +63 -0
  37. package/hedhog/frontend/app/_lib/mocks/allocations.mock.ts.ejs +74 -74
  38. package/hedhog/frontend/app/_lib/mocks/contracts.mock.ts.ejs +74 -74
  39. package/hedhog/frontend/app/_lib/mocks/operations-growth.mock.ts.ejs +824 -0
  40. package/hedhog/frontend/app/_lib/mocks/projects.mock.ts.ejs +455 -60
  41. package/hedhog/frontend/app/_lib/mocks/tasks.mock.ts.ejs +117 -88
  42. package/hedhog/frontend/app/_lib/mocks/timesheets.mock.ts.ejs +84 -84
  43. package/hedhog/frontend/app/_lib/mocks/users.mock.ts.ejs +67 -67
  44. package/hedhog/frontend/app/_lib/services/contracts.service.ts.ejs +10 -10
  45. package/hedhog/frontend/app/_lib/services/operations-growth.service.ts.ejs +31 -0
  46. package/hedhog/frontend/app/_lib/services/projects.service.ts.ejs +10 -10
  47. package/hedhog/frontend/app/_lib/services/tasks.service.ts.ejs +10 -10
  48. package/hedhog/frontend/app/_lib/services/timesheets.service.ts.ejs +10 -10
  49. package/hedhog/frontend/app/_lib/types/operations-growth.ts.ejs +209 -0
  50. package/hedhog/frontend/app/_lib/types/operations.ts.ejs +156 -95
  51. package/hedhog/frontend/app/_lib/utils/format.ts.ejs +25 -25
  52. package/hedhog/frontend/app/_lib/utils/growth.ts.ejs +62 -0
  53. package/hedhog/frontend/app/_lib/utils/metrics.ts.ejs +103 -103
  54. package/hedhog/frontend/app/_lib/utils/status.ts.ejs +80 -80
  55. package/hedhog/frontend/app/allocations/page.tsx.ejs +155 -99
  56. package/hedhog/frontend/app/approvals/page.tsx.ejs +147 -147
  57. package/hedhog/frontend/app/career/page.tsx.ejs +143 -0
  58. package/hedhog/frontend/app/certifications/page.tsx.ejs +202 -0
  59. package/hedhog/frontend/app/contracts/[id]/page.tsx.ejs +108 -108
  60. package/hedhog/frontend/app/contracts/page.tsx.ejs +181 -124
  61. package/hedhog/frontend/app/evaluations/page.tsx.ejs +278 -0
  62. package/hedhog/frontend/app/goals/page.tsx.ejs +171 -0
  63. package/hedhog/frontend/app/growth/page.tsx.ejs +288 -0
  64. package/hedhog/frontend/app/layout.tsx.ejs +9 -9
  65. package/hedhog/frontend/app/manager/page.tsx.ejs +175 -0
  66. package/hedhog/frontend/app/page.tsx.ejs +177 -177
  67. package/hedhog/frontend/app/projects/[id]/page.tsx.ejs +936 -186
  68. package/hedhog/frontend/app/projects/page.tsx.ejs +1074 -111
  69. package/hedhog/frontend/app/rewards/page.tsx.ejs +196 -0
  70. package/hedhog/frontend/app/tasks/page.tsx.ejs +999 -47
  71. package/hedhog/frontend/app/timesheets/page.tsx.ejs +126 -126
  72. package/hedhog/frontend/messages/en.json +454 -142
  73. package/hedhog/frontend/messages/pt.json +454 -142
  74. package/hedhog/table/operations_allocation.yaml +52 -0
  75. package/hedhog/table/operations_calibration_item.yaml +61 -0
  76. package/hedhog/table/operations_calibration_session.yaml +25 -0
  77. package/hedhog/table/operations_career_level.yaml +75 -0
  78. package/hedhog/table/operations_career_track.yaml +21 -0
  79. package/hedhog/table/operations_certification.yaml +48 -0
  80. package/hedhog/table/operations_contract.yaml +57 -0
  81. package/hedhog/table/operations_employee.yaml +64 -0
  82. package/hedhog/table/operations_employee_certification.yaml +43 -0
  83. package/hedhog/table/operations_employee_connect.yaml +61 -0
  84. package/hedhog/table/operations_employee_evaluation.yaml +113 -0
  85. package/hedhog/table/operations_employee_evaluation_item.yaml +39 -0
  86. package/hedhog/table/operations_employee_profile.yaml +80 -0
  87. package/hedhog/table/operations_employee_skill_matrix.yaml +30 -0
  88. package/hedhog/table/operations_evaluation_cycle.yaml +31 -0
  89. package/hedhog/table/operations_goal.yaml +67 -0
  90. package/hedhog/table/operations_goal_progress.yaml +31 -0
  91. package/hedhog/table/operations_performance_criterion.yaml +29 -0
  92. package/hedhog/table/operations_project.yaml +66 -0
  93. package/hedhog/table/operations_promotion_readiness.yaml +49 -0
  94. package/hedhog/table/operations_promotion_recommendation.yaml +63 -0
  95. package/hedhog/table/operations_public_recognition.yaml +46 -0
  96. package/hedhog/table/operations_reward.yaml +100 -0
  97. package/hedhog/table/operations_score_event.yaml +81 -0
  98. package/hedhog/table/operations_task.yaml +60 -0
  99. package/hedhog/table/operations_timesheet.yaml +49 -0
  100. package/hedhog/table/operations_timesheet_entry.yaml +51 -0
  101. package/package.json +4 -4
  102. package/src/index.ts +2 -1
  103. package/src/language/en.json +8 -8
  104. package/src/language/pt.json +8 -8
  105. package/src/operations-data.controller.ts +54 -0
  106. package/src/operations-growth.controller.ts +44 -0
  107. package/src/operations.module.ts +21 -15
  108. package/src/operations.service.ts +137 -0
@@ -1,83 +1,626 @@
1
- import { formatDate, formatHours } from '../_lib/utils/format';
2
- import {
3
- getTaskBadgeClasses,
4
- getTaskStatusLabel,
5
- } from '../_lib/utils/status';
6
- import type { OperationsUser, Task, TaskStatus } from '../_lib/types/operations';
7
- import { StatusBadge } from './status-badge';
8
-
9
- const columns: TaskStatus[] = [
10
- 'backlog',
11
- 'todo',
12
- 'in-progress',
13
- 'review',
14
- 'done',
15
- ];
16
-
17
- interface KanbanBoardProps {
18
- tasks: Task[];
19
- users: OperationsUser[];
20
- }
21
-
22
- export function KanbanBoard({ tasks, users }: KanbanBoardProps) {
23
- return (
24
- <div className="grid gap-4 xl:grid-cols-5">
25
- {columns.map((column) => {
26
- const columnTasks = tasks.filter((task) => task.status === column);
27
-
28
- return (
29
- <div
30
- key={column}
31
- className="rounded-xl border bg-muted/30 p-3 shadow-sm"
32
- >
33
- <div className="mb-3 flex items-center justify-between">
34
- <h3 className="text-sm font-semibold">{getTaskStatusLabel(column)}</h3>
35
- <span className="text-xs text-muted-foreground">
36
- {columnTasks.length}
37
- </span>
38
- </div>
39
- <div className="space-y-3">
40
- {columnTasks.map((task) => {
41
- const assignedUser = users.find(
42
- (user) => user.id === task.assignedUserId
43
- );
44
-
45
- return (
46
- <div
47
- key={task.id}
48
- className="rounded-lg border bg-background p-3 shadow-sm"
49
- >
50
- <div className="space-y-2">
51
- <div className="flex items-start justify-between gap-2">
52
- <p className="text-sm font-medium">{task.title}</p>
53
- <StatusBadge
54
- label={getTaskStatusLabel(task.status)}
55
- className={getTaskBadgeClasses(task.status)}
56
- />
57
- </div>
58
- <div className="flex flex-wrap gap-2">
59
- {task.labels.map((label) => (
60
- <span
61
- key={label}
62
- className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] text-slate-700"
63
- >
64
- {label}
65
- </span>
66
- ))}
67
- </div>
68
- <div className="text-xs text-muted-foreground">
69
- <p>{assignedUser?.name ?? 'Unassigned'}</p>
70
- <p>{formatDate(task.dueDate)}</p>
71
- <p>{formatHours(task.estimatedHours)}</p>
72
- </div>
73
- </div>
74
- </div>
75
- );
76
- })}
77
- </div>
78
- </div>
79
- );
80
- })}
81
- </div>
82
- );
83
- }
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';
25
+ import {
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';
41
+ import { StatusBadge } from './status-badge';
42
+
43
+ export const TASK_UNASSIGNED_LANE = 'unassigned';
44
+
45
+ const columns: TaskStatus[] = [
46
+ 'backlog',
47
+ 'todo',
48
+ 'in-progress',
49
+ 'review',
50
+ 'done',
51
+ ];
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
+
71
+ interface KanbanBoardProps {
72
+ tasks: Task[];
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];
121
+ }
122
+
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
+
170
+ return (
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"
225
+ >
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
+ ))}
405
+ </div>
406
+ </div>
407
+ )}
408
+ </div>
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
+ );
626
+ }