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:
@@ -13,7 +13,7 @@ from app.models.user import User
|
|||||||
from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate, SaveAsTreeRequest, SaveAsTreeResponse
|
from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate, SaveAsTreeRequest, SaveAsTreeResponse
|
||||||
from app.api.deps import get_current_active_user
|
from app.api.deps import get_current_active_user
|
||||||
from app.core.permissions import can_access_tree
|
from app.core.permissions import can_access_tree
|
||||||
from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export
|
from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export
|
||||||
|
|
||||||
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
||||||
|
|
||||||
@@ -288,6 +288,9 @@ async def export_session(
|
|||||||
elif export_options.format == "html":
|
elif export_options.format == "html":
|
||||||
content = generate_html_export(session, export_options)
|
content = generate_html_export(session, export_options)
|
||||||
media_type = "text/html"
|
media_type = "text/html"
|
||||||
|
elif export_options.format == "psa":
|
||||||
|
content = generate_psa_export(session, export_options)
|
||||||
|
media_type = "text/plain"
|
||||||
else: # text
|
else: # text
|
||||||
content = generate_text_export(session, export_options)
|
content = generate_text_export(session, export_options)
|
||||||
media_type = "text/plain"
|
media_type = "text/plain"
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class SessionResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class SessionExport(BaseModel):
|
class SessionExport(BaseModel):
|
||||||
format: str = Field(default="markdown", pattern="^(text|markdown|html)$")
|
format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$")
|
||||||
include_timestamps: bool = True
|
include_timestamps: bool = True
|
||||||
include_tree_info: bool = True
|
include_tree_info: bool = True
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
"""
|
"""
|
||||||
Session export generators for ResolutionFlow.
|
Session export generators for ResolutionFlow.
|
||||||
|
|
||||||
Provides markdown, plain text, and HTML export formatters
|
Provides markdown, plain text, HTML, and PSA/ticket note export formatters
|
||||||
for troubleshooting sessions.
|
for troubleshooting sessions.
|
||||||
"""
|
"""
|
||||||
import html
|
import html
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from app.models.session import Session
|
from app.models.session import Session
|
||||||
from app.schemas.session import SessionExport
|
from app.schemas.session import SessionExport
|
||||||
|
|
||||||
|
|
||||||
|
def _format_duration(started_at: datetime, completed_at: datetime | None) -> str:
|
||||||
|
"""Format duration between two datetimes as human-readable string."""
|
||||||
|
if not completed_at:
|
||||||
|
return "In progress"
|
||||||
|
delta = completed_at - started_at
|
||||||
|
total_seconds = int(delta.total_seconds())
|
||||||
|
if total_seconds < 0:
|
||||||
|
return "0 minutes"
|
||||||
|
hours, remainder = divmod(total_seconds, 3600)
|
||||||
|
minutes = remainder // 60
|
||||||
|
if hours > 0:
|
||||||
|
return f"{hours}h {minutes}m"
|
||||||
|
return f"{minutes} minutes"
|
||||||
|
|
||||||
|
|
||||||
def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
||||||
"""Generate markdown export."""
|
"""Generate markdown export."""
|
||||||
lines = []
|
lines = []
|
||||||
@@ -160,3 +176,65 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
|||||||
|
|
||||||
html_parts.extend(['</body>', '</html>'])
|
html_parts.extend(['</body>', '</html>'])
|
||||||
return "\n".join(html_parts)
|
return "\n".join(html_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_psa_export(session: Session, options: SessionExport) -> str:
|
||||||
|
"""Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools."""
|
||||||
|
lines = []
|
||||||
|
|
||||||
|
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
|
||||||
|
tree_description = session.tree_snapshot.get("description", "")
|
||||||
|
ticket_number = session.ticket_number or "N/A"
|
||||||
|
client_name = session.client_name or "N/A"
|
||||||
|
date_str = session.started_at.strftime("%Y-%m-%d %H:%M")
|
||||||
|
|
||||||
|
lines.append("=== TROUBLESHOOTING NOTES ===")
|
||||||
|
lines.append(f"Ticket: {ticket_number} | Client: {client_name}")
|
||||||
|
lines.append(f"Tree: {tree_name} | Date: {date_str}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Problem section
|
||||||
|
lines.append("--- PROBLEM ---")
|
||||||
|
lines.append(tree_description if tree_description else "No description provided.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Steps taken
|
||||||
|
lines.append("--- STEPS TAKEN ---")
|
||||||
|
if session.decisions:
|
||||||
|
for i, decision in enumerate(session.decisions, 1):
|
||||||
|
question = decision.get("question") or decision.get("action_performed", "Step")
|
||||||
|
answer = decision.get("answer", "")
|
||||||
|
notes = decision.get("notes", "")
|
||||||
|
|
||||||
|
line = f"{i}. {question}"
|
||||||
|
if answer:
|
||||||
|
line += f" -> {answer}"
|
||||||
|
lines.append(line)
|
||||||
|
if notes:
|
||||||
|
lines.append(f" Notes: {notes}")
|
||||||
|
else:
|
||||||
|
lines.append("No steps recorded.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Resolution - last decision answer
|
||||||
|
lines.append("--- RESOLUTION ---")
|
||||||
|
if session.decisions:
|
||||||
|
last_decision = session.decisions[-1]
|
||||||
|
resolution = last_decision.get("answer") or last_decision.get("question", "No resolution recorded")
|
||||||
|
lines.append(resolution)
|
||||||
|
else:
|
||||||
|
lines.append("No resolution recorded.")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Time spent
|
||||||
|
lines.append("--- TIME SPENT ---")
|
||||||
|
duration = _format_duration(session.started_at, session.completed_at)
|
||||||
|
lines.append(f"Duration: {duration}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
# Engineer notes (scratchpad)
|
||||||
|
lines.append("--- ENGINEER NOTES ---")
|
||||||
|
scratchpad = getattr(session, 'scratchpad', '') or ''
|
||||||
|
lines.append(scratchpad.strip() if scratchpad.strip() else "None")
|
||||||
|
|
||||||
|
return "\n".join(lines)
|
||||||
|
|||||||
233
backend/tests/test_psa_export.py
Normal file
233
backend/tests/test_psa_export.py
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
"""Tests for PSA/ticket note export format.
|
||||||
|
|
||||||
|
Covers: all fields present, missing optional fields, duration calculation,
|
||||||
|
incomplete sessions, and edge cases.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.schemas.session import SessionExport
|
||||||
|
from app.services.export_service import generate_psa_export, _format_duration
|
||||||
|
|
||||||
|
|
||||||
|
def _make_session(
|
||||||
|
tree_name="Test Tree",
|
||||||
|
tree_description="A test problem description",
|
||||||
|
ticket_number=None,
|
||||||
|
client_name=None,
|
||||||
|
decisions=None,
|
||||||
|
scratchpad="",
|
||||||
|
started_at=None,
|
||||||
|
completed_at=None,
|
||||||
|
):
|
||||||
|
"""Create a mock session object for PSA export testing."""
|
||||||
|
session = MagicMock()
|
||||||
|
session.tree_snapshot = {"name": tree_name, "description": tree_description}
|
||||||
|
session.ticket_number = ticket_number
|
||||||
|
session.client_name = client_name
|
||||||
|
session.decisions = decisions or []
|
||||||
|
session.scratchpad = scratchpad
|
||||||
|
session.started_at = started_at or datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||||
|
session.completed_at = completed_at or datetime(2026, 1, 15, 11, 30, tzinfo=timezone.utc)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _default_options():
|
||||||
|
return SessionExport(format="psa", include_timestamps=True, include_tree_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPsaExportAllFields:
|
||||||
|
"""Test PSA export with all fields populated."""
|
||||||
|
|
||||||
|
def test_includes_header(self):
|
||||||
|
session = _make_session(
|
||||||
|
ticket_number="TK-5001",
|
||||||
|
client_name="Contoso Corp",
|
||||||
|
)
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "=== TROUBLESHOOTING NOTES ===" in result
|
||||||
|
assert "Ticket: TK-5001" in result
|
||||||
|
assert "Client: Contoso Corp" in result
|
||||||
|
|
||||||
|
def test_includes_tree_name_and_date(self):
|
||||||
|
session = _make_session(tree_name="DNS Troubleshooting")
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "Tree: DNS Troubleshooting" in result
|
||||||
|
assert "Date: 2026-01-15 10:00" in result
|
||||||
|
|
||||||
|
def test_includes_problem_section(self):
|
||||||
|
session = _make_session(tree_description="Client cannot resolve DNS names")
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "--- PROBLEM ---" in result
|
||||||
|
assert "Client cannot resolve DNS names" in result
|
||||||
|
|
||||||
|
def test_includes_steps_taken(self):
|
||||||
|
session = _make_session(decisions=[
|
||||||
|
{"question": "Is the DNS service running?", "answer": "Yes", "notes": "Checked services"},
|
||||||
|
{"question": "Can you ping the DNS server?", "answer": "No"},
|
||||||
|
])
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "--- STEPS TAKEN ---" in result
|
||||||
|
assert "1. Is the DNS service running? -> Yes" in result
|
||||||
|
assert " Notes: Checked services" in result
|
||||||
|
assert "2. Can you ping the DNS server? -> No" in result
|
||||||
|
|
||||||
|
def test_includes_resolution(self):
|
||||||
|
session = _make_session(decisions=[
|
||||||
|
{"question": "Check cable", "answer": "Connected"},
|
||||||
|
{"question": "Restart service", "answer": "Service restarted successfully"},
|
||||||
|
])
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "--- RESOLUTION ---" in result
|
||||||
|
assert "Service restarted successfully" in result
|
||||||
|
|
||||||
|
def test_includes_duration(self):
|
||||||
|
session = _make_session(
|
||||||
|
started_at=datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc),
|
||||||
|
completed_at=datetime(2026, 1, 15, 11, 30, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "--- TIME SPENT ---" in result
|
||||||
|
assert "Duration: 1h 30m" in result
|
||||||
|
|
||||||
|
def test_includes_engineer_notes(self):
|
||||||
|
session = _make_session(scratchpad="Checked firewall rules, port 53 was blocked")
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "--- ENGINEER NOTES ---" in result
|
||||||
|
assert "Checked firewall rules, port 53 was blocked" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestPsaExportMissingFields:
|
||||||
|
"""Test PSA export gracefully handles missing optional fields."""
|
||||||
|
|
||||||
|
def test_missing_ticket_number(self):
|
||||||
|
session = _make_session(ticket_number=None)
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "Ticket: N/A" in result
|
||||||
|
|
||||||
|
def test_missing_client_name(self):
|
||||||
|
session = _make_session(client_name=None)
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "Client: N/A" in result
|
||||||
|
|
||||||
|
def test_missing_description(self):
|
||||||
|
session = _make_session(tree_description="")
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "No description provided." in result
|
||||||
|
|
||||||
|
def test_empty_scratchpad(self):
|
||||||
|
session = _make_session(scratchpad="")
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "--- ENGINEER NOTES ---" in result
|
||||||
|
lines = result.split("\n")
|
||||||
|
notes_idx = lines.index("--- ENGINEER NOTES ---")
|
||||||
|
assert lines[notes_idx + 1] == "None"
|
||||||
|
|
||||||
|
def test_whitespace_only_scratchpad(self):
|
||||||
|
session = _make_session(scratchpad=" \n \n ")
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
lines = result.split("\n")
|
||||||
|
notes_idx = lines.index("--- ENGINEER NOTES ---")
|
||||||
|
assert lines[notes_idx + 1] == "None"
|
||||||
|
|
||||||
|
def test_no_decisions(self):
|
||||||
|
session = _make_session(decisions=[])
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "No steps recorded." in result
|
||||||
|
assert "No resolution recorded." in result
|
||||||
|
|
||||||
|
def test_decision_with_action_performed_fallback(self):
|
||||||
|
session = _make_session(decisions=[
|
||||||
|
{"action_performed": "Restarted the server", "answer": "Done"},
|
||||||
|
])
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "1. Restarted the server -> Done" in result
|
||||||
|
|
||||||
|
def test_decision_without_answer(self):
|
||||||
|
session = _make_session(decisions=[
|
||||||
|
{"question": "Check logs"},
|
||||||
|
])
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "1. Check logs" in result
|
||||||
|
# No arrow when answer is missing
|
||||||
|
assert "->" not in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestDurationCalculation:
|
||||||
|
"""Test the _format_duration helper."""
|
||||||
|
|
||||||
|
def test_hours_and_minutes(self):
|
||||||
|
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2026, 1, 15, 12, 45, tzinfo=timezone.utc)
|
||||||
|
assert _format_duration(start, end) == "2h 45m"
|
||||||
|
|
||||||
|
def test_minutes_only(self):
|
||||||
|
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2026, 1, 15, 10, 25, tzinfo=timezone.utc)
|
||||||
|
assert _format_duration(start, end) == "25 minutes"
|
||||||
|
|
||||||
|
def test_zero_minutes(self):
|
||||||
|
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2026, 1, 15, 10, 0, 30, tzinfo=timezone.utc)
|
||||||
|
assert _format_duration(start, end) == "0 minutes"
|
||||||
|
|
||||||
|
def test_incomplete_session(self):
|
||||||
|
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||||
|
assert _format_duration(start, None) == "In progress"
|
||||||
|
|
||||||
|
def test_exact_hour(self):
|
||||||
|
start = datetime(2026, 1, 15, 10, 0, tzinfo=timezone.utc)
|
||||||
|
end = datetime(2026, 1, 15, 11, 0, tzinfo=timezone.utc)
|
||||||
|
assert _format_duration(start, end) == "1h 0m"
|
||||||
|
|
||||||
|
|
||||||
|
class TestPsaExportIncompleteSession:
|
||||||
|
"""Test PSA export for sessions that are not yet completed."""
|
||||||
|
|
||||||
|
def test_incomplete_session_shows_in_progress_duration(self):
|
||||||
|
session = _make_session(completed_at=None)
|
||||||
|
# Override completed_at to None explicitly
|
||||||
|
session.completed_at = None
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "Duration: In progress" in result
|
||||||
|
|
||||||
|
def test_incomplete_session_with_steps(self):
|
||||||
|
session = _make_session(
|
||||||
|
decisions=[{"question": "First step", "answer": "Done"}],
|
||||||
|
completed_at=None,
|
||||||
|
)
|
||||||
|
session.completed_at = None
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
assert "1. First step -> Done" in result
|
||||||
|
assert "Duration: In progress" in result
|
||||||
|
|
||||||
|
|
||||||
|
class TestPsaExportFormat:
|
||||||
|
"""Test the overall format structure of PSA export."""
|
||||||
|
|
||||||
|
def test_section_order(self):
|
||||||
|
session = _make_session(
|
||||||
|
ticket_number="TK-100",
|
||||||
|
client_name="Acme",
|
||||||
|
decisions=[{"question": "Step 1", "answer": "Yes"}],
|
||||||
|
scratchpad="Some notes",
|
||||||
|
)
|
||||||
|
result = generate_psa_export(session, _default_options())
|
||||||
|
sections = [
|
||||||
|
"=== TROUBLESHOOTING NOTES ===",
|
||||||
|
"--- PROBLEM ---",
|
||||||
|
"--- STEPS TAKEN ---",
|
||||||
|
"--- RESOLUTION ---",
|
||||||
|
"--- TIME SPENT ---",
|
||||||
|
"--- ENGINEER NOTES ---",
|
||||||
|
]
|
||||||
|
positions = [result.index(s) for s in sections]
|
||||||
|
assert positions == sorted(positions), "Sections should appear in the expected order"
|
||||||
|
|
||||||
|
def test_format_validation_accepts_psa(self):
|
||||||
|
"""Verify the schema accepts 'psa' as a valid format."""
|
||||||
|
export = SessionExport(format="psa")
|
||||||
|
assert export.format == "psa"
|
||||||
@@ -47,6 +47,7 @@ export function AppLayout() {
|
|||||||
}, [mobileMenuOpen, handleKeyDown])
|
}, [mobileMenuOpen, handleKeyDown])
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
|
{ path: '/', label: 'Home' },
|
||||||
{ path: '/trees', label: 'Trees' },
|
{ path: '/trees', label: 'Trees' },
|
||||||
{ path: '/my-trees', label: 'My Trees' },
|
{ path: '/my-trees', label: 'My Trees' },
|
||||||
{ path: '/sessions', label: 'Sessions' },
|
{ path: '/sessions', label: 'Sessions' },
|
||||||
@@ -70,7 +71,7 @@ export function AppLayout() {
|
|||||||
<Menu className="h-5 w-5" />
|
<Menu className="h-5 w-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Link to="/trees" className="flex items-center gap-2">
|
<Link to="/" className="flex items-center gap-2">
|
||||||
<BrandLogo size="sm" />
|
<BrandLogo size="sm" />
|
||||||
<BrandWordmark size="sm" />
|
<BrandWordmark size="sm" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -81,7 +82,7 @@ export function AppLayout() {
|
|||||||
to={item.path}
|
to={item.path}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
'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'
|
? 'bg-accent text-accent-foreground'
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||||
)}
|
)}
|
||||||
@@ -137,7 +138,7 @@ export function AppLayout() {
|
|||||||
{/* Drawer */}
|
{/* Drawer */}
|
||||||
<nav className="absolute inset-y-0 left-0 w-72 border-r border-border bg-card shadow-xl animate-slide-in-left">
|
<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">
|
<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" />
|
<BrandLogo size="sm" />
|
||||||
<BrandWordmark size="sm" />
|
<BrandWordmark size="sm" />
|
||||||
</Link>
|
</Link>
|
||||||
@@ -180,7 +181,7 @@ export function AppLayout() {
|
|||||||
to={item.path}
|
to={item.path}
|
||||||
className={cn(
|
className={cn(
|
||||||
'block rounded-md px-3 py-2.5 text-sm font-medium transition-colors',
|
'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'
|
? 'bg-accent text-accent-foreground'
|
||||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ interface ExportPreviewModalProps {
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
content: string
|
content: string
|
||||||
filename: string
|
filename: string
|
||||||
format: 'markdown' | 'text' | 'html'
|
format: 'markdown' | 'text' | 'html' | 'psa'
|
||||||
onDownload: () => void
|
onDownload: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
253
frontend/src/pages/QuickStartPage.tsx
Normal file
253
frontend/src/pages/QuickStartPage.tsx
Normal 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
|
||||||
@@ -19,10 +19,11 @@ export function SessionDetailPage() {
|
|||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [isExporting, setIsExporting] = useState(false)
|
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 [exportContent, setExportContent] = useState<string | null>(null)
|
||||||
const [showPreview, setShowPreview] = useState(false)
|
const [showPreview, setShowPreview] = useState(false)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [copiedPsa, setCopiedPsa] = useState(false)
|
||||||
const [showSaveAsTreeModal, setShowSaveAsTreeModal] = useState(false)
|
const [showSaveAsTreeModal, setShowSaveAsTreeModal] = useState(false)
|
||||||
const [isSavingTree, setIsSavingTree] = useState(false)
|
const [isSavingTree, setIsSavingTree] = useState(false)
|
||||||
const [showRatingModal, setShowRatingModal] = useState(false)
|
const [showRatingModal, setShowRatingModal] = useState(false)
|
||||||
@@ -81,7 +82,7 @@ export function SessionDetailPage() {
|
|||||||
|
|
||||||
const getFilename = () => {
|
const getFilename = () => {
|
||||||
if (!session) return 'export.txt'
|
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}`
|
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 = () => {
|
const handleDownload = () => {
|
||||||
if (!exportContent || !session) return
|
if (!exportContent || !session) return
|
||||||
const blob = new Blob([exportContent], { type: 'text/plain' })
|
const blob = new Blob([exportContent], { type: 'text/plain' })
|
||||||
@@ -273,6 +295,18 @@ export function SessionDetailPage() {
|
|||||||
</button>
|
</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 */}
|
{/* Export Controls */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
@@ -287,6 +321,7 @@ export function SessionDetailPage() {
|
|||||||
<option value="markdown">Markdown</option>
|
<option value="markdown">Markdown</option>
|
||||||
<option value="text">Plain Text</option>
|
<option value="text">Plain Text</option>
|
||||||
<option value="html">HTML</option>
|
<option value="html">HTML</option>
|
||||||
|
<option value="psa">PSA / Ticket Note</option>
|
||||||
</select>
|
</select>
|
||||||
<button
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '@/pages'
|
} from '@/pages'
|
||||||
|
|
||||||
// Lazy load heavy pages for code splitting
|
// Lazy load heavy pages for code splitting
|
||||||
|
const QuickStartPage = lazy(() => import('@/pages/QuickStartPage'))
|
||||||
const TreeLibraryPage = lazy(() => import('@/pages/TreeLibraryPage'))
|
const TreeLibraryPage = lazy(() => import('@/pages/TreeLibraryPage'))
|
||||||
const MyTreesPage = lazy(() => import('@/pages/MyTreesPage'))
|
const MyTreesPage = lazy(() => import('@/pages/MyTreesPage'))
|
||||||
const TreeNavigationPage = lazy(() => import('@/pages/TreeNavigationPage'))
|
const TreeNavigationPage = lazy(() => import('@/pages/TreeNavigationPage'))
|
||||||
@@ -54,7 +55,11 @@ export const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
index: true,
|
index: true,
|
||||||
element: <Navigate to="/trees" replace />,
|
element: (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<QuickStartPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'trees',
|
path: 'trees',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
type ExportFormat = 'markdown' | 'text' | 'html'
|
type ExportFormat = 'markdown' | 'text' | 'html' | 'psa'
|
||||||
type TreeLibraryView = 'grid' | 'list' | 'table'
|
type TreeLibraryView = 'grid' | 'list' | 'table'
|
||||||
type TreeSortBy = 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
|
type TreeSortBy = 'usage_count' | 'updated_at' | 'created_at' | 'name' | 'name_desc' | 'version'
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export interface SessionUpdate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionExport {
|
export interface SessionExport {
|
||||||
format: 'text' | 'markdown' | 'html'
|
format: 'text' | 'markdown' | 'html' | 'psa'
|
||||||
include_timestamps?: boolean
|
include_timestamps?: boolean
|
||||||
include_tree_info?: boolean
|
include_tree_info?: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user