@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,306 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { SprintStatus, TaskStatus } from "@locusai/shared";
|
|
4
|
+
import { useQuery } from "@tanstack/react-query";
|
|
5
|
+
import { AnimatePresence } from "framer-motion";
|
|
6
|
+
import { MoreHorizontal, Plus } from "lucide-react";
|
|
7
|
+
import { useRouter } from "next/navigation";
|
|
8
|
+
import { useCallback, useEffect, useState } from "react";
|
|
9
|
+
import { BoardFilter } from "@/components/BoardFilter";
|
|
10
|
+
import { TaskCard } from "@/components/TaskCard";
|
|
11
|
+
import { TaskCreateModal } from "@/components/TaskCreateModal";
|
|
12
|
+
import { TaskPanel } from "@/components/TaskPanel";
|
|
13
|
+
import { Button } from "@/components/ui/Button";
|
|
14
|
+
import { useTasks } from "@/hooks/useTasks";
|
|
15
|
+
import { cn } from "@/lib/utils";
|
|
16
|
+
import { sprintService } from "@/services";
|
|
17
|
+
|
|
18
|
+
const COLUMNS = [
|
|
19
|
+
TaskStatus.BACKLOG,
|
|
20
|
+
TaskStatus.IN_PROGRESS,
|
|
21
|
+
TaskStatus.REVIEW,
|
|
22
|
+
TaskStatus.VERIFICATION,
|
|
23
|
+
TaskStatus.DONE,
|
|
24
|
+
TaskStatus.BLOCKED,
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const STATUS_CONFIG: Record<
|
|
28
|
+
string,
|
|
29
|
+
{ label: string; color: string; indicator: string }
|
|
30
|
+
> = {
|
|
31
|
+
[TaskStatus.BACKLOG]: {
|
|
32
|
+
label: "Backlog",
|
|
33
|
+
color: "var(--status-backlog)",
|
|
34
|
+
indicator: "bg-slate-500",
|
|
35
|
+
},
|
|
36
|
+
[TaskStatus.IN_PROGRESS]: {
|
|
37
|
+
label: "In Progress",
|
|
38
|
+
color: "var(--status-progress)",
|
|
39
|
+
indicator: "bg-amber-500",
|
|
40
|
+
},
|
|
41
|
+
[TaskStatus.REVIEW]: {
|
|
42
|
+
label: "Review",
|
|
43
|
+
color: "var(--status-review)",
|
|
44
|
+
indicator: "bg-purple-500",
|
|
45
|
+
},
|
|
46
|
+
[TaskStatus.VERIFICATION]: {
|
|
47
|
+
label: "Verification",
|
|
48
|
+
color: "var(--accent)",
|
|
49
|
+
indicator: "bg-cyan-500",
|
|
50
|
+
},
|
|
51
|
+
[TaskStatus.DONE]: {
|
|
52
|
+
label: "Done",
|
|
53
|
+
color: "var(--status-done)",
|
|
54
|
+
indicator: "bg-emerald-500",
|
|
55
|
+
},
|
|
56
|
+
[TaskStatus.BLOCKED]: {
|
|
57
|
+
label: "Blocked",
|
|
58
|
+
color: "var(--status-blocked)",
|
|
59
|
+
indicator: "bg-rose-500",
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function Board() {
|
|
64
|
+
const router = useRouter();
|
|
65
|
+
const {
|
|
66
|
+
searchQuery,
|
|
67
|
+
setSearchQuery,
|
|
68
|
+
priorityFilter,
|
|
69
|
+
setPriorityFilter,
|
|
70
|
+
assigneeFilter,
|
|
71
|
+
setAssigneeFilter,
|
|
72
|
+
hasActiveFilters,
|
|
73
|
+
clearFilters,
|
|
74
|
+
getTasksByStatus,
|
|
75
|
+
updateTaskStatus,
|
|
76
|
+
deleteTask,
|
|
77
|
+
refreshTasks,
|
|
78
|
+
} = useTasks();
|
|
79
|
+
|
|
80
|
+
const { data: sprints = [] } = useQuery({
|
|
81
|
+
queryKey: ["sprints"],
|
|
82
|
+
queryFn: sprintService.getAll,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const activeSprint = sprints.find((s) => s.status === SprintStatus.ACTIVE);
|
|
86
|
+
|
|
87
|
+
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
88
|
+
const [createModalStatus, setCreateModalStatus] = useState<TaskStatus>(
|
|
89
|
+
TaskStatus.BACKLOG
|
|
90
|
+
);
|
|
91
|
+
const [selectedTaskId, setSelectedTaskId] = useState<number | null>(null);
|
|
92
|
+
const [dragOverColumn, setDragOverColumn] = useState<TaskStatus | null>(null);
|
|
93
|
+
|
|
94
|
+
const handleOpenCreateModal = useCallback((status: TaskStatus) => {
|
|
95
|
+
setCreateModalStatus(status);
|
|
96
|
+
setIsCreateModalOpen(true);
|
|
97
|
+
}, []);
|
|
98
|
+
|
|
99
|
+
// Keyboard shortcuts
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
102
|
+
if (
|
|
103
|
+
e.target instanceof HTMLInputElement ||
|
|
104
|
+
e.target instanceof HTMLTextAreaElement
|
|
105
|
+
) {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (e.key === "n" || e.key === "N") {
|
|
109
|
+
e.preventDefault();
|
|
110
|
+
handleOpenCreateModal(TaskStatus.BACKLOG);
|
|
111
|
+
}
|
|
112
|
+
if (e.key === "/" && !e.metaKey && !e.ctrlKey) {
|
|
113
|
+
e.preventDefault();
|
|
114
|
+
const searchInput = document.querySelector(
|
|
115
|
+
".search-input"
|
|
116
|
+
) as HTMLInputElement;
|
|
117
|
+
searchInput?.focus();
|
|
118
|
+
}
|
|
119
|
+
if (e.key === "Escape" && selectedTaskId) {
|
|
120
|
+
setSelectedTaskId(null);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
document.addEventListener("keydown", handleKeyDown);
|
|
125
|
+
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
126
|
+
}, [selectedTaskId, handleOpenCreateModal]);
|
|
127
|
+
|
|
128
|
+
const handleDrop = async (status: TaskStatus, e: React.DragEvent) => {
|
|
129
|
+
e.preventDefault();
|
|
130
|
+
setDragOverColumn(null);
|
|
131
|
+
const taskId = e.dataTransfer.getData("taskId");
|
|
132
|
+
if (!taskId) return;
|
|
133
|
+
await updateTaskStatus(Number(taskId), status);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const handleDragOver = (status: TaskStatus, e: React.DragEvent) => {
|
|
137
|
+
e.preventDefault();
|
|
138
|
+
e.dataTransfer.dropEffect = "move";
|
|
139
|
+
setDragOverColumn(status);
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const handleDragLeave = () => {
|
|
143
|
+
setDragOverColumn(null);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (!activeSprint) {
|
|
147
|
+
return (
|
|
148
|
+
<div className="flex-1 flex flex-col items-center justify-center p-8 text-center space-y-4">
|
|
149
|
+
<div className="bg-primary/5 p-4 rounded-full">
|
|
150
|
+
<svg
|
|
151
|
+
className="w-12 h-12 text-primary/40"
|
|
152
|
+
fill="none"
|
|
153
|
+
stroke="currentColor"
|
|
154
|
+
viewBox="0 0 24 24"
|
|
155
|
+
>
|
|
156
|
+
<title>Icon</title>
|
|
157
|
+
<path
|
|
158
|
+
strokeLinecap="round"
|
|
159
|
+
strokeLinejoin="round"
|
|
160
|
+
strokeWidth={2}
|
|
161
|
+
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
|
|
162
|
+
/>
|
|
163
|
+
</svg>
|
|
164
|
+
</div>
|
|
165
|
+
<div>
|
|
166
|
+
<h2 className="text-xl font-bold">No Active Sprint</h2>
|
|
167
|
+
<p className="text-muted-foreground mt-2 max-w-sm mx-auto">
|
|
168
|
+
You need to start a sprint from the Backlog to see tasks on the
|
|
169
|
+
board.
|
|
170
|
+
</p>
|
|
171
|
+
</div>
|
|
172
|
+
<Button onClick={() => router.push("/backlog")} variant="secondary">
|
|
173
|
+
Go to Backlog
|
|
174
|
+
</Button>
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<div className="flex-1 overflow-auto bg-background">
|
|
181
|
+
<div className="mb-6">
|
|
182
|
+
<div className="flex justify-between items-center">
|
|
183
|
+
<div>
|
|
184
|
+
<div className="flex items-center gap-3">
|
|
185
|
+
<h1 className="text-2xl font-bold tracking-tight text-foreground">
|
|
186
|
+
{activeSprint.name}
|
|
187
|
+
</h1>
|
|
188
|
+
<span className="px-2 py-0.5 rounded-full bg-primary/10 text-primary text-[10px] font-black uppercase tracking-wider">
|
|
189
|
+
Active
|
|
190
|
+
</span>
|
|
191
|
+
</div>
|
|
192
|
+
<p className="text-muted-foreground text-sm mt-1 flex items-center gap-3">
|
|
193
|
+
Track and manage engineering tasks
|
|
194
|
+
<span className="hidden md:inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-secondary/60 text-[10px] font-semibold text-muted-foreground">
|
|
195
|
+
<kbd className="bg-background/80 px-1.5 py-0.5 rounded text-[9px] font-bold border">
|
|
196
|
+
N
|
|
197
|
+
</kbd>
|
|
198
|
+
New task
|
|
199
|
+
</span>
|
|
200
|
+
</p>
|
|
201
|
+
</div>
|
|
202
|
+
<Button
|
|
203
|
+
onClick={() => handleOpenCreateModal(TaskStatus.BACKLOG)}
|
|
204
|
+
className="shadow-lg shadow-primary/20"
|
|
205
|
+
>
|
|
206
|
+
<Plus size={16} className="mr-1.5" />
|
|
207
|
+
New Task
|
|
208
|
+
</Button>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
<BoardFilter
|
|
212
|
+
searchQuery={searchQuery}
|
|
213
|
+
onSearchChange={setSearchQuery}
|
|
214
|
+
priorityFilter={priorityFilter}
|
|
215
|
+
onPriorityChange={setPriorityFilter}
|
|
216
|
+
assigneeFilter={assigneeFilter}
|
|
217
|
+
onAssigneeChange={setAssigneeFilter}
|
|
218
|
+
onClearFilters={clearFilters}
|
|
219
|
+
hasActiveFilters={hasActiveFilters}
|
|
220
|
+
/>
|
|
221
|
+
<div className="flex gap-4 min-h-[600px] pb-4">
|
|
222
|
+
{COLUMNS.map((status) => {
|
|
223
|
+
// Filter tasks by active sprint ID
|
|
224
|
+
const columnTasks = getTasksByStatus(status).filter(
|
|
225
|
+
(t) => t.sprintId === activeSprint.id
|
|
226
|
+
);
|
|
227
|
+
const isDragOver = dragOverColumn === status;
|
|
228
|
+
|
|
229
|
+
return (
|
|
230
|
+
<div
|
|
231
|
+
key={status}
|
|
232
|
+
className={cn(
|
|
233
|
+
"flex flex-col w-[280px] shrink-0 rounded-xl transition-all border",
|
|
234
|
+
isDragOver
|
|
235
|
+
? "bg-primary/5 border-primary/40 ring-1 ring-primary/20"
|
|
236
|
+
: "bg-card/50 border-border/50"
|
|
237
|
+
)}
|
|
238
|
+
onDrop={(e) => handleDrop(status, e)}
|
|
239
|
+
onDragOver={(e) => handleDragOver(status, e)}
|
|
240
|
+
onDragLeave={handleDragLeave}
|
|
241
|
+
>
|
|
242
|
+
<div className="flex items-center justify-between p-3 border-b border-border/50">
|
|
243
|
+
<div className="flex items-center gap-2.5">
|
|
244
|
+
<div
|
|
245
|
+
className={cn(
|
|
246
|
+
"h-2.5 w-2.5 rounded-full",
|
|
247
|
+
STATUS_CONFIG[status].indicator
|
|
248
|
+
)}
|
|
249
|
+
/>
|
|
250
|
+
<span className="text-xs font-semibold text-foreground">
|
|
251
|
+
{STATUS_CONFIG[status].label}
|
|
252
|
+
</span>
|
|
253
|
+
<span className="flex items-center justify-center h-5 min-w-[20px] px-1.5 bg-secondary/80 text-[10px] font-bold rounded-md text-muted-foreground">
|
|
254
|
+
{columnTasks.length}
|
|
255
|
+
</span>
|
|
256
|
+
</div>
|
|
257
|
+
<button className="p-1.5 rounded-lg hover:bg-secondary text-muted-foreground transition-colors">
|
|
258
|
+
<MoreHorizontal size={14} />
|
|
259
|
+
</button>
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<div className="flex flex-col gap-2.5 p-2.5 flex-1 overflow-y-auto">
|
|
263
|
+
{columnTasks.map((task) => (
|
|
264
|
+
<TaskCard
|
|
265
|
+
key={task.id}
|
|
266
|
+
task={task}
|
|
267
|
+
onClick={() => setSelectedTaskId(task.id)}
|
|
268
|
+
onDelete={deleteTask}
|
|
269
|
+
/>
|
|
270
|
+
))}
|
|
271
|
+
|
|
272
|
+
<Button
|
|
273
|
+
variant="ghost"
|
|
274
|
+
className="w-full justify-center text-xs font-medium text-muted-foreground/60 hover:text-foreground hover:bg-secondary/40 h-8 border border-dashed border-border/50 hover:border-border rounded-lg"
|
|
275
|
+
onClick={() => handleOpenCreateModal(status)}
|
|
276
|
+
>
|
|
277
|
+
<Plus size={14} className="mr-1.5" />
|
|
278
|
+
Add
|
|
279
|
+
</Button>
|
|
280
|
+
</div>
|
|
281
|
+
</div>
|
|
282
|
+
);
|
|
283
|
+
})}
|
|
284
|
+
</div>
|
|
285
|
+
<TaskCreateModal
|
|
286
|
+
isOpen={isCreateModalOpen}
|
|
287
|
+
onClose={() => setIsCreateModalOpen(false)}
|
|
288
|
+
onCreated={refreshTasks}
|
|
289
|
+
initialStatus={createModalStatus}
|
|
290
|
+
sprintId={activeSprint?.id}
|
|
291
|
+
/>
|
|
292
|
+
|
|
293
|
+
<AnimatePresence>
|
|
294
|
+
{selectedTaskId && (
|
|
295
|
+
<TaskPanel
|
|
296
|
+
key="task-panel"
|
|
297
|
+
taskId={selectedTaskId}
|
|
298
|
+
onClose={() => setSelectedTaskId(null)}
|
|
299
|
+
onDeleted={refreshTasks}
|
|
300
|
+
onUpdated={refreshTasks}
|
|
301
|
+
/>
|
|
302
|
+
)}
|
|
303
|
+
</AnimatePresence>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|