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