|
1 | 1 | 'use client'; |
2 | 2 |
|
3 | | -import { useState, useEffect, useMemo } from 'react'; |
| 3 | +import { useState, useEffect, useMemo, useRef } from 'react'; |
4 | 4 | import useSWR, { preload } from 'swr'; |
5 | 5 | import { useAuth } from '../auth-context'; |
6 | 6 | import { findFirstJiraKey } from '@/lib/jira-key-utils'; |
@@ -120,12 +120,19 @@ export default function ProjectsContent() { |
120 | 120 |
|
121 | 121 | // Status editing |
122 | 122 | const [editingStatus, setEditingStatus] = useState<string | null>(null); |
| 123 | + const [statusDropdownPos, setStatusDropdownPos] = useState<{ top: number; left: number } | null>(null); |
| 124 | + const statusTriggerRef = useRef<HTMLElement | null>(null); |
123 | 125 | const [transitionsCache, setTransitionsCache] = useState<Record<string, Array<{ id: string; name: string; to: { name: string } }>>>({}); |
124 | 126 | const [transitionsLoading, setTransitionsLoading] = useState(false); |
125 | 127 | const [savingStatus, setSavingStatus] = useState<string | null>(null); |
126 | 128 |
|
127 | | - const openStatusEditor = async (epicKey: string) => { |
128 | | - if (editingStatus === epicKey) { setEditingStatus(null); return; } |
| 129 | + const openStatusEditor = async (epicKey: string, triggerEl?: HTMLElement) => { |
| 130 | + if (editingStatus === epicKey) { setEditingStatus(null); setStatusDropdownPos(null); statusTriggerRef.current = null; return; } |
| 131 | + if (triggerEl) { |
| 132 | + statusTriggerRef.current = triggerEl; |
| 133 | + const rect = triggerEl.getBoundingClientRect(); |
| 134 | + setStatusDropdownPos({ top: rect.bottom + 2, left: rect.left }); |
| 135 | + } |
129 | 136 | setEditingStatus(epicKey); |
130 | 137 | if (transitionsCache[epicKey]) return; // already cached |
131 | 138 | setTransitionsLoading(true); |
@@ -168,16 +175,30 @@ export default function ProjectsContent() { |
168 | 175 | } finally { |
169 | 176 | setSavingStatus(null); |
170 | 177 | setEditingStatus(null); |
| 178 | + setStatusDropdownPos(null); |
| 179 | + statusTriggerRef.current = null; |
171 | 180 | // Invalidate transitions cache for this epic (status changed, transitions differ) |
172 | 181 | setTransitionsCache(prev => { const n = { ...prev }; delete n[epicKey]; return n; }); |
173 | 182 | } |
174 | 183 | }; |
175 | 184 |
|
176 | 185 | useEffect(() => { |
177 | 186 | if (!editingStatus) return; |
178 | | - const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setEditingStatus(null); }; |
179 | | - window.addEventListener('keydown', handler); |
180 | | - return () => window.removeEventListener('keydown', handler); |
| 187 | + const close = () => { setEditingStatus(null); setStatusDropdownPos(null); statusTriggerRef.current = null; }; |
| 188 | + const reposition = () => { |
| 189 | + if (!statusTriggerRef.current) return; |
| 190 | + const rect = statusTriggerRef.current.getBoundingClientRect(); |
| 191 | + setStatusDropdownPos({ top: rect.bottom + 2, left: rect.left }); |
| 192 | + }; |
| 193 | + const keyHandler = (e: KeyboardEvent) => { if (e.key === 'Escape') close(); }; |
| 194 | + window.addEventListener('keydown', keyHandler); |
| 195 | + window.addEventListener('scroll', reposition, { capture: true, passive: true }); |
| 196 | + window.addEventListener('resize', reposition); |
| 197 | + return () => { |
| 198 | + window.removeEventListener('keydown', keyHandler); |
| 199 | + window.removeEventListener('scroll', reposition, { capture: true }); |
| 200 | + window.removeEventListener('resize', reposition); |
| 201 | + }; |
181 | 202 | }, [editingStatus]); |
182 | 203 |
|
183 | 204 | useEffect(() => { |
@@ -911,59 +932,64 @@ export default function ProjectsContent() { |
911 | 932 | </> |
912 | 933 | )} |
913 | 934 | </td> |
914 | | - <td className="px-4 py-3 text-gray-300 relative"> |
| 935 | + <td className="px-4 py-3 text-gray-300"> |
915 | 936 | <div>{epic.assignee || '—'}</div> |
916 | | - {canAct ? ( |
917 | | - <div |
918 | | - onClick={(e) => { e.stopPropagation(); openStatusEditor(epic.key); }} |
919 | | - className={`flex items-center gap-1 mt-0.5 cursor-pointer text-[10px] transition-colors ${ |
920 | | - editingStatus === epic.key ? 'text-accent-lighter' : 'text-gray-500 hover:text-gray-300' |
921 | | - }`} |
922 | | - > |
923 | | - <span className="w-[6px] h-[6px] rounded-full shrink-0" style={{ |
924 | | - background: savingStatus === epic.key ? '#6B7280' : |
925 | | - epic.status === 'Done' ? '#10B981' : epic.status === 'Rollout' ? '#3B82F6' : |
926 | | - epic.status === 'In Progress' ? '#D97706' : '#6B7280' |
927 | | - }} /> |
928 | | - {savingStatus === epic.key ? 'Saving...' : epic.status} |
929 | | - <svg className="w-2 h-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M19 9l-7 7-7-7" /></svg> |
930 | | - </div> |
931 | | - ) : ( |
932 | | - <div className="flex items-center gap-1 mt-0.5 text-[10px] text-gray-600"> |
933 | | - <span className="w-[6px] h-[6px] rounded-full shrink-0" style={{ |
934 | | - background: epic.status === 'Done' ? '#10B981' : epic.status === 'Rollout' ? '#3B82F6' : |
935 | | - epic.status === 'In Progress' ? '#D97706' : '#6B7280' |
936 | | - }} /> |
937 | | - {epic.status} |
938 | | - </div> |
939 | | - )} |
940 | | - {editingStatus === epic.key && ( |
941 | | - <> |
942 | | - <div className="fixed inset-0 z-20" onClick={() => setEditingStatus(null)} /> |
943 | | - <div className="absolute top-full left-2 mt-0 z-30 bg-gray-800 border border-gray-700 rounded-lg shadow-xl overflow-hidden min-w-[140px]"> |
944 | | - {transitionsLoading && !transitionsCache[epic.key] ? ( |
945 | | - <div className="px-3 py-2 text-xs text-gray-500 animate-pulse">Loading...</div> |
946 | | - ) : (transitionsCache[epic.key] || []).length === 0 ? ( |
947 | | - <div className="px-3 py-2 text-xs text-gray-600">No transitions</div> |
948 | | - ) : ( |
949 | | - (transitionsCache[epic.key] || []).map(t => ( |
950 | | - <button |
951 | | - key={t.id} |
952 | | - onClick={(e) => { e.stopPropagation(); if (t.to.name === epic.status) { setEditingStatus(null); } else { executeTransition(epic.key, t.id, t.to.name); } }} |
953 | | - className={`w-full text-left px-3 py-1.5 text-xs transition-colors flex items-center gap-2 ${ |
954 | | - t.to.name === epic.status ? 'text-accent-lighter font-medium' : 'text-gray-300 hover:bg-gray-700' |
955 | | - }`} |
956 | | - > |
957 | | - <span className="w-[6px] h-[6px] rounded-full shrink-0" style={{ |
958 | | - background: t.to.name === 'Done' ? '#10B981' : t.to.name === 'Rollout' ? '#3B82F6' : t.to.name === 'In Progress' ? '#D97706' : '#6B7280' |
959 | | - }} /> |
960 | | - {t.to.name} |
961 | | - </button> |
962 | | - )) |
963 | | - )} |
| 937 | + <div className="mt-0.5"> |
| 938 | + {canAct ? ( |
| 939 | + <div |
| 940 | + onClick={(e) => { e.stopPropagation(); openStatusEditor(epic.key, e.currentTarget as HTMLElement); }} |
| 941 | + className={`flex items-center gap-1 cursor-pointer text-[10px] transition-colors ${ |
| 942 | + editingStatus === epic.key ? 'text-accent-lighter' : 'text-gray-500 hover:text-gray-300' |
| 943 | + }`} |
| 944 | + > |
| 945 | + <span className="w-[6px] h-[6px] rounded-full shrink-0" style={{ |
| 946 | + background: savingStatus === epic.key ? '#6B7280' : |
| 947 | + epic.status === 'Done' ? '#10B981' : epic.status === 'Rollout' ? '#3B82F6' : |
| 948 | + epic.status === 'In Progress' ? '#D97706' : '#6B7280' |
| 949 | + }} /> |
| 950 | + {savingStatus === epic.key ? 'Saving...' : epic.status} |
| 951 | + <svg className="w-2 h-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M19 9l-7 7-7-7" /></svg> |
964 | 952 | </div> |
965 | | - </> |
966 | | - )} |
| 953 | + ) : ( |
| 954 | + <div className="flex items-center gap-1 text-[10px] text-gray-600"> |
| 955 | + <span className="w-[6px] h-[6px] rounded-full shrink-0" style={{ |
| 956 | + background: epic.status === 'Done' ? '#10B981' : epic.status === 'Rollout' ? '#3B82F6' : |
| 957 | + epic.status === 'In Progress' ? '#D97706' : '#6B7280' |
| 958 | + }} /> |
| 959 | + {epic.status} |
| 960 | + </div> |
| 961 | + )} |
| 962 | + {editingStatus === epic.key && statusDropdownPos && ( |
| 963 | + <> |
| 964 | + <div className="fixed inset-0 z-20" onClick={() => { setEditingStatus(null); setStatusDropdownPos(null); statusTriggerRef.current = null; }} /> |
| 965 | + <div |
| 966 | + className="fixed z-30 bg-gray-800 border border-gray-700 rounded-lg shadow-xl overflow-hidden min-w-[140px]" |
| 967 | + style={{ top: statusDropdownPos.top, left: statusDropdownPos.left }} |
| 968 | + > |
| 969 | + {transitionsLoading && !transitionsCache[epic.key] ? ( |
| 970 | + <div className="px-3 py-2 text-xs text-gray-500 animate-pulse">Loading...</div> |
| 971 | + ) : (transitionsCache[epic.key] || []).length === 0 ? ( |
| 972 | + <div className="px-3 py-2 text-xs text-gray-600">No transitions</div> |
| 973 | + ) : ( |
| 974 | + (transitionsCache[epic.key] || []).map(t => ( |
| 975 | + <button |
| 976 | + key={t.id} |
| 977 | + onClick={(e) => { e.stopPropagation(); if (t.to.name === epic.status) { setEditingStatus(null); setStatusDropdownPos(null); statusTriggerRef.current = null; } else { executeTransition(epic.key, t.id, t.to.name); } }} |
| 978 | + className={`w-full text-left px-3 py-1.5 text-xs transition-colors flex items-center gap-2 ${ |
| 979 | + t.to.name === epic.status ? 'text-accent-lighter font-medium' : 'text-gray-300 hover:bg-gray-700' |
| 980 | + }`} |
| 981 | + > |
| 982 | + <span className="w-[6px] h-[6px] rounded-full shrink-0" style={{ |
| 983 | + background: t.to.name === 'Done' ? '#10B981' : t.to.name === 'Rollout' ? '#3B82F6' : t.to.name === 'In Progress' ? '#D97706' : '#6B7280' |
| 984 | + }} /> |
| 985 | + {t.to.name} |
| 986 | + </button> |
| 987 | + )) |
| 988 | + )} |
| 989 | + </div> |
| 990 | + </> |
| 991 | + )} |
| 992 | + </div> |
967 | 993 | </td> |
968 | 994 | <td className="px-4 py-3"> |
969 | 995 | {epic.team ? ( |
|
0 commit comments