@locusai/web 0.1.0

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 (38) hide show
  1. package/LICENSE +21 -0
  2. package/next.config.js +7 -0
  3. package/package.json +37 -0
  4. package/postcss.config.mjs +5 -0
  5. package/src/app/backlog/page.tsx +19 -0
  6. package/src/app/docs/page.tsx +7 -0
  7. package/src/app/globals.css +603 -0
  8. package/src/app/layout.tsx +43 -0
  9. package/src/app/page.tsx +16 -0
  10. package/src/app/providers.tsx +16 -0
  11. package/src/app/settings/page.tsx +194 -0
  12. package/src/components/BoardFilter.tsx +98 -0
  13. package/src/components/Header.tsx +21 -0
  14. package/src/components/PropertyItem.tsx +98 -0
  15. package/src/components/Sidebar.tsx +109 -0
  16. package/src/components/TaskCard.tsx +138 -0
  17. package/src/components/TaskCreateModal.tsx +243 -0
  18. package/src/components/TaskPanel.tsx +765 -0
  19. package/src/components/index.ts +7 -0
  20. package/src/components/ui/Badge.tsx +77 -0
  21. package/src/components/ui/Button.tsx +47 -0
  22. package/src/components/ui/Checkbox.tsx +52 -0
  23. package/src/components/ui/Dropdown.tsx +107 -0
  24. package/src/components/ui/Input.tsx +36 -0
  25. package/src/components/ui/Modal.tsx +79 -0
  26. package/src/components/ui/Textarea.tsx +21 -0
  27. package/src/components/ui/index.ts +7 -0
  28. package/src/hooks/useTasks.ts +119 -0
  29. package/src/lib/api-client.ts +24 -0
  30. package/src/lib/utils.ts +6 -0
  31. package/src/services/doc.service.ts +27 -0
  32. package/src/services/index.ts +3 -0
  33. package/src/services/sprint.service.ts +26 -0
  34. package/src/services/task.service.ts +75 -0
  35. package/src/views/Backlog.tsx +691 -0
  36. package/src/views/Board.tsx +306 -0
  37. package/src/views/Docs.tsx +625 -0
  38. package/tsconfig.json +21 -0
@@ -0,0 +1,691 @@
1
+ "use client";
2
+
3
+ import { type Sprint, SprintStatus, type Task } from "@locusai/shared";
4
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { format } from "date-fns";
6
+ import { AnimatePresence, motion } from "framer-motion";
7
+ import {
8
+ Archive,
9
+ CheckCircle,
10
+ ChevronRight,
11
+ GripVertical,
12
+ Layers,
13
+ Play,
14
+ Plus,
15
+ Sparkles,
16
+ Target,
17
+ Trash2,
18
+ Zap,
19
+ } from "lucide-react";
20
+ import { useState } from "react";
21
+ import { TaskCreateModal } from "@/components/TaskCreateModal";
22
+ import { TaskPanel } from "@/components/TaskPanel";
23
+ import { Button, Input, PriorityBadge, StatusBadge } from "@/components/ui";
24
+ import { cn } from "@/lib/utils";
25
+ import { sprintService } from "@/services/sprint.service";
26
+ import { taskService } from "@/services/task.service";
27
+
28
+ export function Backlog() {
29
+ const queryClient = useQueryClient();
30
+ const [selectedTask, setSelectedTask] = useState<number | null>(null);
31
+ const [isCreatingSprint, setIsCreatingSprint] = useState(false);
32
+ const [newSprintName, setNewSprintName] = useState("");
33
+ const [isCreateTaskOpen, setIsCreateTaskOpen] = useState(false);
34
+ const [showCompletedSprints, setShowCompletedSprints] = useState(false);
35
+ const [expandedSprints, setExpandedSprints] = useState<Set<number>>(
36
+ new Set()
37
+ );
38
+ const [backlogExpanded, setBacklogExpanded] = useState(true);
39
+
40
+ const { data: sprints = [] } = useQuery({
41
+ queryKey: ["sprints"],
42
+ queryFn: sprintService.getAll,
43
+ });
44
+
45
+ const { data: tasks = [] } = useQuery({
46
+ queryKey: ["tasks"],
47
+ queryFn: taskService.getAll,
48
+ });
49
+
50
+ const createSprint = useMutation({
51
+ mutationFn: sprintService.create,
52
+ onSuccess: () => {
53
+ queryClient.invalidateQueries({ queryKey: ["sprints"] });
54
+ setIsCreatingSprint(false);
55
+ setNewSprintName("");
56
+ },
57
+ });
58
+
59
+ const updateTask = useMutation({
60
+ mutationFn: ({ id, updates }: { id: number; updates: Partial<Task> }) =>
61
+ taskService.update(id, updates),
62
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tasks"] }),
63
+ });
64
+
65
+ const updateSprint = useMutation({
66
+ mutationFn: ({ id, updates }: { id: number; updates: Partial<Sprint> }) =>
67
+ sprintService.update(id, updates),
68
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["sprints"] }),
69
+ });
70
+
71
+ const deleteSprint = useMutation({
72
+ mutationFn: (id: number) => sprintService.delete(id),
73
+ onSuccess: () => {
74
+ queryClient.invalidateQueries({ queryKey: ["sprints"] });
75
+ queryClient.invalidateQueries({ queryKey: ["tasks"] });
76
+ },
77
+ });
78
+
79
+ const handleCreateSprint = () => {
80
+ if (!newSprintName.trim()) return;
81
+ createSprint.mutate({ name: newSprintName });
82
+ };
83
+
84
+ const handleStartSprint = (sprint: Sprint) => {
85
+ updateSprint.mutate({
86
+ id: sprint.id,
87
+ updates: {
88
+ status: SprintStatus.ACTIVE,
89
+ startDate: Date.now(),
90
+ },
91
+ });
92
+ };
93
+
94
+ const handleCompleteSprint = (sprint: Sprint) => {
95
+ updateSprint.mutate({
96
+ id: sprint.id,
97
+ updates: {
98
+ status: SprintStatus.COMPLETED,
99
+ endDate: Date.now(),
100
+ },
101
+ });
102
+ };
103
+
104
+ const toggleSprintExpand = (sprintId: number) => {
105
+ setExpandedSprints((prev) => {
106
+ const next = new Set(prev);
107
+ if (next.has(sprintId)) next.delete(sprintId);
108
+ else next.add(sprintId);
109
+ return next;
110
+ });
111
+ };
112
+
113
+ const backlogTasks = tasks.filter((t: Task) => !t.sprintId);
114
+ const activeSprints = sprints.filter(
115
+ (s: Sprint) => s.status === SprintStatus.ACTIVE
116
+ );
117
+ const plannedSprints = sprints.filter(
118
+ (s: Sprint) => s.status === SprintStatus.PLANNED
119
+ );
120
+ const completedSprints = sprints.filter(
121
+ (s: Sprint) => s.status === SprintStatus.COMPLETED
122
+ );
123
+
124
+ const handleDeleteSprint = (sprint: Sprint) => {
125
+ if (
126
+ confirm(
127
+ `Delete "${sprint.name}" and all its tasks? This cannot be undone.`
128
+ )
129
+ ) {
130
+ deleteSprint.mutate(sprint.id);
131
+ }
132
+ };
133
+
134
+ const getTasksForSprint = (sprintId: number) =>
135
+ tasks.filter((t: Task) => t.sprintId === sprintId);
136
+
137
+ return (
138
+ <div className="h-full flex flex-col bg-linear-to-br from-background via-background to-secondary/5">
139
+ {/* Header */}
140
+ <header className="px-8 py-6 border-b border-border/30 bg-background/80 backdrop-blur-xl sticky top-0 z-20">
141
+ <div className="flex items-center justify-between">
142
+ <div className="flex items-center gap-4">
143
+ <div className="p-2.5 rounded-xl bg-linear-to-br from-primary/20 to-primary/5 border border-primary/20">
144
+ <Layers className="w-5 h-5 text-primary" />
145
+ </div>
146
+ <div>
147
+ <h1 className="text-xl font-bold text-foreground">
148
+ Product Backlog
149
+ </h1>
150
+ <p className="text-sm text-muted-foreground">
151
+ {tasks.length} items · {sprints.length} sprints
152
+ </p>
153
+ </div>
154
+ </div>
155
+
156
+ <div className="flex items-center gap-3">
157
+ <Button
158
+ variant="secondary"
159
+ onClick={() => setIsCreatingSprint(true)}
160
+ disabled={isCreatingSprint}
161
+ className="gap-2"
162
+ >
163
+ <Target size={16} />
164
+ New Sprint
165
+ </Button>
166
+ <Button
167
+ onClick={() => setIsCreateTaskOpen(true)}
168
+ className="gap-2 shadow-lg shadow-primary/20"
169
+ >
170
+ <Plus size={16} />
171
+ Create Issue
172
+ </Button>
173
+ </div>
174
+ </div>
175
+
176
+ {/* Sprint Creation Inline */}
177
+ <AnimatePresence>
178
+ {isCreatingSprint && (
179
+ <motion.div
180
+ initial={{ height: 0, opacity: 0 }}
181
+ animate={{ height: "auto", opacity: 1 }}
182
+ exit={{ height: 0, opacity: 0 }}
183
+ className="overflow-hidden"
184
+ >
185
+ <div className="flex gap-3 mt-4 pt-4 border-t border-border/30 max-w-lg">
186
+ <Input
187
+ value={newSprintName}
188
+ onChange={(e) => setNewSprintName(e.target.value)}
189
+ placeholder="Sprint name (e.g. Sprint 24)"
190
+ autoFocus
191
+ onKeyDown={(e) => e.key === "Enter" && handleCreateSprint()}
192
+ className="flex-1"
193
+ />
194
+ <Button
195
+ onClick={handleCreateSprint}
196
+ disabled={!newSprintName.trim()}
197
+ size="sm"
198
+ >
199
+ Create
200
+ </Button>
201
+ <Button
202
+ variant="ghost"
203
+ size="sm"
204
+ onClick={() => {
205
+ setIsCreatingSprint(false);
206
+ setNewSprintName("");
207
+ }}
208
+ >
209
+ Cancel
210
+ </Button>
211
+ </div>
212
+ </motion.div>
213
+ )}
214
+ </AnimatePresence>
215
+ </header>
216
+
217
+ {/* Content */}
218
+ <div className="flex-1 overflow-y-auto">
219
+ <div className="max-w-5xl mx-auto px-8 py-6 space-y-4">
220
+ {/* Active Sprints */}
221
+ {activeSprints.map((sprint) => (
222
+ <SprintSection
223
+ key={sprint.id}
224
+ sprint={sprint}
225
+ tasks={getTasksForSprint(sprint.id)}
226
+ isExpanded={expandedSprints.has(sprint.id)}
227
+ onToggle={() => toggleSprintExpand(sprint.id)}
228
+ onTaskClick={setSelectedTask}
229
+ onMoveTask={(taskId, targetSprintId) =>
230
+ updateTask.mutate({
231
+ id: taskId,
232
+ updates: { sprintId: targetSprintId },
233
+ })
234
+ }
235
+ availableSprints={sprints}
236
+ variant="active"
237
+ onAction={() => handleCompleteSprint(sprint)}
238
+ actionLabel="Complete"
239
+ actionIcon={<CheckCircle size={14} />}
240
+ />
241
+ ))}
242
+
243
+ {/* Planned Sprints */}
244
+ {plannedSprints.map((sprint) => (
245
+ <SprintSection
246
+ key={sprint.id}
247
+ sprint={sprint}
248
+ tasks={getTasksForSprint(sprint.id)}
249
+ isExpanded={expandedSprints.has(sprint.id)}
250
+ onToggle={() => toggleSprintExpand(sprint.id)}
251
+ onTaskClick={setSelectedTask}
252
+ onMoveTask={(taskId, targetSprintId) =>
253
+ updateTask.mutate({
254
+ id: taskId,
255
+ updates: { sprintId: targetSprintId },
256
+ })
257
+ }
258
+ availableSprints={sprints}
259
+ variant="planned"
260
+ onAction={() => handleStartSprint(sprint)}
261
+ actionLabel="Start Sprint"
262
+ actionIcon={<Play size={14} />}
263
+ />
264
+ ))}
265
+
266
+ {/* Backlog Section */}
267
+ <motion.div
268
+ layout
269
+ className="rounded-2xl border border-border/40 bg-card/30 backdrop-blur-sm overflow-hidden"
270
+ >
271
+ <div className="flex items-center justify-between p-4 hover:bg-secondary/30 transition-colors">
272
+ <div
273
+ className="flex items-center gap-3 flex-1 cursor-pointer"
274
+ onClick={() => setBacklogExpanded(!backlogExpanded)}
275
+ >
276
+ <motion.div
277
+ animate={{ rotate: backlogExpanded ? 90 : 0 }}
278
+ transition={{ duration: 0.2 }}
279
+ >
280
+ <ChevronRight size={18} className="text-muted-foreground" />
281
+ </motion.div>
282
+ <div className="flex items-center gap-2">
283
+ <Sparkles size={16} className="text-amber-500" />
284
+ <span className="font-semibold text-foreground">Backlog</span>
285
+ </div>
286
+ <span className="px-2 py-0.5 rounded-full bg-secondary text-[11px] font-bold text-muted-foreground">
287
+ {backlogTasks.length}
288
+ </span>
289
+ </div>
290
+ <Button
291
+ variant="ghost"
292
+ size="sm"
293
+ className="text-xs gap-1.5 text-muted-foreground hover:text-primary"
294
+ onClick={() => setIsCreateTaskOpen(true)}
295
+ >
296
+ <Plus size={14} />
297
+ Add
298
+ </Button>
299
+ </div>
300
+
301
+ <AnimatePresence>
302
+ {backlogExpanded && (
303
+ <motion.div
304
+ initial={{ height: 0 }}
305
+ animate={{ height: "auto" }}
306
+ exit={{ height: 0 }}
307
+ className="overflow-hidden"
308
+ >
309
+ <div className="border-t border-border/30">
310
+ {backlogTasks.length === 0 ? (
311
+ <div className="py-12 text-center">
312
+ <div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-secondary/50 mb-3">
313
+ <Zap className="w-5 h-5 text-muted-foreground/50" />
314
+ </div>
315
+ <p className="text-sm text-muted-foreground">
316
+ No items in backlog
317
+ </p>
318
+ <Button
319
+ variant="ghost"
320
+ size="sm"
321
+ className="mt-2 text-primary"
322
+ onClick={() => setIsCreateTaskOpen(true)}
323
+ >
324
+ Create your first issue
325
+ </Button>
326
+ </div>
327
+ ) : (
328
+ <div className="divide-y divide-border/20">
329
+ {backlogTasks.map((task) => (
330
+ <TaskRow
331
+ key={task.id}
332
+ task={task}
333
+ onClick={() => setSelectedTask(task.id)}
334
+ onMoveTask={(targetSprintId) =>
335
+ updateTask.mutate({
336
+ id: task.id,
337
+ updates: { sprintId: targetSprintId },
338
+ })
339
+ }
340
+ availableSprints={sprints.filter(
341
+ (s) => s.status !== SprintStatus.COMPLETED
342
+ )}
343
+ currentSprintId={null}
344
+ />
345
+ ))}
346
+ </div>
347
+ )}
348
+ </div>
349
+ </motion.div>
350
+ )}
351
+ </AnimatePresence>
352
+ </motion.div>
353
+
354
+ {/* Completed Sprints */}
355
+ {completedSprints.length > 0 && (
356
+ <motion.div
357
+ layout
358
+ className="rounded-2xl border border-border/30 bg-background/50 overflow-hidden"
359
+ >
360
+ <button
361
+ onClick={() => setShowCompletedSprints(!showCompletedSprints)}
362
+ className="w-full flex items-center justify-between p-4 hover:bg-secondary/20 transition-colors"
363
+ >
364
+ <div className="flex items-center gap-3">
365
+ <motion.div
366
+ animate={{ rotate: showCompletedSprints ? 90 : 0 }}
367
+ transition={{ duration: 0.2 }}
368
+ >
369
+ <ChevronRight
370
+ size={18}
371
+ className="text-muted-foreground/50"
372
+ />
373
+ </motion.div>
374
+ <div className="flex items-center gap-2">
375
+ <Archive size={16} className="text-muted-foreground/50" />
376
+ <span className="font-medium text-muted-foreground">
377
+ Completed Sprints
378
+ </span>
379
+ </div>
380
+ <span className="px-2 py-0.5 rounded-full bg-secondary/50 text-[11px] font-bold text-muted-foreground/60">
381
+ {completedSprints.length}
382
+ </span>
383
+ </div>
384
+ </button>
385
+
386
+ <AnimatePresence>
387
+ {showCompletedSprints && (
388
+ <motion.div
389
+ initial={{ height: 0 }}
390
+ animate={{ height: "auto" }}
391
+ exit={{ height: 0 }}
392
+ className="overflow-hidden"
393
+ >
394
+ <div className="border-t border-border/30 divide-y divide-border/20">
395
+ {completedSprints.map((sprint) => (
396
+ <div
397
+ key={sprint.id}
398
+ className="flex items-center justify-between px-4 py-3 hover:bg-secondary/10 transition-colors"
399
+ >
400
+ <div className="flex items-center gap-3">
401
+ <CheckCircle
402
+ size={14}
403
+ className="text-emerald-500/60"
404
+ />
405
+ <span className="text-sm font-medium text-muted-foreground">
406
+ {sprint.name}
407
+ </span>
408
+ <span className="text-xs text-muted-foreground/50 font-mono">
409
+ {getTasksForSprint(sprint.id).length} tasks
410
+ </span>
411
+ {sprint.endDate && (
412
+ <span
413
+ className="text-xs text-muted-foreground/40"
414
+ suppressHydrationWarning
415
+ >
416
+ Completed{" "}
417
+ {format(sprint.endDate, "MMM d, yyyy")}
418
+ </span>
419
+ )}
420
+ </div>
421
+ <Button
422
+ variant="ghost"
423
+ size="sm"
424
+ className="text-xs text-muted-foreground hover:text-destructive hover:bg-destructive/10"
425
+ onClick={() => handleDeleteSprint(sprint)}
426
+ >
427
+ <Trash2 size={14} className="mr-1" />
428
+ Delete
429
+ </Button>
430
+ </div>
431
+ ))}
432
+ </div>
433
+ </motion.div>
434
+ )}
435
+ </AnimatePresence>
436
+ </motion.div>
437
+ )}
438
+ </div>
439
+ </div>
440
+
441
+ {/* Task Create Modal */}
442
+ <TaskCreateModal
443
+ isOpen={isCreateTaskOpen}
444
+ onClose={() => setIsCreateTaskOpen(false)}
445
+ onCreated={() => {
446
+ queryClient.invalidateQueries({ queryKey: ["tasks"] });
447
+ setIsCreateTaskOpen(false);
448
+ }}
449
+ />
450
+
451
+ {/* Task Panel */}
452
+ <AnimatePresence>
453
+ {selectedTask && (
454
+ <TaskPanel
455
+ taskId={selectedTask}
456
+ onClose={() => setSelectedTask(null)}
457
+ onDeleted={() => {
458
+ setSelectedTask(null);
459
+ queryClient.invalidateQueries({ queryKey: ["tasks"] });
460
+ }}
461
+ onUpdated={() =>
462
+ queryClient.invalidateQueries({ queryKey: ["tasks"] })
463
+ }
464
+ />
465
+ )}
466
+ </AnimatePresence>
467
+ </div>
468
+ );
469
+ }
470
+
471
+ // Sprint Section Component
472
+ function SprintSection({
473
+ sprint,
474
+ tasks,
475
+ isExpanded,
476
+ onToggle,
477
+ onTaskClick,
478
+ onMoveTask,
479
+ availableSprints,
480
+ variant,
481
+ onAction,
482
+ actionLabel,
483
+ actionIcon,
484
+ }: {
485
+ sprint: Sprint;
486
+ tasks: Task[];
487
+ isExpanded: boolean;
488
+ onToggle: () => void;
489
+ onTaskClick: (id: number) => void;
490
+ onMoveTask: (taskId: number, targetSprintId: number | null) => void;
491
+ availableSprints: Sprint[];
492
+ variant: "active" | "planned";
493
+ onAction: () => void;
494
+ actionLabel: string;
495
+ actionIcon: React.ReactNode;
496
+ }) {
497
+ const isActive = variant === "active";
498
+
499
+ return (
500
+ <motion.div
501
+ layout
502
+ className={cn(
503
+ "rounded-2xl border overflow-hidden transition-all",
504
+ isActive
505
+ ? "border-primary/30 bg-primary/2 shadow-lg shadow-primary/5"
506
+ : "border-border/40 bg-card/30"
507
+ )}
508
+ >
509
+ <div
510
+ onClick={onToggle}
511
+ className={cn(
512
+ "w-full flex items-center justify-between p-4 transition-colors",
513
+ isActive ? "hover:bg-primary/5" : "hover:bg-secondary/30"
514
+ )}
515
+ >
516
+ <div className="flex items-center gap-3">
517
+ <motion.div
518
+ animate={{ rotate: isExpanded ? 90 : 0 }}
519
+ transition={{ duration: 0.2 }}
520
+ >
521
+ <ChevronRight
522
+ size={18}
523
+ className={isActive ? "text-primary" : "text-muted-foreground"}
524
+ />
525
+ </motion.div>
526
+
527
+ <div className="flex items-center gap-3">
528
+ {isActive && (
529
+ <div className="relative">
530
+ <div className="w-2 h-2 rounded-full bg-primary animate-pulse" />
531
+ <div className="absolute inset-0 w-2 h-2 rounded-full bg-primary animate-ping" />
532
+ </div>
533
+ )}
534
+ <span
535
+ className={cn(
536
+ "font-semibold",
537
+ isActive ? "text-primary" : "text-foreground"
538
+ )}
539
+ >
540
+ {sprint.name}
541
+ </span>
542
+ <span className="px-2 py-0.5 rounded-full bg-secondary text-[11px] font-bold text-muted-foreground">
543
+ {tasks.length} issues
544
+ </span>
545
+ {sprint.startDate && isActive && (
546
+ <span
547
+ className="text-xs text-muted-foreground font-mono"
548
+ suppressHydrationWarning
549
+ >
550
+ Started {format(sprint.startDate, "MMM d")}
551
+ </span>
552
+ )}
553
+ </div>
554
+ </div>
555
+
556
+ <Button
557
+ variant={isActive ? "secondary" : "ghost"}
558
+ size="sm"
559
+ className={cn(
560
+ "gap-1.5 text-xs",
561
+ isActive
562
+ ? "hover:bg-emerald-500/10 hover:text-emerald-600"
563
+ : "hover:bg-primary/10 hover:text-primary"
564
+ )}
565
+ onClick={(e) => {
566
+ e.stopPropagation();
567
+ onAction();
568
+ }}
569
+ >
570
+ {actionIcon}
571
+ {actionLabel}
572
+ </Button>
573
+ </div>
574
+
575
+ <AnimatePresence>
576
+ {isExpanded && (
577
+ <motion.div
578
+ initial={{ height: 0 }}
579
+ animate={{ height: "auto" }}
580
+ exit={{ height: 0 }}
581
+ className="overflow-hidden"
582
+ >
583
+ <div className="border-t border-border/30">
584
+ {tasks.length === 0 ? (
585
+ <div className="py-8 text-center text-sm text-muted-foreground/60">
586
+ Drag issues here or move from backlog
587
+ </div>
588
+ ) : (
589
+ <div className="divide-y divide-border/20">
590
+ {tasks.map((task) => (
591
+ <TaskRow
592
+ key={task.id}
593
+ task={task}
594
+ onClick={() => onTaskClick(task.id)}
595
+ onMoveTask={(targetSprintId) =>
596
+ onMoveTask(task.id, targetSprintId)
597
+ }
598
+ availableSprints={availableSprints.filter(
599
+ (s) =>
600
+ s.id !== sprint.id &&
601
+ s.status !== SprintStatus.COMPLETED
602
+ )}
603
+ currentSprintId={sprint.id}
604
+ />
605
+ ))}
606
+ </div>
607
+ )}
608
+ </div>
609
+ </motion.div>
610
+ )}
611
+ </AnimatePresence>
612
+ </motion.div>
613
+ );
614
+ }
615
+
616
+ // Task Row Component
617
+ function TaskRow({
618
+ task,
619
+ onClick,
620
+ onMoveTask,
621
+ availableSprints,
622
+ currentSprintId,
623
+ }: {
624
+ task: Task;
625
+ onClick: () => void;
626
+ onMoveTask: (targetSprintId: number | null) => void;
627
+ availableSprints: Sprint[];
628
+ currentSprintId: number | null;
629
+ }) {
630
+ return (
631
+ <motion.div
632
+ layout
633
+ className="group flex items-center gap-3 px-4 py-3 hover:bg-secondary/20 transition-all cursor-pointer"
634
+ onClick={onClick}
635
+ >
636
+ <div className="opacity-0 group-hover:opacity-40 transition-opacity cursor-grab">
637
+ <GripVertical size={14} />
638
+ </div>
639
+
640
+ <PriorityBadge priority={task.priority} />
641
+
642
+ <div className="flex-1 min-w-0">
643
+ <div className="flex items-center gap-2">
644
+ <span className="text-xs font-mono text-muted-foreground/60 group-hover:text-primary transition-colors">
645
+ LCS-{task.id}
646
+ </span>
647
+ <span className="text-sm font-medium text-foreground/90 truncate group-hover:text-foreground transition-colors">
648
+ {task.title}
649
+ </span>
650
+ </div>
651
+ </div>
652
+
653
+ <div className="flex items-center gap-2">
654
+ <StatusBadge status={task.status} />
655
+ {task.assigneeRole && (
656
+ <span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-secondary border border-border/50 text-muted-foreground">
657
+ {task.assigneeRole}
658
+ </span>
659
+ )}
660
+ </div>
661
+
662
+ {/* Move dropdown */}
663
+ <div
664
+ className="opacity-0 group-hover:opacity-100 transition-opacity"
665
+ onClick={(e) => e.stopPropagation()}
666
+ >
667
+ <select
668
+ className="appearance-none bg-secondary/50 hover:bg-secondary text-xs font-medium text-muted-foreground px-2 py-1 rounded-md border border-border/50 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary/20"
669
+ value=""
670
+ onChange={(e) => {
671
+ const val = e.target.value;
672
+ if (val === "backlog") onMoveTask(null);
673
+ else if (val) onMoveTask(Number(val));
674
+ }}
675
+ >
676
+ <option value="" disabled>
677
+ Move →
678
+ </option>
679
+ {currentSprintId !== null && (
680
+ <option value="backlog">→ Backlog</option>
681
+ )}
682
+ {availableSprints.map((s) => (
683
+ <option key={s.id} value={s.id}>
684
+ → {s.name}
685
+ </option>
686
+ ))}
687
+ </select>
688
+ </div>
689
+ </motion.div>
690
+ );
691
+ }