Files
resolutionflow/frontend/src/pages/TreeLibraryPage.tsx
2026-03-23 04:48:23 +00:00

645 lines
24 KiB
TypeScript

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<TreeListItem[]>([])
const [categories, setCategories] = useState<CategoryListItem[]>([])
const [folders, setFolders] = useState<FolderListItem[]>([])
const urlCategory = searchParams.get('category') || ''
const urlTags = searchParams.get('tags')
const [selectedCategoryId, setSelectedCategoryId] = useState<string>(urlCategory)
const [selectedTags, setSelectedTags] = useState<string[]>(urlTags ? urlTags.split(',') : [])
const [selectedFolderId, setSelectedFolderId] = useState<string | null>(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<FolderListItem | null>(null)
const [newFolderParentId, setNewFolderParentId] = useState<string | null>(null)
// Delete confirmation state
const [treeToDelete, setTreeToDelete] = useState<TreeListItem | null>(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
// Fork modal state
const [forkTarget, setForkTarget] = useState<TreeListItem | null>(null)
// Import/Export modal state
const [showImportModal, setShowImportModal] = useState(false)
const [exportTarget, setExportTarget] = useState<TreeListItem | null>(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<Session[]>([])
const [dismissedSessionIds, setDismissedSessionIds] = useState<Set<string>>(() => {
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 (
<div className="overflow-y-auto h-full">
<PageMeta title="Flow Library" />
<div className="min-h-full">
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
<div className="mb-6 flex flex-col gap-4 sm:mb-8 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">
{typeFilter === 'procedural' ? 'Projects' : typeFilter === 'troubleshooting' ? 'Troubleshooting Flows' : 'Flow Library'}
</h1>
<p className="mt-2 text-muted-foreground">
{typeFilter === 'procedural'
? 'Step-by-step projects and runbooks'
: typeFilter === 'troubleshooting'
? 'Branching decision flows for troubleshooting'
: 'Browse and start troubleshooting flows and projects'}
</p>
</div>
{canCreateTrees && (
<div className="flex items-center gap-2">
<Button
variant="secondary"
onClick={() => setShowImportModal(true)}
>
<FileUp className="h-4 w-4" />
Import
</Button>
<CreateFlowDropdown
aiEnabled={aiEnabled}
label="Create New"
/>
</div>
)}
</div>
{/* Search and Filter */}
<div className="mb-4 space-y-4">
<div className="flex flex-col gap-4 sm:flex-row">
<div className="flex flex-1 gap-2">
<input
type="text"
placeholder="Search flows..."
value={searchQuery}
onChange={(e) => 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'
)}
/>
<Button onClick={handleSearch}>
Search
</Button>
</div>
<select
value={selectedCategoryId}
onChange={(e) => setSelectedCategoryId(e.target.value)}
aria-label="Filter by category"
className={cn(
'rounded-md border border-border bg-card px-3 py-2',
'text-foreground focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
)}
>
<option value="">All Categories</option>
{categories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name} ({cat.tree_count})
</option>
))}
</select>
</div>
{/* View Controls */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* Type filter tabs */}
<div className="flex rounded-lg border border-border p-0.5">
{(['all', 'troubleshooting', 'procedural'] as const).map((t) => (
<button
key={t}
onClick={() => setTypeFilter(t)}
className={cn(
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
typeFilter === t
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:text-foreground'
)}
>
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : 'Projects'}
</button>
))}
</div>
{/* Right controls: sort + view toggle */}
<div className="flex items-center gap-2">
<SortDropdown value={treeLibrarySortBy} onChange={setTreeLibrarySortBy} />
<ViewToggle view={treeLibraryView} onChange={setTreeLibraryView} />
</div>
</div>
</div>
{/* Active Filters */}
{hasActiveFilters && (
<div className="mb-6 flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">Filters:</span>
{selectedFolderId && (
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-3 py-1 text-sm text-foreground">
Folder
<button
onClick={() => setSelectedFolderId(null)}
className="rounded-full p-0.5 hover:bg-accent"
>
<X className="h-3 w-3" />
</button>
</span>
)}
{selectedCategoryId && (
<span className="inline-flex items-center gap-1 rounded-full bg-accent px-3 py-1 text-sm text-foreground">
{categories.find((c) => c.id === selectedCategoryId)?.name}
<button
onClick={() => setSelectedCategoryId('')}
className="rounded-full p-0.5 hover:bg-accent"
>
<X className="h-3 w-3" />
</button>
</span>
)}
{selectedTags.map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1 rounded-full bg-accent px-3 py-1 text-sm text-foreground"
>
{tag}
<button
onClick={() => removeTagFilter(tag)}
className="rounded-full p-0.5 hover:bg-accent"
>
<X className="h-3 w-3" />
</button>
</span>
))}
<button
onClick={clearAllFilters}
className="text-sm text-muted-foreground hover:text-foreground"
>
Clear all
</button>
</div>
)}
{/* Incomplete Session Recovery */}
{visibleIncompleteSessions.length > 0 && (
<div className="mb-6 space-y-2">
{visibleIncompleteSessions.map(s => (
<div key={s.id} className="bg-card border border-border flex items-center justify-between rounded-xl p-4">
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">
{s.tree_snapshot?.name || 'Unknown tree'}
</p>
<p className="text-sm text-muted-foreground">
{s.client_name && `${s.client_name} · `}
{s.started_at ? `Started ${formatTimeAgo(s.started_at)}` : 'Not started'}
</p>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
onClick={() => navigate(getSessionResumePath(s.tree_id, s.tree_snapshot?.tree_type), { state: { sessionId: s.id } })}
>
<Play className="h-3.5 w-3.5" />
Resume
</Button>
<button
onClick={() => dismissSession(s.id)}
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
))}
</div>
)}
{/* Repeat Last Session */}
{lastSessionData && (
<div className="mb-6">
<button
onClick={() => navigate(getSessionResumePath(lastSessionData.tree_id, lastSessionData.tree_type), {
state: { prefillClientName: lastSessionData.client_name, prefillTicketNumber: lastSessionData.ticket_number },
})}
className={cn(
'flex items-center gap-2 rounded-lg border border-border px-4 py-2.5 text-sm text-muted-foreground',
'hover:border-border hover:bg-accent hover:text-foreground'
)}
>
<RotateCcw className="h-4 w-4" />
Repeat: {lastSessionData.tree_name}
{lastSessionData.client_name && ` (${lastSessionData.client_name})`}
</button>
</div>
)}
{/* Loading State */}
{isLoading ? (
<div className="flex justify-center py-12">
<Spinner />
</div>
) : trees.length === 0 ? (
(searchQuery || hasActiveFilters) ? (
<EmptyState
title="No flows match your filters"
description="Try adjusting your search or filters."
action={
<Button variant="secondary" onClick={clearAllFilters}>
Clear Filters
</Button>
}
/>
) : (
<EmptyState
illustration={<FlowIllustration />}
title="Build your first troubleshooting flow"
description="Flows guide your team through proven resolution paths, capturing every decision along the way."
action={
canCreateTrees ? (
<CreateFlowDropdown aiEnabled={aiEnabled} label="Create a Flow" />
) : undefined
}
learnMoreLink="/guides/creating-flows"
/>
)
) : (
<>
{treeLibraryView === 'grid' && (
<TreeGridView
trees={trees}
onStartSession={handleStartSession}
onPrepareSession={handlePrepareSession}
onTagClick={handleTagClick}
onFolderCreated={handleCreateFolder}
onDeleteTree={(tree) => {
setTreeToDelete(tree)
setShowDeleteConfirm(true)
}}
onForkTree={handleForkTree}
onExportTree={handleExportTree}
/>
)}
{treeLibraryView === 'list' && (
<TreeListView
trees={trees}
onStartSession={handleStartSession}
onPrepareSession={handlePrepareSession}
onTagClick={handleTagClick}
onFolderCreated={handleCreateFolder}
onDeleteTree={(tree) => {
setTreeToDelete(tree)
setShowDeleteConfirm(true)
}}
onForkTree={handleForkTree}
onExportTree={handleExportTree}
/>
)}
{treeLibraryView === 'table' && (
<TreeTableView
trees={trees}
onStartSession={handleStartSession}
onPrepareSession={handlePrepareSession}
onTagClick={handleTagClick}
onFolderCreated={handleCreateFolder}
onDeleteTree={(tree) => {
setTreeToDelete(tree)
setShowDeleteConfirm(true)
}}
onSortChange={(sortBy) => {
setTreeLibrarySortBy(
sortBy as 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
)
}}
onForkTree={handleForkTree}
onExportTree={handleExportTree}
/>
)}
</>
)}
</div>
{/* Folder Edit Modal */}
<FolderEditModal
folder={editingFolder}
parentId={newFolderParentId}
folders={folders}
isOpen={folderModalOpen}
onClose={() => {
setFolderModalOpen(false)
setNewFolderParentId(null)
}}
onSave={loadTrees}
/>
{/* Delete Confirmation */}
<ConfirmDialog
isOpen={showDeleteConfirm}
onClose={() => {
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 && (
<ForkModal
treeId={forkTarget.id}
treeName={forkTarget.name}
onClose={() => setForkTarget(null)}
/>
)}
{exportTarget && (
<ExportFlowModal
treeId={exportTarget.id}
treeName={exportTarget.name}
onClose={() => setExportTarget(null)}
/>
)}
{showImportModal && (
<ImportFlowModal
onClose={() => { setShowImportModal(false); loadTrees() }}
/>
)}
{prepareTarget && (
<PrepareSessionModal
isOpen
onClose={() => setPrepareTarget(null)}
treeId={prepareTarget.tree.id}
treeName={prepareTarget.tree.name}
intakeFields={prepareTarget.intakeFields}
teamMembers={prepareTarget.teamMembers}
onPrepared={() => setPrepareTarget(null)}
/>
)}
</div>
</div>
)
}
export default TreeLibraryPage