Skip to content

Commit e7a728b

Browse files
committed
add carry-over task between sprints functionality
1 parent 8c3b782 commit e7a728b

3 files changed

Lines changed: 977 additions & 441 deletions

File tree

Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
1+
"use client";
2+
3+
import { useEffect, useState } from "react";
4+
import { Button } from "@/components/ui/button";
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogFooter,
9+
DialogHeader,
10+
DialogTitle,
11+
} from "@/components/ui/dialog";
12+
import {
13+
Select,
14+
SelectContent,
15+
SelectItem,
16+
SelectTrigger,
17+
SelectValue,
18+
} from "@/components/ui/select";
19+
import { Label } from "@codevs/ui/label";
20+
import { Loader2Icon, ArrowRightLeft } from "lucide-react";
21+
import toast from "react-hot-toast";
22+
import { createClientClientComponent } from "@/utils/supabase/client";
23+
import { getSprintsData } from "@/app/home/kanban/[projectId]/_services/query";
24+
import { transferTaskToSprint } from "@/app/home/kanban/[projectId]/[id]/actions";
25+
import { useKanbanStore } from "@/store/kanban-store";
26+
import { Task } from "@/types/home/codev";
27+
28+
interface Sprint {
29+
id: string;
30+
name: string;
31+
start_at: string;
32+
end_at: string;
33+
board_id: string;
34+
project_id?: string;
35+
kanban_board?: { id: string; name: string } | null;
36+
}
37+
38+
interface Column {
39+
id: string;
40+
name: string;
41+
position: number;
42+
}
43+
44+
interface Props {
45+
isOpen: boolean;
46+
onClose: () => void;
47+
task: Task;
48+
projectId: string;
49+
}
50+
51+
const formatDate = (dateStr: string) => {
52+
return new Date(dateStr).toLocaleDateString("en-US", {
53+
month: "short",
54+
day: "numeric",
55+
year: "numeric",
56+
});
57+
};
58+
59+
export default function MoveToSprintModal({
60+
isOpen,
61+
onClose,
62+
task,
63+
projectId,
64+
}: Props) {
65+
const { fetchBoardData, removeTaskOptimistic } = useKanbanStore();
66+
67+
const [sprints, setSprints] = useState<Sprint[]>([]);
68+
const [currentSprintId, setCurrentSprintId] = useState<string | null>(null);
69+
const [selectedSprintId, setSelectedSprintId] = useState<string>("");
70+
const [columns, setColumns] = useState<Column[]>([]);
71+
const [selectedColumnId, setSelectedColumnId] = useState<string>("");
72+
const [isLoadingSprints, setIsLoadingSprints] = useState(true);
73+
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
74+
const [isTransferring, setIsTransferring] = useState(false);
75+
76+
// -------------------------------------------------------------------------
77+
// Load all sprints for the project, then exclude the task's current sprint
78+
// -------------------------------------------------------------------------
79+
useEffect(() => {
80+
if (!isOpen || !projectId || !task?.kanban_column_id) return;
81+
82+
const load = async () => {
83+
setIsLoadingSprints(true);
84+
setSelectedSprintId("");
85+
setSelectedColumnId("");
86+
setColumns([]);
87+
88+
try {
89+
const supabase = createClientClientComponent();
90+
if (!supabase) throw new Error("Supabase client not initialized");
91+
// Find which sprint the task currently belongs to via its column → board
92+
const { data: columnData } = await supabase
93+
.from("kanban_columns")
94+
.select("board_id")
95+
.eq("id", task.kanban_column_id)
96+
.single();
97+
98+
if (columnData?.board_id) {
99+
const { data: sprintData } = await supabase
100+
.from("kanban_sprints")
101+
.select("id")
102+
.eq("board_id", columnData.board_id)
103+
.single();
104+
105+
setCurrentSprintId(sprintData?.id ?? null);
106+
}
107+
108+
// Fetch all project sprints
109+
const projectData = await getSprintsData(projectId);
110+
const allSprints =
111+
(projectData?.kanban_sprints as unknown as Sprint[]) ?? [];
112+
113+
setSprints(allSprints);
114+
} catch (err) {
115+
console.error("Error loading sprints:", err);
116+
toast.error("Failed to load sprints.");
117+
} finally {
118+
setIsLoadingSprints(false);
119+
}
120+
};
121+
122+
load();
123+
}, [isOpen, projectId, task?.kanban_column_id]);
124+
125+
// -------------------------------------------------------------------------
126+
// When a destination sprint is selected, load its columns
127+
// -------------------------------------------------------------------------
128+
useEffect(() => {
129+
if (!selectedSprintId) {
130+
setColumns([]);
131+
setSelectedColumnId("");
132+
return;
133+
}
134+
135+
const selectedSprint = sprints.find((s) => s.id === selectedSprintId);
136+
if (!selectedSprint?.board_id) return;
137+
138+
const loadColumns = async () => {
139+
setIsLoadingColumns(true);
140+
setSelectedColumnId("");
141+
142+
try {
143+
const supabase = createClientClientComponent();
144+
if (!supabase) throw new Error("Supabase client not initialized");
145+
const { data, error } = await supabase
146+
.from("kanban_columns")
147+
.select("id, name, position")
148+
.eq("board_id", selectedSprint.board_id)
149+
.order("position", { ascending: true });
150+
151+
if (error) {
152+
console.error("Error loading columns:", error);
153+
setColumns([]);
154+
} else {
155+
setColumns((data as Column[]) ?? []);
156+
// Pre-select the first column (default landing spot)
157+
if (data && data.length > 0) {
158+
setSelectedColumnId(data[0]?.id ?? "");
159+
}
160+
}
161+
} catch (err) {
162+
console.error("Error loading columns:", err);
163+
setColumns([]);
164+
} finally {
165+
setIsLoadingColumns(false);
166+
}
167+
};
168+
169+
loadColumns();
170+
}, [selectedSprintId, sprints]);
171+
172+
// -------------------------------------------------------------------------
173+
// Transfer handler
174+
// -------------------------------------------------------------------------
175+
const handleTransfer = async () => {
176+
if (!selectedSprintId) {
177+
toast.error("Please select a destination sprint.");
178+
return;
179+
}
180+
181+
if (!selectedColumnId && columns.length > 0) {
182+
toast.error("Please select a destination column.");
183+
return;
184+
}
185+
186+
if (columns.length === 0) {
187+
toast.error(
188+
"The selected sprint has no columns. Add a column to that sprint first.",
189+
);
190+
return;
191+
}
192+
193+
setIsTransferring(true);
194+
195+
// Optimistically remove task from the current board view
196+
removeTaskOptimistic(task.id);
197+
onClose();
198+
toast.success("Transferring task to sprint...");
199+
200+
try {
201+
const result = await transferTaskToSprint(
202+
task.id,
203+
selectedSprintId,
204+
selectedColumnId || undefined,
205+
);
206+
207+
if (result.success) {
208+
toast.success("Task successfully moved to the selected sprint!");
209+
await fetchBoardData();
210+
} else {
211+
toast.error(result.error ?? "Failed to transfer task.");
212+
// Re-fetch to restore the task if optimistic removal was premature
213+
await fetchBoardData();
214+
}
215+
} catch (err) {
216+
console.error("Transfer error:", err);
217+
toast.error("An unexpected error occurred.");
218+
await fetchBoardData();
219+
} finally {
220+
setIsTransferring(false);
221+
}
222+
};
223+
224+
// Sprints available as destinations (exclude current sprint)
225+
const availableSprints = sprints.filter((s) => s.id !== currentSprintId);
226+
227+
const selectedSprint = sprints.find((s) => s.id === selectedSprintId);
228+
229+
return (
230+
<Dialog open={isOpen} onOpenChange={onClose}>
231+
<DialogContent className="w-[95vw] max-w-md bg-white dark:bg-gray-900">
232+
<DialogHeader>
233+
<DialogTitle className="flex items-center gap-2 text-lg font-bold text-gray-900 dark:text-white">
234+
<ArrowRightLeft className="h-5 w-5 text-blue-600" />
235+
Move to Sprint
236+
</DialogTitle>
237+
</DialogHeader>
238+
239+
<div className="flex flex-col gap-4 py-2">
240+
{/* Task name reminder */}
241+
<div className="rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
242+
<span className="font-medium">Task:</span> {task?.title}
243+
</div>
244+
245+
{/* Sprint selector */}
246+
<div className="space-y-2">
247+
<Label className="text-sm font-medium">Destination Sprint</Label>
248+
{isLoadingSprints ? (
249+
<div className="flex items-center gap-2 text-sm text-gray-500">
250+
<Loader2Icon className="h-4 w-4 animate-spin" />
251+
Loading sprints...
252+
</div>
253+
) : availableSprints.length === 0 ? (
254+
<div className="rounded-md border border-dashed border-gray-300 bg-gray-50 px-3 py-3 text-sm text-gray-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400">
255+
No other sprints available in this project. Create another
256+
sprint first.
257+
</div>
258+
) : (
259+
<Select
260+
value={selectedSprintId}
261+
onValueChange={setSelectedSprintId}
262+
>
263+
<SelectTrigger className="border border-gray-300 bg-white dark:border-gray-700 dark:bg-gray-800">
264+
<SelectValue placeholder="Select a sprint..." />
265+
</SelectTrigger>
266+
<SelectContent>
267+
{availableSprints.map((sprint) => (
268+
<SelectItem key={sprint.id} value={sprint.id}>
269+
<div className="flex flex-col">
270+
<span className="font-medium">{sprint.name}</span>
271+
<span className="text-xs text-gray-500">
272+
{formatDate(sprint.start_at)} →{" "}
273+
{formatDate(sprint.end_at)}
274+
</span>
275+
</div>
276+
</SelectItem>
277+
))}
278+
</SelectContent>
279+
</Select>
280+
)}
281+
</div>
282+
283+
{/* Column selector — only shown once a sprint is picked */}
284+
{selectedSprintId && (
285+
<div className="space-y-2">
286+
<Label className="text-sm font-medium">
287+
Destination Column{" "}
288+
<span className="font-normal text-gray-400">
289+
(defaults to first column)
290+
</span>
291+
</Label>
292+
293+
{isLoadingColumns ? (
294+
<div className="flex items-center gap-2 text-sm text-gray-500">
295+
<Loader2Icon className="h-4 w-4 animate-spin" />
296+
Loading columns...
297+
</div>
298+
) : columns.length === 0 ? (
299+
<div className="rounded-md border border-dashed border-orange-300 bg-orange-50 px-3 py-2 text-sm text-orange-600 dark:border-orange-700 dark:bg-orange-900/20 dark:text-orange-400">
300+
This sprint has no columns. Add columns to it before
301+
transferring.
302+
</div>
303+
) : (
304+
<Select
305+
value={selectedColumnId}
306+
onValueChange={setSelectedColumnId}
307+
>
308+
<SelectTrigger className="border border-gray-300 bg-white dark:border-gray-700 dark:bg-gray-800">
309+
<SelectValue placeholder="Select a column..." />
310+
</SelectTrigger>
311+
<SelectContent>
312+
{columns.map((col) => (
313+
<SelectItem key={col.id} value={col.id}>
314+
{col.name}
315+
</SelectItem>
316+
))}
317+
</SelectContent>
318+
</Select>
319+
)}
320+
</div>
321+
)}
322+
323+
{/* Summary preview */}
324+
{selectedSprint && selectedColumnId && columns.length > 0 && (
325+
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-700 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300">
326+
This task will be moved to{" "}
327+
<span className="font-semibold">{selectedSprint.name}</span> →{" "}
328+
<span className="font-semibold">
329+
{columns.find((c) => c.id === selectedColumnId)?.name}
330+
</span>
331+
. All task details, comments, and points will be preserved.
332+
</div>
333+
)}
334+
</div>
335+
336+
<DialogFooter className="flex gap-2">
337+
<Button
338+
variant="outline"
339+
onClick={onClose}
340+
disabled={isTransferring}
341+
className="border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-300"
342+
>
343+
Cancel
344+
</Button>
345+
<Button
346+
onClick={handleTransfer}
347+
disabled={
348+
isTransferring ||
349+
!selectedSprintId ||
350+
columns.length === 0 ||
351+
isLoadingColumns
352+
}
353+
className="bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
354+
>
355+
{isTransferring && (
356+
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
357+
)}
358+
Move to Sprint
359+
</Button>
360+
</DialogFooter>
361+
</DialogContent>
362+
</Dialog>
363+
);
364+
}

0 commit comments

Comments
 (0)