feat: add PSA ticket export format and Quick-Start landing page

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>
This commit is contained in:
chihlasm
2026-02-08 19:36:51 -05:00
parent f2ae3a51fa
commit 4f8b7dd7ca
11 changed files with 621 additions and 13 deletions

View File

@@ -47,6 +47,7 @@ export function AppLayout() {
}, [mobileMenuOpen, handleKeyDown])
const navItems = [
{ path: '/', label: 'Home' },
{ path: '/trees', label: 'Trees' },
{ path: '/my-trees', label: 'My Trees' },
{ path: '/sessions', label: 'Sessions' },
@@ -70,7 +71,7 @@ export function AppLayout() {
<Menu className="h-5 w-5" />
</button>
<Link to="/trees" className="flex items-center gap-2">
<Link to="/" className="flex items-center gap-2">
<BrandLogo size="sm" />
<BrandWordmark size="sm" />
</Link>
@@ -81,7 +82,7 @@ export function AppLayout() {
to={item.path}
className={cn(
'relative rounded-md px-3 py-2 text-sm font-medium transition-colors',
location.pathname.startsWith(item.path)
(item.path === '/' ? location.pathname === '/' : location.pathname.startsWith(item.path))
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
@@ -137,7 +138,7 @@ export function AppLayout() {
{/* Drawer */}
<nav className="absolute inset-y-0 left-0 w-72 border-r border-border bg-card shadow-xl animate-slide-in-left">
<div className="flex h-16 items-center justify-between border-b border-border px-4">
<Link to="/trees" className="flex items-center gap-2">
<Link to="/" className="flex items-center gap-2">
<BrandLogo size="sm" />
<BrandWordmark size="sm" />
</Link>
@@ -180,7 +181,7 @@ export function AppLayout() {
to={item.path}
className={cn(
'block rounded-md px-3 py-2.5 text-sm font-medium transition-colors',
location.pathname.startsWith(item.path)
(item.path === '/' ? location.pathname === '/' : location.pathname.startsWith(item.path))
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}

View File

@@ -8,7 +8,7 @@ interface ExportPreviewModalProps {
onClose: () => void
content: string
filename: string
format: 'markdown' | 'text' | 'html'
format: 'markdown' | 'text' | 'html' | 'psa'
onDownload: () => void
}

View File

@@ -0,0 +1,253 @@
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

View File

@@ -19,10 +19,11 @@ export function SessionDetailPage() {
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [isExporting, setIsExporting] = useState(false)
const [exportFormat, setExportFormat] = useState<'markdown' | 'text' | 'html'>(defaultExportFormat)
const [exportFormat, setExportFormat] = useState<'markdown' | 'text' | 'html' | 'psa'>(defaultExportFormat)
const [exportContent, setExportContent] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(false)
const [copied, setCopied] = useState(false)
const [copiedPsa, setCopiedPsa] = useState(false)
const [showSaveAsTreeModal, setShowSaveAsTreeModal] = useState(false)
const [isSavingTree, setIsSavingTree] = useState(false)
const [showRatingModal, setShowRatingModal] = useState(false)
@@ -81,7 +82,7 @@ export function SessionDetailPage() {
const getFilename = () => {
if (!session) return 'export.txt'
const ext = exportFormat === 'markdown' ? 'md' : exportFormat === 'html' ? 'html' : 'txt'
const ext = exportFormat === 'markdown' ? 'md' : exportFormat === 'html' ? 'html' : 'txt' // psa and text both use .txt
return `session-${session.ticket_number || session.id}.${ext}`
}
@@ -129,6 +130,27 @@ export function SessionDetailPage() {
}
}
const handleCopyForTicket = async () => {
if (!session) return
try {
const options: SessionExport = {
format: 'psa',
include_timestamps: true,
include_tree_info: true,
}
const content = await sessionsApi.export(session.id, options)
if (content) {
await navigator.clipboard.writeText(content)
setCopiedPsa(true)
setTimeout(() => setCopiedPsa(false), 2000)
toast.success('Copied ticket notes to clipboard')
}
} catch (err) {
console.error('Copy for ticket failed:', err)
toast.error('Failed to copy ticket notes')
}
}
const handleDownload = () => {
if (!exportContent || !session) return
const blob = new Blob([exportContent], { type: 'text/plain' })
@@ -273,6 +295,18 @@ export function SessionDetailPage() {
</button>
)}
{/* Copy for Ticket */}
<button
onClick={handleCopyForTicket}
className={cn(
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
{copiedPsa ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
{copiedPsa ? 'Copied!' : 'Copy for Ticket'}
</button>
{/* Export Controls */}
<div className="flex items-center gap-2">
<select
@@ -287,6 +321,7 @@ export function SessionDetailPage() {
<option value="markdown">Markdown</option>
<option value="text">Plain Text</option>
<option value="html">HTML</option>
<option value="psa">PSA / Ticket Note</option>
</select>
<button
onClick={handleCopy}

View File

@@ -9,6 +9,7 @@ import {
} from '@/pages'
// Lazy load heavy pages for code splitting
const QuickStartPage = lazy(() => import('@/pages/QuickStartPage'))
const TreeLibraryPage = lazy(() => import('@/pages/TreeLibraryPage'))
const MyTreesPage = lazy(() => import('@/pages/MyTreesPage'))
const TreeNavigationPage = lazy(() => import('@/pages/TreeNavigationPage'))
@@ -54,7 +55,11 @@ export const router = createBrowserRouter([
children: [
{
index: true,
element: <Navigate to="/trees" replace />,
element: (
<Suspense fallback={<PageLoader />}>
<QuickStartPage />
</Suspense>
),
},
{
path: 'trees',

View File

@@ -1,7 +1,7 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
type ExportFormat = 'markdown' | 'text' | 'html'
type ExportFormat = 'markdown' | 'text' | 'html' | 'psa'
type TreeLibraryView = 'grid' | 'list' | 'table'
type TreeSortBy = 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'

View File

@@ -67,7 +67,7 @@ export interface SessionUpdate {
}
export interface SessionExport {
format: 'text' | 'markdown' | 'html'
format: 'text' | 'markdown' | 'html' | 'psa'
include_timestamps?: boolean
include_tree_info?: boolean
}