@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.
- package/LICENSE +21 -0
- package/next.config.js +7 -0
- package/package.json +37 -0
- package/postcss.config.mjs +5 -0
- package/src/app/backlog/page.tsx +19 -0
- package/src/app/docs/page.tsx +7 -0
- package/src/app/globals.css +603 -0
- package/src/app/layout.tsx +43 -0
- package/src/app/page.tsx +16 -0
- package/src/app/providers.tsx +16 -0
- package/src/app/settings/page.tsx +194 -0
- package/src/components/BoardFilter.tsx +98 -0
- package/src/components/Header.tsx +21 -0
- package/src/components/PropertyItem.tsx +98 -0
- package/src/components/Sidebar.tsx +109 -0
- package/src/components/TaskCard.tsx +138 -0
- package/src/components/TaskCreateModal.tsx +243 -0
- package/src/components/TaskPanel.tsx +765 -0
- package/src/components/index.ts +7 -0
- package/src/components/ui/Badge.tsx +77 -0
- package/src/components/ui/Button.tsx +47 -0
- package/src/components/ui/Checkbox.tsx +52 -0
- package/src/components/ui/Dropdown.tsx +107 -0
- package/src/components/ui/Input.tsx +36 -0
- package/src/components/ui/Modal.tsx +79 -0
- package/src/components/ui/Textarea.tsx +21 -0
- package/src/components/ui/index.ts +7 -0
- package/src/hooks/useTasks.ts +119 -0
- package/src/lib/api-client.ts +24 -0
- package/src/lib/utils.ts +6 -0
- package/src/services/doc.service.ts +27 -0
- package/src/services/index.ts +3 -0
- package/src/services/sprint.service.ts +26 -0
- package/src/services/task.service.ts +75 -0
- package/src/views/Backlog.tsx +691 -0
- package/src/views/Board.tsx +306 -0
- package/src/views/Docs.tsx +625 -0
- 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
|
+
}
|