import { useEffect, useState, useCallback, useRef } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' import { X, RotateCcw, Play, FileUp } from 'lucide-react' import { PageMeta } from '@/components/common/PageMeta' import { Button } from '@/components/ui/Button' import { FlowIllustration } from '@/components/common/EmptyStateIllustrations' import { treesApi } from '@/api/trees' import { categoriesApi } from '@/api/categories' import { foldersApi } from '@/api/folders' import { sessionsApi } from '@/api/sessions' import type { TreeListItem, CategoryListItem, FolderListItem, Session, IntakeFormField } from '@/types' import { FolderEditModal } from '@/components/library/FolderEditModal' import { ForkModal } from '@/components/library/ForkModal' import { ExportFlowModal } from '@/components/library/ExportFlowModal' import { ImportFlowModal } from '@/components/library/ImportFlowModal' import { PrepareSessionModal } from '@/components/procedural/PrepareSessionModal' import { accountsApi } from '@/api/accounts' import { ConfirmDialog } from '@/components/common/ConfirmDialog' import { TreeGridView } from '@/components/library/TreeGridView' import { TreeListView } from '@/components/library/TreeListView' import { TreeTableView } from '@/components/library/TreeTableView' import { ViewToggle } from '@/components/library/ViewToggle' import { SortDropdown } from '@/components/library/SortDropdown' import { cn, safeGetItem } from '@/lib/utils' import { getSessionResumePath, getTreeNavigatePath } from '@/lib/routing' import { usePermissions } from '@/hooks/usePermissions' import { useUserPreferencesStore } from '@/store/userPreferencesStore' import { useCachedQuota } from '@/hooks/useCachedQuota' import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown' import { Spinner } from '@/components/common/Spinner' import { EmptyState } from '@/components/common/EmptyState' import { toast } from '@/lib/toast' export function TreeLibraryPage() { const { canCreateTrees } = usePermissions() const navigate = useNavigate() const [searchParams] = useSearchParams() const [trees, setTrees] = useState([]) const [categories, setCategories] = useState([]) const [folders, setFolders] = useState([]) const urlCategory = searchParams.get('category') || '' const urlTags = searchParams.get('tags') const [selectedCategoryId, setSelectedCategoryId] = useState(urlCategory) const [selectedTags, setSelectedTags] = useState(urlTags ? urlTags.split(',') : []) const [selectedFolderId, setSelectedFolderId] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [isLoading, setIsLoading] = useState(true) // Read type filter from URL query params (e.g. /trees?type=procedural) const urlType = searchParams.get('type') const [typeFilter, setTypeFilter] = useState<'all' | 'troubleshooting' | 'procedural'>( urlType === 'troubleshooting' || urlType === 'procedural' ? urlType : 'all' ) // Sync filters when URL changes (e.g. clicking sidebar categories/tags or nav sub-items) useEffect(() => { const t = searchParams.get('type') if (t === 'troubleshooting' || t === 'procedural') { setTypeFilter(t) } else { setTypeFilter('all') } setSelectedCategoryId(searchParams.get('category') || '') const tagsParam = searchParams.get('tags') setSelectedTags(tagsParam ? tagsParam.split(',') : []) }, [searchParams]) // View preferences from store const { treeLibraryView, setTreeLibraryView, treeLibrarySortBy, setTreeLibrarySortBy } = useUserPreferencesStore() // Folder modal state const [folderModalOpen, setFolderModalOpen] = useState(false) const [editingFolder, setEditingFolder] = useState(null) const [newFolderParentId, setNewFolderParentId] = useState(null) // Delete confirmation state const [treeToDelete, setTreeToDelete] = useState(null) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [isDeleting, setIsDeleting] = useState(false) // Fork modal state const [forkTarget, setForkTarget] = useState(null) // Import/Export modal state const [showImportModal, setShowImportModal] = useState(false) const [exportTarget, setExportTarget] = useState(null) // Prepare session modal state const [prepareTarget, setPrepareTarget] = useState<{ tree: TreeListItem intakeFields: IntakeFormField[] teamMembers: { id: string; name: string; email: string }[] } | null>(null) // AI builder state const { aiEnabled } = useCachedQuota() // Repeat Last Session const lastSessionData = (() => { const raw = safeGetItem('last-session') if (!raw) return null try { return JSON.parse(raw) as { tree_id: string; tree_name: string; client_name: string; ticket_number: string; tree_type?: string } } catch { return null } })() // Incomplete sessions for auto-recovery const [incompleteSessions, setIncompleteSessions] = useState([]) const [dismissedSessionIds, setDismissedSessionIds] = useState>(() => { try { const raw = sessionStorage.getItem('dismissed-sessions') return raw ? new Set(JSON.parse(raw) as string[]) : new Set() } catch { return new Set() } }) const loadFolders = useCallback(async () => { try { const foldersData = await foldersApi.list() setFolders(foldersData) } catch (err) { console.error('Failed to load folders:', err) } }, []) // Load incomplete sessions on mount useEffect(() => { sessionsApi.list({ completed: false, size: 5 }) .then(setIncompleteSessions) .catch((err) => console.error('Failed to load incomplete sessions:', err)) }, []) const dismissSession = (sessionId: string) => { const next = new Set(dismissedSessionIds) next.add(sessionId) setDismissedSessionIds(next) try { sessionStorage.setItem('dismissed-sessions', JSON.stringify([...next])) } catch { /* */ } } const visibleIncompleteSessions = incompleteSessions.filter(s => !dismissedSessionIds.has(s.id)) const formatTimeAgo = (dateString: string) => { const diff = Math.floor((Date.now() - new Date(dateString).getTime()) / 1000) if (diff < 60) return 'just now' if (diff < 3600) return `${Math.floor(diff / 60)} min ago` if (diff < 86400) return `${Math.floor(diff / 3600)} hr ago` return `${Math.floor(diff / 86400)} days ago` } // Load categories once on mount (they rarely change) useEffect(() => { categoriesApi.list() .then(setCategories) .catch((err) => console.error('Failed to load categories:', err)) }, []) // Request ID ref to discard stale responses when filters change rapidly const loadTreesRequestId = useRef(0) const loadTrees = useCallback(async () => { const requestId = ++loadTreesRequestId.current setIsLoading(true) try { const treesData = await treesApi.list({ tree_type: typeFilter !== 'all' ? typeFilter : undefined, category_id: selectedCategoryId || undefined, tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined, folder_id: selectedFolderId || undefined, sort_by: treeLibrarySortBy, }) if (requestId !== loadTreesRequestId.current) return setTrees(treesData) } catch (err) { if (requestId !== loadTreesRequestId.current) return toast.error('Failed to load flows') console.error(err) } finally { if (requestId === loadTreesRequestId.current) setIsLoading(false) } }, [selectedCategoryId, selectedTags, selectedFolderId, treeLibrarySortBy, typeFilter]) // Load trees when filters change useEffect(() => { loadTrees() }, [loadTrees]) // Load folders on mount and listen for changes useEffect(() => { loadFolders() const handleFolderChange = () => loadFolders() window.addEventListener('folder-changed', handleFolderChange) return () => window.removeEventListener('folder-changed', handleFolderChange) }, [loadFolders]) const handleSearch = async () => { if (!searchQuery.trim()) { loadTrees() return } setIsLoading(true) try { const results = await treesApi.search(searchQuery) setTrees(results) } catch (err) { toast.error('Failed to search flows') console.error(err) } finally { setIsLoading(false) } } const handleTagClick = (tag: string) => { if (!selectedTags.includes(tag)) { setSelectedTags([...selectedTags, tag]) } } const removeTagFilter = (tag: string) => { setSelectedTags(selectedTags.filter((t) => t !== tag)) } const clearAllFilters = () => { setSelectedCategoryId('') setSelectedTags([]) setSelectedFolderId(null) setSearchQuery('') } const handleStartSession = (treeId: string, treeType?: string) => { navigate(getTreeNavigatePath(treeId, treeType)) } const handleCreateFolder = (parentId?: string | null) => { setEditingFolder(null) setNewFolderParentId(parentId || null) setFolderModalOpen(true) } const handleDeleteTree = async () => { if (!treeToDelete) return setIsDeleting(true) try { await treesApi.delete(treeToDelete.id) setTrees(trees.filter((t) => t.id !== treeToDelete.id)) window.dispatchEvent(new Event('folder-changed')) toast.success(`"${treeToDelete.name}" deleted successfully`) } catch (err) { console.error('Failed to delete flow:', err) toast.error('Failed to delete flow') } finally { setIsDeleting(false) setShowDeleteConfirm(false) setTreeToDelete(null) } } const handleForkTree = (treeId: string) => { const tree = trees.find((t) => t.id === treeId) if (tree) setForkTarget(tree) } const handleExportTree = (treeId: string) => { const tree = trees.find((t) => t.id === treeId) if (tree) setExportTarget(tree) } const handlePrepareSession = async (tree: TreeListItem) => { try { const [treeDetail, members] = await Promise.all([ treesApi.get(tree.id), accountsApi.getMembers().catch(() => []), ]) setPrepareTarget({ tree, intakeFields: treeDetail.intake_form || [], teamMembers: members .filter(m => m.is_active) .map(m => ({ id: m.id, name: m.name, email: m.email })), }) } catch { toast.error('Failed to load flow details') } } const hasActiveFilters = selectedCategoryId || selectedTags.length > 0 || searchQuery || selectedFolderId return (

{typeFilter === 'procedural' ? 'Projects' : typeFilter === 'troubleshooting' ? 'Troubleshooting Flows' : 'Flow Library'}

{typeFilter === 'procedural' ? 'Step-by-step projects and runbooks' : typeFilter === 'troubleshooting' ? 'Branching decision flows for troubleshooting' : 'Browse and start troubleshooting flows and projects'}

{canCreateTrees && (
)}
{/* Search and Filter */}
setSearchQuery(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} className={cn( 'flex-1 rounded-md border border-border bg-card px-3 py-2', 'text-foreground placeholder:text-muted-foreground', 'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20' )} />
{/* View Controls */}
{/* Type filter tabs */}
{(['all', 'troubleshooting', 'procedural'] as const).map((t) => ( ))}
{/* Right controls: sort + view toggle */}
{/* Active Filters */} {hasActiveFilters && (
Filters: {selectedFolderId && ( Folder )} {selectedCategoryId && ( {categories.find((c) => c.id === selectedCategoryId)?.name} )} {selectedTags.map((tag) => ( {tag} ))}
)} {/* Incomplete Session Recovery */} {visibleIncompleteSessions.length > 0 && (
{visibleIncompleteSessions.map(s => (

{s.tree_snapshot?.name || 'Unknown tree'}

{s.client_name && `${s.client_name} ยท `} {s.started_at ? `Started ${formatTimeAgo(s.started_at)}` : 'Not started'}

))}
)} {/* Repeat Last Session */} {lastSessionData && (
)} {/* Loading State */} {isLoading ? (
) : trees.length === 0 ? ( (searchQuery || hasActiveFilters) ? ( Clear Filters } /> ) : ( } title="Build your first troubleshooting flow" description="Flows guide your team through proven resolution paths, capturing every decision along the way." action={ canCreateTrees ? ( ) : undefined } learnMoreLink="/guides/creating-flows" /> ) ) : ( <> {treeLibraryView === 'grid' && ( { setTreeToDelete(tree) setShowDeleteConfirm(true) }} onForkTree={handleForkTree} onExportTree={handleExportTree} /> )} {treeLibraryView === 'list' && ( { setTreeToDelete(tree) setShowDeleteConfirm(true) }} onForkTree={handleForkTree} onExportTree={handleExportTree} /> )} {treeLibraryView === 'table' && ( { setTreeToDelete(tree) setShowDeleteConfirm(true) }} onSortChange={(sortBy) => { setTreeLibrarySortBy( sortBy as 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version' ) }} onForkTree={handleForkTree} onExportTree={handleExportTree} /> )} )}
{/* Folder Edit Modal */} { setFolderModalOpen(false) setNewFolderParentId(null) }} onSave={loadTrees} /> {/* Delete Confirmation */} { setShowDeleteConfirm(false) setTreeToDelete(null) }} onConfirm={handleDeleteTree} title="Delete Flow" message={`Are you sure you want to delete "${treeToDelete?.name}"? This action can be undone by an administrator.`} confirmLabel="Delete" confirmVariant="destructive" isLoading={isDeleting} /> {forkTarget && ( setForkTarget(null)} /> )} {exportTarget && ( setExportTarget(null)} /> )} {showImportModal && ( { setShowImportModal(false); loadTrees() }} /> )} {prepareTarget && ( setPrepareTarget(null)} treeId={prepareTarget.tree.id} treeName={prepareTarget.tree.name} intakeFields={prepareTarget.intakeFields} teamMembers={prepareTarget.teamMembers} onPrepared={() => setPrepareTarget(null)} /> )}
) } export default TreeLibraryPage