PSA Export: - New "PSA / Ticket Note" export format optimized for ConnectWise - Structured output: Problem, Steps Taken, Resolution, Time Spent, Notes - Prominent "Copy for Ticket" button on session detail page - 24 unit tests for PSA export generator Quick-Start Landing: - New default landing page with search-first UX - Auto-focused search bar with debounced tree search - "Continue Session" cards for active sessions - "Recent Trees" section from session history - Home nav item and logo links updated Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
254 lines
9.3 KiB
TypeScript
254 lines
9.3 KiB
TypeScript
import { useState, useEffect, useRef } from 'react'
|
|
import { useNavigate, Link } from 'react-router-dom'
|
|
import { Search, Clock, ArrowRight, Play, Loader2 } from 'lucide-react'
|
|
import { treesApi } from '@/api/trees'
|
|
import { sessionsApi } from '@/api/sessions'
|
|
import type { TreeListItem } from '@/types'
|
|
import type { Session } from '@/types/session'
|
|
import { cn } from '@/lib/utils'
|
|
|
|
function timeAgo(dateStr: string): string {
|
|
const now = Date.now()
|
|
const then = new Date(dateStr).getTime()
|
|
const diffMs = now - then
|
|
const minutes = Math.floor(diffMs / 60000)
|
|
if (minutes < 1) return 'just now'
|
|
if (minutes < 60) return `${minutes}m ago`
|
|
const hours = Math.floor(minutes / 60)
|
|
if (hours < 24) return `${hours}h ago`
|
|
const days = Math.floor(hours / 24)
|
|
return `${days}d ago`
|
|
}
|
|
|
|
export function QuickStartPage() {
|
|
const navigate = useNavigate()
|
|
const [query, setQuery] = useState('')
|
|
const [searchResults, setSearchResults] = useState<TreeListItem[]>([])
|
|
const [isSearching, setIsSearching] = useState(false)
|
|
const [showResults, setShowResults] = useState(false)
|
|
const [activeSessions, setActiveSessions] = useState<Session[]>([])
|
|
const [recentTrees, setRecentTrees] = useState<{ tree_id: string; name: string; lastUsed: string }[]>([])
|
|
const [isLoading, setIsLoading] = useState(true)
|
|
const searchRef = useRef<HTMLDivElement>(null)
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
// Load sessions on mount
|
|
useEffect(() => {
|
|
async function loadData() {
|
|
try {
|
|
const [active, recent] = await Promise.all([
|
|
sessionsApi.list({ completed: false, size: 5 }),
|
|
sessionsApi.list({ size: 10 }),
|
|
])
|
|
setActiveSessions(active.slice(0, 3))
|
|
|
|
// Deduplicate recent sessions by tree_id, max 5
|
|
const seen = new Set<string>()
|
|
const deduped: { tree_id: string; name: string; lastUsed: string }[] = []
|
|
for (const s of recent) {
|
|
if (!seen.has(s.tree_id) && deduped.length < 5) {
|
|
seen.add(s.tree_id)
|
|
deduped.push({
|
|
tree_id: s.tree_id,
|
|
name: s.tree_snapshot?.name || 'Unnamed Tree',
|
|
lastUsed: s.started_at,
|
|
})
|
|
}
|
|
}
|
|
setRecentTrees(deduped)
|
|
} catch (err) {
|
|
console.error('Failed to load sessions:', err)
|
|
} finally {
|
|
setIsLoading(false)
|
|
}
|
|
}
|
|
loadData()
|
|
}, [])
|
|
|
|
// Debounced search
|
|
useEffect(() => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
|
|
if (query.length < 2) {
|
|
setSearchResults([])
|
|
setShowResults(false)
|
|
setIsSearching(false)
|
|
return
|
|
}
|
|
|
|
setIsSearching(true)
|
|
setShowResults(true)
|
|
debounceRef.current = setTimeout(async () => {
|
|
try {
|
|
const results = await treesApi.search(query, 8)
|
|
setSearchResults(results)
|
|
} catch (err) {
|
|
console.error('Search failed:', err)
|
|
setSearchResults([])
|
|
} finally {
|
|
setIsSearching(false)
|
|
}
|
|
}, 300)
|
|
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
}
|
|
}, [query])
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
function handleClick(e: MouseEvent) {
|
|
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
|
|
setShowResults(false)
|
|
}
|
|
}
|
|
document.addEventListener('mousedown', handleClick)
|
|
return () => document.removeEventListener('mousedown', handleClick)
|
|
}, [])
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 py-8">
|
|
{/* Hero Section */}
|
|
<div className="mx-auto max-w-2xl text-center">
|
|
<h1 className="font-heading text-3xl font-bold text-foreground">
|
|
What are you troubleshooting?
|
|
</h1>
|
|
<div ref={searchRef} className="relative mt-6">
|
|
<div className="relative">
|
|
<Search className="absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-muted-foreground" />
|
|
<input
|
|
type="text"
|
|
autoFocus
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
onFocus={() => query.length >= 2 && setShowResults(true)}
|
|
placeholder="Paste ticket subject or search for a tree..."
|
|
className={cn(
|
|
'w-full rounded-lg border border-border bg-card py-3 pl-12 pr-4 text-lg',
|
|
'text-foreground placeholder:text-muted-foreground',
|
|
'focus:outline-none focus:ring-2 focus:ring-primary/50'
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Search Results Dropdown */}
|
|
{showResults && (
|
|
<div className="absolute z-10 mt-1 w-full rounded-lg border border-border bg-card shadow-lg">
|
|
{isSearching ? (
|
|
<div className="flex items-center justify-center py-6">
|
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
|
</div>
|
|
) : searchResults.length === 0 ? (
|
|
<div className="px-4 py-6 text-center text-sm text-muted-foreground">
|
|
No results found
|
|
</div>
|
|
) : (
|
|
<ul className="max-h-80 overflow-y-auto py-1">
|
|
{searchResults.map((tree) => (
|
|
<li key={tree.id}>
|
|
<button
|
|
onClick={() => navigate(`/trees/${tree.id}/navigate`)}
|
|
className="w-full px-4 py-3 text-left transition-colors hover:bg-accent"
|
|
>
|
|
<div className="text-sm font-medium text-foreground">
|
|
{tree.name}
|
|
</div>
|
|
{tree.description && (
|
|
<div className="mt-0.5 line-clamp-1 text-xs text-muted-foreground">
|
|
{tree.description}
|
|
</div>
|
|
)}
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Continue Session Section */}
|
|
{activeSessions.length > 0 && (
|
|
<div className="mx-auto mt-12 max-w-4xl">
|
|
<h2 className="font-heading text-lg font-semibold text-foreground">
|
|
Continue Session
|
|
</h2>
|
|
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{activeSessions.map((session) => (
|
|
<button
|
|
key={session.id}
|
|
onClick={() =>
|
|
navigate(`/trees/${session.tree_id}/navigate`, {
|
|
state: { sessionId: session.id },
|
|
})
|
|
}
|
|
className="rounded-lg border border-border bg-card p-4 text-left transition-colors hover:border-primary/50 hover:bg-accent"
|
|
>
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="truncate text-sm font-medium text-foreground">
|
|
{session.tree_snapshot?.name || 'Unnamed Tree'}
|
|
</div>
|
|
{(session.ticket_number || session.client_name) && (
|
|
<div className="mt-1 truncate text-xs text-muted-foreground">
|
|
{[session.ticket_number, session.client_name]
|
|
.filter(Boolean)
|
|
.join(' - ')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<Play className="mt-0.5 h-4 w-4 flex-shrink-0 text-primary" />
|
|
</div>
|
|
<div className="mt-2 flex items-center gap-1 text-xs text-muted-foreground">
|
|
<Clock className="h-3 w-3" />
|
|
<span>{timeAgo(session.started_at)}</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Recent Trees Section */}
|
|
{!isLoading && recentTrees.length > 0 && (
|
|
<div className="mx-auto mt-10 max-w-4xl">
|
|
<h2 className="font-heading text-lg font-semibold text-foreground">
|
|
Recent Trees
|
|
</h2>
|
|
<div className="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
|
{recentTrees.map((tree) => (
|
|
<button
|
|
key={tree.tree_id}
|
|
onClick={() => navigate(`/trees/${tree.tree_id}/navigate`)}
|
|
className="rounded-lg border border-border bg-card p-4 text-left transition-colors hover:border-primary/50 hover:bg-accent"
|
|
>
|
|
<div className="truncate text-sm font-medium text-foreground">
|
|
{tree.name}
|
|
</div>
|
|
<div className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
|
<Clock className="h-3 w-3" />
|
|
<span>{timeAgo(tree.lastUsed)}</span>
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Footer */}
|
|
<div className="mx-auto mt-12 max-w-4xl text-center">
|
|
<Link
|
|
to="/trees"
|
|
className="inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
|
|
>
|
|
Browse All Trees
|
|
<ArrowRight className="h-4 w-4" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default QuickStartPage
|