@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.
@@ -1,47 +1,999 @@
1
1
  'use client';
2
2
 
3
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';
4
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';
5
33
  import { useTranslations } from 'next-intl';
6
- import { useMemo, useState } from 'react';
7
- import { KanbanBoard } from '../_components/kanban-board';
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';
8
40
  import { OperationsHeader } from '../_components/operations-header';
9
- import { SectionCard } from '../_components/section-card';
10
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
+ }
11
104
 
12
105
  export default function TasksPage() {
13
106
  const t = useTranslations('operations.TasksPage');
14
107
  const { tasks, users } = useOperationsData();
15
- const [search, setSearch] = useState('');
108
+ const searchInputRef = useRef<HTMLInputElement>(null);
16
109
 
17
- const filteredTasks = useMemo(
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(
18
180
  () =>
19
- tasks.filter((task) =>
20
- `${task.title} ${task.projectName} ${task.labels.join(' ')}`
21
- .toLowerCase()
22
- .includes(search.toLowerCase())
181
+ Array.from(new Set(activeTasks.map((task) => task.projectName))).sort(
182
+ (a, b) => a.localeCompare(b)
23
183
  ),
24
- [tasks, search]
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]
25
583
  );
26
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
+
27
693
  return (
28
694
  <Page>
29
695
  <OperationsHeader
30
696
  title={t('title')}
31
697
  description={t('description')}
32
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
+ }
33
711
  />
34
712
 
35
- <SectionCard title={t('boardTitle')} description={t('boardDescription')}>
36
- <div className="mb-4">
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]">
37
730
  <Input
38
- value={search}
39
- onChange={(event) => setSearch(event.target.value)}
731
+ ref={searchInputRef}
732
+ value={searchDraft}
733
+ onChange={(event) => setSearchDraft(event.target.value)}
40
734
  placeholder={t('searchPlaceholder')}
41
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
+ ))}
42
822
  </div>
43
- <KanbanBoard tasks={filteredTasks} users={users} />
44
- </SectionCard>
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>
45
997
  </Page>
46
998
  );
47
999
  }