feat: empty states, onboarding checklist, PDF exports, and supporting data #114
6
.gitignore
vendored
6
.gitignore
vendored
@@ -220,6 +220,12 @@ frontend/playwright-report/
|
||||
frontend/test-results/
|
||||
frontend/e2e/.auth/
|
||||
|
||||
# Superpowers brainstorming mockups
|
||||
.superpowers/
|
||||
|
||||
# Test results (root level)
|
||||
test-results/
|
||||
|
||||
# Railway CLI (local tooling)
|
||||
node_modules/
|
||||
package.json
|
||||
|
||||
@@ -6,6 +6,10 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
libpango1.0-dev \
|
||||
libcairo2-dev \
|
||||
libgdk-pixbuf-2.0-dev \
|
||||
libffi-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python dependencies
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add onboarding and branding columns
|
||||
|
||||
Revision ID: 21ddb46ddd05
|
||||
Revises: 061
|
||||
Create Date: 2026-03-16 23:30:48.910485
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '21ddb46ddd05'
|
||||
down_revision: Union[str, None] = '061'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Users: onboarding + branding columns
|
||||
op.add_column('users', sa.Column('onboarding_dismissed', sa.Boolean(), server_default='false', nullable=False))
|
||||
op.add_column('users', sa.Column('logo_data', sa.Text(), nullable=True))
|
||||
op.add_column('users', sa.Column('logo_content_type', sa.String(length=50), nullable=True))
|
||||
op.add_column('users', sa.Column('company_display_name', sa.String(length=255), nullable=True))
|
||||
|
||||
# Teams: branding columns
|
||||
op.add_column('teams', sa.Column('logo_data', sa.Text(), nullable=True))
|
||||
op.add_column('teams', sa.Column('logo_content_type', sa.String(length=50), nullable=True))
|
||||
op.add_column('teams', sa.Column('company_display_name', sa.String(length=255), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('teams', 'company_display_name')
|
||||
op.drop_column('teams', 'logo_content_type')
|
||||
op.drop_column('teams', 'logo_data')
|
||||
op.drop_column('users', 'company_display_name')
|
||||
op.drop_column('users', 'logo_content_type')
|
||||
op.drop_column('users', 'logo_data')
|
||||
op.drop_column('users', 'onboarding_dismissed')
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add session_supporting_data table
|
||||
|
||||
Revision ID: ee98013dd18c
|
||||
Revises: 21ddb46ddd05
|
||||
Create Date: 2026-03-16 23:31:43.483511
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ee98013dd18c'
|
||||
down_revision: Union[str, None] = '21ddb46ddd05'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table('session_supporting_data',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('session_id', sa.UUID(), nullable=False),
|
||||
sa.Column('label', sa.String(length=255), nullable=False),
|
||||
sa.Column('data_type', sa.Enum('text_snippet', 'screenshot', name='supporting_data_type'), nullable=False),
|
||||
sa.Column('content', sa.Text(), nullable=False),
|
||||
sa.Column('content_type', sa.String(length=50), nullable=True),
|
||||
sa.Column('sort_order', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
|
||||
sa.ForeignKeyConstraint(['session_id'], ['sessions.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index(op.f('ix_session_supporting_data_session_id'), 'session_supporting_data', ['session_id'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f('ix_session_supporting_data_session_id'), table_name='session_supporting_data')
|
||||
op.drop_table('session_supporting_data')
|
||||
op.execute("DROP TYPE IF EXISTS supporting_data_type")
|
||||
130
backend/app/api/endpoints/branding.py
Normal file
130
backend/app/api/endpoints/branding.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""Team branding endpoints — logo upload and company display name."""
|
||||
|
||||
import base64
|
||||
from typing import Annotated, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user, get_db
|
||||
from app.models.team import Team
|
||||
from app.models.user import User
|
||||
from app.schemas.branding import BrandingResponse
|
||||
|
||||
router = APIRouter(prefix="/teams", tags=["branding"])
|
||||
|
||||
ALLOWED_CONTENT_TYPES = {"image/png", "image/jpeg", "image/svg+xml"}
|
||||
MAX_LOGO_SIZE = 2 * 1024 * 1024 # 2MB
|
||||
|
||||
|
||||
def _require_team_member(user: User, team_id: UUID) -> None:
|
||||
"""Ensure the user belongs to the given team (or is super admin)."""
|
||||
if user.is_super_admin:
|
||||
return
|
||||
if user.team_id != team_id:
|
||||
raise HTTPException(status_code=403, detail="Not a member of this team")
|
||||
|
||||
|
||||
def _require_team_admin(user: User, team_id: UUID) -> None:
|
||||
"""Ensure the user is a team admin for the given team (or is super admin)."""
|
||||
if user.is_super_admin:
|
||||
return
|
||||
if not user.is_team_admin or user.team_id != team_id:
|
||||
raise HTTPException(status_code=403, detail="Team admin required")
|
||||
|
||||
|
||||
@router.get("/{team_id}/branding", response_model=BrandingResponse)
|
||||
async def get_branding(
|
||||
team_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> BrandingResponse:
|
||||
"""Retrieve branding info for a team. Any team member can read."""
|
||||
_require_team_member(current_user, team_id)
|
||||
|
||||
result = await db.execute(select(Team).where(Team.id == team_id))
|
||||
team = result.scalar_one_or_none()
|
||||
if not team:
|
||||
raise HTTPException(status_code=404, detail="Team not found")
|
||||
|
||||
return BrandingResponse(
|
||||
company_display_name=team.company_display_name,
|
||||
logo_content_type=team.logo_content_type,
|
||||
has_logo=team.logo_data is not None,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{team_id}/branding", response_model=BrandingResponse)
|
||||
async def update_branding(
|
||||
team_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
logo: Annotated[Optional[UploadFile], File()] = None,
|
||||
company_display_name: Annotated[Optional[str], Form()] = None,
|
||||
) -> BrandingResponse:
|
||||
"""Upload logo and/or update company display name. Team admin only."""
|
||||
_require_team_admin(current_user, team_id)
|
||||
|
||||
result = await db.execute(select(Team).where(Team.id == team_id))
|
||||
team = result.scalar_one_or_none()
|
||||
if not team:
|
||||
raise HTTPException(status_code=404, detail="Team not found")
|
||||
|
||||
# Handle logo upload
|
||||
if logo is not None:
|
||||
if logo.content_type not in ALLOWED_CONTENT_TYPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid content type '{logo.content_type}'. Allowed: {', '.join(sorted(ALLOWED_CONTENT_TYPES))}",
|
||||
)
|
||||
|
||||
raw_bytes = await logo.read()
|
||||
if len(raw_bytes) > MAX_LOGO_SIZE:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Logo exceeds maximum size of {MAX_LOGO_SIZE // (1024 * 1024)}MB",
|
||||
)
|
||||
|
||||
team.logo_data = base64.b64encode(raw_bytes).decode("utf-8")
|
||||
team.logo_content_type = logo.content_type
|
||||
|
||||
# Handle display name update
|
||||
if company_display_name is not None:
|
||||
team.company_display_name = company_display_name
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(team)
|
||||
|
||||
return BrandingResponse(
|
||||
company_display_name=team.company_display_name,
|
||||
logo_content_type=team.logo_content_type,
|
||||
has_logo=team.logo_data is not None,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{team_id}/branding/logo", status_code=200, response_model=BrandingResponse)
|
||||
async def delete_logo(
|
||||
team_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> BrandingResponse:
|
||||
"""Remove the team logo. Team admin only."""
|
||||
_require_team_admin(current_user, team_id)
|
||||
|
||||
result = await db.execute(select(Team).where(Team.id == team_id))
|
||||
team = result.scalar_one_or_none()
|
||||
if not team:
|
||||
raise HTTPException(status_code=404, detail="Team not found")
|
||||
|
||||
team.logo_data = None
|
||||
team.logo_content_type = None
|
||||
await db.commit()
|
||||
await db.refresh(team)
|
||||
|
||||
return BrandingResponse(
|
||||
company_display_name=team.company_display_name,
|
||||
logo_content_type=team.logo_content_type,
|
||||
has_logo=False,
|
||||
)
|
||||
110
backend/app/api/endpoints/onboarding.py
Normal file
110
backend/app/api/endpoints/onboarding.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""Onboarding status endpoints — track new-user checklist progress."""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.core.database import get_db
|
||||
from app.models.assistant_chat import AssistantChat
|
||||
from app.models.psa_connection import PsaConnection
|
||||
from app.models.session import Session
|
||||
from app.models.tree import Tree
|
||||
from app.models.user import User
|
||||
from app.schemas.onboarding import OnboardingStatus
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["onboarding"])
|
||||
|
||||
|
||||
@router.get("/onboarding-status", response_model=OnboardingStatus)
|
||||
async def get_onboarding_status(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> OnboardingStatus:
|
||||
"""Return onboarding checklist completion status for the current user."""
|
||||
|
||||
# created_flow — user owns at least 1 tree
|
||||
created_flow_q = await db.execute(
|
||||
select(func.count()).select_from(Tree).where(Tree.author_id == current_user.id).limit(1)
|
||||
)
|
||||
created_flow = (created_flow_q.scalar() or 0) > 0
|
||||
|
||||
# ran_session — user has at least 1 session
|
||||
ran_session_q = await db.execute(
|
||||
select(func.count()).select_from(Session).where(Session.user_id == current_user.id).limit(1)
|
||||
)
|
||||
ran_session = (ran_session_q.scalar() or 0) > 0
|
||||
|
||||
# exported_session — user has at least 1 session with exported=True
|
||||
exported_q = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(Session)
|
||||
.where(Session.user_id == current_user.id, Session.exported == True) # noqa: E712
|
||||
.limit(1)
|
||||
)
|
||||
exported_session = (exported_q.scalar() or 0) > 0
|
||||
|
||||
# tried_ai_assistant — user has at least 1 assistant chat
|
||||
ai_q = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(AssistantChat)
|
||||
.where(AssistantChat.user_id == current_user.id)
|
||||
.limit(1)
|
||||
)
|
||||
tried_ai_assistant = (ai_q.scalar() or 0) > 0
|
||||
|
||||
# Team-dependent checks
|
||||
is_team_user = current_user.team_id is not None
|
||||
|
||||
invited_teammate = False
|
||||
connected_psa = False
|
||||
|
||||
if is_team_user:
|
||||
# invited_teammate — team/account has more than 1 member
|
||||
if current_user.account_id:
|
||||
teammate_q = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(User)
|
||||
.where(
|
||||
User.account_id == current_user.account_id,
|
||||
User.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
invited_teammate = (teammate_q.scalar() or 0) > 1
|
||||
|
||||
# connected_psa — account has at least 1 PSA connection
|
||||
if current_user.account_id:
|
||||
psa_q = await db.execute(
|
||||
select(func.count())
|
||||
.select_from(PsaConnection)
|
||||
.where(PsaConnection.account_id == current_user.account_id)
|
||||
.limit(1)
|
||||
)
|
||||
connected_psa = (psa_q.scalar() or 0) > 0
|
||||
|
||||
return OnboardingStatus(
|
||||
created_flow=created_flow,
|
||||
ran_session=ran_session,
|
||||
exported_session=exported_session,
|
||||
tried_ai_assistant=tried_ai_assistant,
|
||||
invited_teammate=invited_teammate,
|
||||
connected_psa=connected_psa,
|
||||
is_team_user=is_team_user,
|
||||
dismissed=current_user.onboarding_dismissed,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/onboarding-status/dismiss", response_model=OnboardingStatus)
|
||||
async def dismiss_onboarding(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
) -> OnboardingStatus:
|
||||
"""Dismiss the onboarding checklist for the current user."""
|
||||
current_user.onboarding_dismissed = True
|
||||
await db.commit()
|
||||
await db.refresh(current_user)
|
||||
|
||||
# Return updated status (reuse the GET logic)
|
||||
return await get_onboarding_status(db=db, current_user=current_user)
|
||||
@@ -391,18 +391,51 @@ async def export_session(
|
||||
detail="You don't have access to this session"
|
||||
)
|
||||
|
||||
# PDF export — separate path with binary response
|
||||
if export_options.format == "pdf":
|
||||
from app.services.export_service import generate_pdf_export
|
||||
from fastapi.responses import Response
|
||||
pdf_bytes = await generate_pdf_export(session, export_options, db)
|
||||
|
||||
if session.completed_at:
|
||||
session.exported = True
|
||||
await db.commit()
|
||||
|
||||
return Response(
|
||||
content=pdf_bytes,
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": f'attachment; filename="session-export-{session_id}.pdf"'},
|
||||
)
|
||||
|
||||
# Query supporting data for non-PDF formats
|
||||
from app.models.supporting_data import SessionSupportingData
|
||||
sd_result = await db.execute(
|
||||
select(SessionSupportingData)
|
||||
.where(SessionSupportingData.session_id == session_id)
|
||||
.order_by(SessionSupportingData.sort_order)
|
||||
)
|
||||
supporting_data_items = [
|
||||
{
|
||||
"label": sd.label,
|
||||
"data_type": sd.data_type,
|
||||
"content": sd.content,
|
||||
"content_type": sd.content_type,
|
||||
}
|
||||
for sd in sd_result.scalars().all()
|
||||
]
|
||||
|
||||
# Generate export based on format
|
||||
if export_options.format == "markdown":
|
||||
content = generate_markdown_export(session, export_options)
|
||||
content = generate_markdown_export(session, export_options, supporting_data=supporting_data_items)
|
||||
media_type = "text/markdown"
|
||||
elif export_options.format == "html":
|
||||
content = generate_html_export(session, export_options)
|
||||
content = generate_html_export(session, export_options, supporting_data=supporting_data_items)
|
||||
media_type = "text/html"
|
||||
elif export_options.format == "psa":
|
||||
content = generate_psa_export(session, export_options)
|
||||
content = generate_psa_export(session, export_options, supporting_data=supporting_data_items)
|
||||
media_type = "text/plain"
|
||||
else: # text
|
||||
content = generate_text_export(session, export_options)
|
||||
content = generate_text_export(session, export_options, supporting_data=supporting_data_items)
|
||||
media_type = "text/plain"
|
||||
|
||||
# Resolve variables in export output
|
||||
|
||||
201
backend/app/api/endpoints/supporting_data.py
Normal file
201
backend/app/api/endpoints/supporting_data.py
Normal file
@@ -0,0 +1,201 @@
|
||||
import base64
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import get_current_active_user
|
||||
from app.models import User
|
||||
from app.models.session import Session
|
||||
from app.models.supporting_data import SessionSupportingData
|
||||
from app.schemas.supporting_data import (
|
||||
SupportingDataCreate,
|
||||
SupportingDataUpdate,
|
||||
SupportingDataResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/sessions", tags=["supporting-data"])
|
||||
|
||||
MAX_ITEMS_PER_SESSION = 20
|
||||
MAX_TEXT_SNIPPET_CHARS = 50_000
|
||||
MAX_SCREENSHOT_RAW_BYTES = 2 * 1024 * 1024 # 2MB
|
||||
|
||||
|
||||
async def _check_session_access(user: User, session: Session, db: AsyncSession) -> None:
|
||||
"""Verify user has access to the session (owner, team admin, or super admin)."""
|
||||
if user.is_super_admin:
|
||||
return
|
||||
if session.user_id == user.id:
|
||||
return
|
||||
# Team admins can only access sessions from their own team members
|
||||
if user.is_team_admin and user.team_id is not None:
|
||||
session_owner = await db.get(User, session.user_id)
|
||||
if session_owner and session_owner.team_id == user.team_id:
|
||||
return
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
|
||||
|
||||
async def _get_session_or_404(session_id: UUID, db: AsyncSession) -> Session:
|
||||
"""Fetch session by ID or raise 404."""
|
||||
result = await db.execute(select(Session).where(Session.id == session_id))
|
||||
session = result.scalar_one_or_none()
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return session
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{session_id}/supporting-data",
|
||||
response_model=SupportingDataResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_supporting_data(
|
||||
session_id: UUID,
|
||||
data: SupportingDataCreate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""Add a supporting data item (text snippet or screenshot) to a session."""
|
||||
session = await _get_session_or_404(session_id, db)
|
||||
await _check_session_access(current_user, session, db)
|
||||
|
||||
# Check item limit
|
||||
count_result = await db.execute(
|
||||
select(func.count()).select_from(SessionSupportingData).where(
|
||||
SessionSupportingData.session_id == session_id
|
||||
)
|
||||
)
|
||||
current_count = count_result.scalar() or 0
|
||||
if current_count >= MAX_ITEMS_PER_SESSION:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Maximum {MAX_ITEMS_PER_SESSION} supporting data items per session",
|
||||
)
|
||||
|
||||
# Validate content size based on type
|
||||
if data.data_type == "text_snippet":
|
||||
if len(data.content) > MAX_TEXT_SNIPPET_CHARS:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Text snippet exceeds maximum {MAX_TEXT_SNIPPET_CHARS} characters",
|
||||
)
|
||||
elif data.data_type == "screenshot":
|
||||
try:
|
||||
raw_bytes = base64.b64decode(data.content)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Invalid base64 content for screenshot")
|
||||
if len(raw_bytes) > MAX_SCREENSHOT_RAW_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Screenshot exceeds maximum {MAX_SCREENSHOT_RAW_BYTES // (1024 * 1024)}MB raw size",
|
||||
)
|
||||
|
||||
# Auto-increment sort_order
|
||||
max_order_result = await db.execute(
|
||||
select(func.max(SessionSupportingData.sort_order)).where(
|
||||
SessionSupportingData.session_id == session_id
|
||||
)
|
||||
)
|
||||
max_order = max_order_result.scalar()
|
||||
next_order = (max_order or 0) + 1
|
||||
|
||||
item = SessionSupportingData(
|
||||
session_id=session_id,
|
||||
label=data.label,
|
||||
data_type=data.data_type,
|
||||
content=data.content,
|
||||
content_type=data.content_type,
|
||||
sort_order=next_order,
|
||||
)
|
||||
db.add(item)
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
return item
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{session_id}/supporting-data",
|
||||
response_model=list[SupportingDataResponse],
|
||||
)
|
||||
async def list_supporting_data(
|
||||
session_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""List all supporting data items for a session, ordered by sort_order."""
|
||||
session = await _get_session_or_404(session_id, db)
|
||||
await _check_session_access(current_user, session, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(SessionSupportingData)
|
||||
.where(SessionSupportingData.session_id == session_id)
|
||||
.order_by(SessionSupportingData.sort_order)
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{session_id}/supporting-data/{item_id}",
|
||||
response_model=SupportingDataResponse,
|
||||
)
|
||||
async def update_supporting_data(
|
||||
session_id: UUID,
|
||||
item_id: UUID,
|
||||
data: SupportingDataUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""Update a supporting data item's label or content."""
|
||||
session = await _get_session_or_404(session_id, db)
|
||||
await _check_session_access(current_user, session, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(SessionSupportingData).where(
|
||||
SessionSupportingData.id == item_id,
|
||||
SessionSupportingData.session_id == session_id,
|
||||
)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Supporting data item not found")
|
||||
|
||||
if data.label is not None:
|
||||
item.label = data.label
|
||||
if data.content is not None:
|
||||
item.content = data.content
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(item)
|
||||
|
||||
return item
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{session_id}/supporting-data/{item_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def delete_supporting_data(
|
||||
session_id: UUID,
|
||||
item_id: UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
):
|
||||
"""Remove a supporting data item from a session."""
|
||||
session = await _get_session_or_404(session_id, db)
|
||||
await _check_session_access(current_user, session, db)
|
||||
|
||||
result = await db.execute(
|
||||
select(SessionSupportingData).where(
|
||||
SessionSupportingData.id == item_id,
|
||||
SessionSupportingData.session_id == session_id,
|
||||
)
|
||||
)
|
||||
item = result.scalar_one_or_none()
|
||||
if not item:
|
||||
raise HTTPException(status_code=404, detail="Supporting data item not found")
|
||||
|
||||
await db.delete(item)
|
||||
await db.commit()
|
||||
@@ -18,6 +18,9 @@ from app.api.endpoints import kb_accelerator
|
||||
from app.api.endpoints import beta_signup
|
||||
from app.api.endpoints import scripts
|
||||
from app.api.endpoints import integrations
|
||||
from app.api.endpoints import onboarding
|
||||
from app.api.endpoints import branding
|
||||
from app.api.endpoints import supporting_data
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
@@ -61,3 +64,6 @@ api_router.include_router(kb_accelerator.router)
|
||||
api_router.include_router(beta_signup.router)
|
||||
api_router.include_router(scripts.router)
|
||||
api_router.include_router(integrations.router)
|
||||
api_router.include_router(onboarding.router)
|
||||
api_router.include_router(branding.router)
|
||||
api_router.include_router(supporting_data.router)
|
||||
|
||||
@@ -39,6 +39,7 @@ from .script_template import ScriptCategory, ScriptTemplate, ScriptGeneration
|
||||
from .psa_connection import PsaConnection
|
||||
from .psa_post_log import PsaPostLog
|
||||
from .psa_member_mapping import PsaMemberMapping
|
||||
from .supporting_data import SessionSupportingData
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -92,4 +93,5 @@ __all__ = [
|
||||
"PsaConnection",
|
||||
"PsaPostLog",
|
||||
"PsaMemberMapping",
|
||||
"SessionSupportingData",
|
||||
]
|
||||
|
||||
@@ -82,6 +82,7 @@ class Session(Base):
|
||||
assigned_to: Mapped[Optional["User"]] = relationship("User", foreign_keys=[assigned_to_id])
|
||||
attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session")
|
||||
shares: Mapped[list["SessionShare"]] = relationship("SessionShare", back_populates="session", cascade="all, delete-orphan")
|
||||
supporting_data = relationship("SessionSupportingData", back_populates="session", cascade="all, delete-orphan", order_by="SessionSupportingData.sort_order")
|
||||
|
||||
# PSA ticket link
|
||||
psa_ticket_id: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
|
||||
25
backend/app/models/supporting_data.py
Normal file
25
backend/app/models/supporting_data.py
Normal file
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class SessionSupportingData(Base):
|
||||
__tablename__ = "session_supporting_data"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
session_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
data_type: Mapped[str] = mapped_column(Enum("text_snippet", "screenshot", name="supporting_data_type"), nullable=False)
|
||||
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
session = relationship("Session", back_populates="supporting_data")
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
from sqlalchemy import String, DateTime
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from sqlalchemy import String, DateTime, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
@@ -23,6 +23,12 @@ class Team(Base):
|
||||
default=uuid.uuid4
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# Branding
|
||||
logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
logo_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
company_display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=lambda: datetime.now(timezone.utc)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional, TYPE_CHECKING
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint
|
||||
from sqlalchemy import String, DateTime, ForeignKey, Boolean, CheckConstraint, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from app.core.database import Base
|
||||
@@ -73,6 +73,15 @@ class User(Base):
|
||||
job_title: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC", server_default="UTC")
|
||||
avatar_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
|
||||
|
||||
# Onboarding
|
||||
onboarding_dismissed: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, server_default="false")
|
||||
|
||||
# Branding (solo pros without a team)
|
||||
logo_data: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
logo_content_type: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
|
||||
company_display_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
|
||||
email_verified_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
14
backend/app/schemas/branding.py
Normal file
14
backend/app/schemas/branding.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class BrandingResponse(BaseModel):
|
||||
company_display_name: Optional[str] = None
|
||||
logo_content_type: Optional[str] = None
|
||||
has_logo: bool = False
|
||||
|
||||
|
||||
class BrandingLogoResponse(BaseModel):
|
||||
company_display_name: Optional[str] = None
|
||||
logo_data: Optional[str] = None
|
||||
logo_content_type: Optional[str] = None
|
||||
12
backend/app/schemas/onboarding.py
Normal file
12
backend/app/schemas/onboarding.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class OnboardingStatus(BaseModel):
|
||||
created_flow: bool
|
||||
ran_session: bool
|
||||
exported_session: bool
|
||||
tried_ai_assistant: bool
|
||||
invited_teammate: bool
|
||||
connected_psa: bool
|
||||
is_team_user: bool
|
||||
dismissed: bool
|
||||
@@ -106,7 +106,7 @@ class SessionResponse(BaseModel):
|
||||
|
||||
|
||||
class SessionExport(BaseModel):
|
||||
format: str = Field(default="markdown", pattern="^(text|markdown|html|psa)$")
|
||||
format: str = Field(default="markdown", pattern="^(text|markdown|html|psa|pdf)$")
|
||||
include_timestamps: bool = True
|
||||
include_tree_info: bool = True
|
||||
# Phase A
|
||||
|
||||
30
backend/app/schemas/supporting_data.py
Normal file
30
backend/app/schemas/supporting_data.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal, Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SupportingDataCreate(BaseModel):
|
||||
label: str = Field(..., min_length=1, max_length=255)
|
||||
data_type: Literal["text_snippet", "screenshot"]
|
||||
content: str = Field(..., min_length=1, max_length=5_000_000)
|
||||
content_type: Optional[str] = Field(None, max_length=50)
|
||||
|
||||
|
||||
class SupportingDataUpdate(BaseModel):
|
||||
label: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||
content: Optional[str] = Field(None, min_length=1)
|
||||
|
||||
|
||||
class SupportingDataResponse(BaseModel):
|
||||
id: UUID
|
||||
session_id: UUID
|
||||
label: str
|
||||
data_type: str
|
||||
content: str
|
||||
content_type: Optional[str]
|
||||
sort_order: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
@@ -46,6 +46,7 @@ class UserResponse(UserBase):
|
||||
role: str = "engineer"
|
||||
account_id: Optional[UUID] = None
|
||||
account_role: Optional[str] = None
|
||||
team_id: Optional[UUID] = None
|
||||
is_super_admin: bool = False
|
||||
is_active: bool = True
|
||||
must_change_password: bool = False
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""
|
||||
Session export generators for ResolutionFlow.
|
||||
|
||||
Provides markdown, plain text, HTML, and PSA/ticket note export formatters
|
||||
Provides markdown, plain text, HTML, PDF, and PSA/ticket note export formatters
|
||||
for troubleshooting sessions.
|
||||
"""
|
||||
import html
|
||||
import os
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app.models.session import Session
|
||||
@@ -169,7 +171,7 @@ def _escape_markdown_table(value: str) -> str:
|
||||
return value.replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
||||
def generate_markdown_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str:
|
||||
"""Generate markdown export."""
|
||||
if _is_procedural_session(session):
|
||||
return _generate_procedural_markdown(session, options)
|
||||
@@ -259,6 +261,22 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
||||
lines.append(f"*{decision['timestamp']}*")
|
||||
lines.append("")
|
||||
|
||||
# Supporting Data
|
||||
if supporting_data:
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
lines.append("## Supporting Data")
|
||||
lines.append("")
|
||||
for sd in supporting_data:
|
||||
lines.append(f"### {sd['label']}")
|
||||
if sd["data_type"] == "text_snippet":
|
||||
lines.append("```")
|
||||
lines.append(sd["content"])
|
||||
lines.append("```")
|
||||
else:
|
||||
lines.append(f"[Screenshot: {sd['label']}]")
|
||||
lines.append("")
|
||||
|
||||
# Resolution / Outcome Notes
|
||||
_raw_notes = getattr(session, 'outcome_notes', None)
|
||||
outcome_notes = _raw_notes if isinstance(_raw_notes, str) else ''
|
||||
@@ -284,7 +302,7 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_text_export(session: Session, options: SessionExport) -> str:
|
||||
def generate_text_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str:
|
||||
"""Generate plain text export."""
|
||||
if _is_procedural_session(session):
|
||||
return _generate_procedural_text(session, options)
|
||||
@@ -359,6 +377,19 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
|
||||
if duration_seconds is not None:
|
||||
lines.append(f" Duration: {_format_step_duration(duration_seconds)}")
|
||||
|
||||
# Supporting Data
|
||||
if supporting_data:
|
||||
lines.append("")
|
||||
lines.append("SUPPORTING DATA")
|
||||
lines.append("-" * 20)
|
||||
for sd in supporting_data:
|
||||
lines.append(f"\n {sd['label']}:")
|
||||
if sd["data_type"] == "text_snippet":
|
||||
for content_line in sd["content"].splitlines():
|
||||
lines.append(f" {content_line}")
|
||||
else:
|
||||
lines.append(f" [Screenshot: {sd['label']}]")
|
||||
|
||||
# Resolution
|
||||
_raw_notes = getattr(session, 'outcome_notes', None)
|
||||
outcome_notes = _raw_notes if isinstance(_raw_notes, str) else ''
|
||||
@@ -380,7 +411,7 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_html_export(session: Session, options: SessionExport) -> str:
|
||||
def generate_html_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str:
|
||||
"""Generate HTML export."""
|
||||
if _is_procedural_session(session):
|
||||
return _generate_procedural_html(session, options)
|
||||
@@ -470,6 +501,15 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
||||
html_parts.append(f'<p class="timestamp">{html.escape(str(decision["timestamp"]))}</p>')
|
||||
html_parts.append('</div>')
|
||||
|
||||
# Supporting Data
|
||||
if supporting_data:
|
||||
html_parts.append('<h2>Supporting Data</h2>')
|
||||
for sd in supporting_data:
|
||||
if sd["data_type"] == "text_snippet":
|
||||
html_parts.append(f'<div class="supporting-item"><h3>{html.escape(sd["label"])}</h3><pre>{html.escape(sd["content"])}</pre></div>')
|
||||
else:
|
||||
html_parts.append(f'<div class="supporting-item"><h3>{html.escape(sd["label"])}</h3><img src="data:{html.escape(sd.get("content_type") or "image/png")};base64,{sd["content"]}" alt="{html.escape(sd["label"])}"></div>')
|
||||
|
||||
# Resolution
|
||||
_raw_notes = getattr(session, 'outcome_notes', None)
|
||||
outcome_notes = _raw_notes if isinstance(_raw_notes, str) else ''
|
||||
@@ -488,7 +528,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
||||
return "\n".join(html_parts)
|
||||
|
||||
|
||||
def generate_psa_export(session: Session, options: SessionExport) -> str:
|
||||
def generate_psa_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str:
|
||||
"""Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools."""
|
||||
if _is_procedural_session(session):
|
||||
return _generate_procedural_psa(session, options)
|
||||
@@ -559,6 +599,19 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
|
||||
lines.append("No steps recorded.")
|
||||
lines.append("")
|
||||
|
||||
# Supporting Data
|
||||
if supporting_data:
|
||||
lines.append("--- SUPPORTING DATA ---")
|
||||
for sd in supporting_data:
|
||||
if sd["data_type"] == "text_snippet":
|
||||
lines.append(f"## {sd['label']}")
|
||||
lines.append("```")
|
||||
lines.append(sd["content"])
|
||||
lines.append("```")
|
||||
else:
|
||||
lines.append(f"[Screenshot: {sd['label']}]")
|
||||
lines.append("")
|
||||
|
||||
# Resolution — only for completed sessions
|
||||
if session.completed_at:
|
||||
lines.append("--- RESOLUTION ---")
|
||||
@@ -904,3 +957,188 @@ def _generate_procedural_psa(session: Session, options: SessionExport) -> str:
|
||||
lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def generate_pdf_export(session: Session, options: SessionExport, db) -> bytes:
|
||||
"""Generate PDF export using WeasyPrint and a Jinja2 HTML template.
|
||||
|
||||
Args:
|
||||
session: The session to export.
|
||||
options: Export options (redaction_mode, max_step_index, etc.).
|
||||
db: Async database session for loading supporting data and branding.
|
||||
|
||||
Returns:
|
||||
PDF file contents as bytes.
|
||||
"""
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
import weasyprint
|
||||
from sqlalchemy import select as sa_select
|
||||
|
||||
# Load Jinja2 template
|
||||
template_dir = Path(__file__).resolve().parent.parent / "templates"
|
||||
env = Environment(loader=FileSystemLoader(str(template_dir)), autoescape=True)
|
||||
template = env.get_template("export_pdf.html")
|
||||
|
||||
# Tree snapshot data
|
||||
tree_snapshot = session.tree_snapshot or {}
|
||||
flow_title = tree_snapshot.get("name", "Session Export")
|
||||
tree_type = tree_snapshot.get("tree_type", "troubleshooting")
|
||||
is_procedural = tree_type == "procedural"
|
||||
report_type = "Procedure Report" if is_procedural else "Troubleshooting Report"
|
||||
|
||||
# Branding — check team first, then user (solo pros)
|
||||
logo_data = None
|
||||
logo_content_type = None
|
||||
company_name = None
|
||||
|
||||
from app.models.user import User
|
||||
user_result = await db.execute(
|
||||
sa_select(User).where(User.id == session.user_id)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
engineer_name = user.name if user else "Unknown"
|
||||
|
||||
if user and user.team_id:
|
||||
from app.models.team import Team
|
||||
team_result = await db.execute(
|
||||
sa_select(Team).where(Team.id == user.team_id)
|
||||
)
|
||||
team = team_result.scalar_one_or_none()
|
||||
if team:
|
||||
logo_data = team.logo_data
|
||||
logo_content_type = team.logo_content_type
|
||||
company_name = team.company_display_name or team.name
|
||||
elif user:
|
||||
logo_data = user.logo_data
|
||||
logo_content_type = user.logo_content_type
|
||||
company_name = user.company_display_name
|
||||
|
||||
has_custom_logo = bool(logo_data)
|
||||
|
||||
# Build steps list from decisions
|
||||
decisions = session.decisions or []
|
||||
if options.max_step_index is not None:
|
||||
decisions = decisions[:options.max_step_index]
|
||||
|
||||
steps = []
|
||||
for decision in decisions:
|
||||
title = decision.get("question") or decision.get("action_performed", "Step")
|
||||
answer = decision.get("answer", "")
|
||||
notes = decision.get("notes", "")
|
||||
duration_seconds = _get_step_duration_seconds(decision)
|
||||
duration_str = _format_step_duration(duration_seconds) if duration_seconds is not None else None
|
||||
|
||||
if is_procedural:
|
||||
completed = answer == "completed"
|
||||
decision_text = "Completed" if completed else ("Skipped" if answer else "")
|
||||
else:
|
||||
decision_text = answer
|
||||
|
||||
steps.append({
|
||||
"title": title,
|
||||
"decision": decision_text,
|
||||
"notes": notes,
|
||||
"duration": duration_str,
|
||||
})
|
||||
|
||||
# Query supporting data
|
||||
from app.models.supporting_data import SessionSupportingData
|
||||
sd_result = await db.execute(
|
||||
sa_select(SessionSupportingData)
|
||||
.where(SessionSupportingData.session_id == session.id)
|
||||
.order_by(SessionSupportingData.sort_order)
|
||||
)
|
||||
supporting_data_rows = sd_result.scalars().all()
|
||||
supporting_data = [
|
||||
{
|
||||
"label": sd.label,
|
||||
"data_type": sd.data_type,
|
||||
"content": sd.content,
|
||||
"content_type": sd.content_type,
|
||||
}
|
||||
for sd in supporting_data_rows
|
||||
]
|
||||
|
||||
# Calculate duration and format outcome
|
||||
duration = _format_duration(session.started_at, session.completed_at)
|
||||
session_date = session.started_at.strftime("%Y-%m-%d %H:%M")
|
||||
outcome_label = _get_outcome_label(session) or ("In Progress" if not session.completed_at else "Completed")
|
||||
outcome_raw = getattr(session, "outcome", None) or ""
|
||||
outcome_class = f"outcome-{outcome_raw}" if outcome_raw else ""
|
||||
|
||||
# Build summary text
|
||||
summary_text = ""
|
||||
if options.include_summary:
|
||||
summary_fields = _build_summary_fields(session)
|
||||
parts = []
|
||||
for label, value in summary_fields.items():
|
||||
if value:
|
||||
parts.append(f"{label.replace('_', ' ').title()}: {value}")
|
||||
summary_text = "\n".join(parts)
|
||||
|
||||
# Resolution / outcome notes as summary fallback
|
||||
if not summary_text:
|
||||
_raw_notes = getattr(session, "outcome_notes", None)
|
||||
if isinstance(_raw_notes, str) and _raw_notes.strip():
|
||||
summary_text = _raw_notes.strip()
|
||||
|
||||
generated_at = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
||||
|
||||
# Variable resolution
|
||||
session_vars = getattr(session, "session_variables", None) or {}
|
||||
if session_vars:
|
||||
from app.services.variable_service import resolve_variables
|
||||
flow_title = resolve_variables(flow_title, session_vars)
|
||||
summary_text = resolve_variables(summary_text, session_vars)
|
||||
for step in steps:
|
||||
step["title"] = resolve_variables(step["title"], session_vars)
|
||||
if step["decision"]:
|
||||
step["decision"] = resolve_variables(step["decision"], session_vars)
|
||||
if step["notes"]:
|
||||
step["notes"] = resolve_variables(step["notes"], session_vars)
|
||||
for sd in supporting_data:
|
||||
if sd["data_type"] == "text_snippet":
|
||||
sd["content"] = resolve_variables(sd["content"], session_vars)
|
||||
|
||||
# Apply redaction
|
||||
if options.redaction_mode == "mask":
|
||||
from app.services.redaction_service import apply_redaction_to_text
|
||||
try:
|
||||
flow_title, _ = apply_redaction_to_text(flow_title)
|
||||
summary_text, _ = apply_redaction_to_text(summary_text)
|
||||
for step in steps:
|
||||
step["title"], _ = apply_redaction_to_text(step["title"])
|
||||
if step["decision"]:
|
||||
step["decision"], _ = apply_redaction_to_text(step["decision"])
|
||||
if step["notes"]:
|
||||
step["notes"], _ = apply_redaction_to_text(step["notes"])
|
||||
for sd in supporting_data:
|
||||
if sd["data_type"] == "text_snippet":
|
||||
sd["content"], _ = apply_redaction_to_text(sd["content"])
|
||||
except Exception:
|
||||
pass # Redaction is best-effort for PDF
|
||||
|
||||
# Render HTML
|
||||
html_content = template.render(
|
||||
report_type=report_type,
|
||||
flow_title=flow_title,
|
||||
logo_data=logo_data,
|
||||
logo_content_type=logo_content_type or "image/png",
|
||||
has_custom_logo=has_custom_logo,
|
||||
company_name=company_name,
|
||||
engineer_name=engineer_name,
|
||||
client_name=session.client_name,
|
||||
ticket_number=session.ticket_number,
|
||||
session_date=session_date,
|
||||
duration=duration,
|
||||
outcome_class=outcome_class,
|
||||
outcome_display=outcome_label,
|
||||
summary=summary_text,
|
||||
steps=steps,
|
||||
supporting_data=supporting_data,
|
||||
generated_at=generated_at,
|
||||
)
|
||||
|
||||
# Convert to PDF
|
||||
pdf_bytes = weasyprint.HTML(string=html_content).write_pdf()
|
||||
return pdf_bytes
|
||||
|
||||
378
backend/app/templates/export_pdf.html
Normal file
378
backend/app/templates/export_pdf.html
Normal file
@@ -0,0 +1,378 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
{% if has_custom_logo %}
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
@bottom-right {
|
||||
content: "Powered by ResolutionFlow";
|
||||
font-size: 8pt;
|
||||
color: #999;
|
||||
}
|
||||
@bottom-left {
|
||||
content: "Generated {{ generated_at }}";
|
||||
font-size: 8pt;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
{% else %}
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
@bottom-left {
|
||||
content: "Generated {{ generated_at }}";
|
||||
font-size: 8pt;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color: #1a1a2e;
|
||||
background: #ffffff;
|
||||
font-size: 10pt;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* --- Header --- */
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 3px solid #06b6d4;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.report-type {
|
||||
font-size: 8pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: #06b6d4;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.flow-title {
|
||||
font-size: 18pt;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.company-name {
|
||||
font-size: 10pt;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
flex-shrink: 0;
|
||||
margin-left: 24px;
|
||||
}
|
||||
|
||||
.header-logo img {
|
||||
max-width: 120px;
|
||||
max-height: 60px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* --- Metadata Grid --- */
|
||||
.metadata-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 7pt;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 10pt;
|
||||
color: #1a1a2e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.outcome-resolved {
|
||||
color: #059669;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.outcome-escalated {
|
||||
color: #d97706;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.outcome-workaround {
|
||||
color: #2563eb;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.outcome-unresolved {
|
||||
color: #dc2626;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* --- Section Headers --- */
|
||||
.section-title {
|
||||
font-size: 12pt;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 2px solid #06b6d4;
|
||||
}
|
||||
|
||||
/* --- Summary --- */
|
||||
.summary {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.summary-text {
|
||||
font-size: 10pt;
|
||||
color: #334155;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* --- Troubleshooting Path Timeline --- */
|
||||
.timeline {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.timeline-step {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
}
|
||||
|
||||
.timeline-step::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 5px;
|
||||
top: 6px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #06b6d4;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.timeline-step::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 9px;
|
||||
top: 18px;
|
||||
width: 2px;
|
||||
height: calc(100% + 0px);
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.timeline-step:last-child::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
flex: 1;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-size: 10pt;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.step-decision {
|
||||
font-size: 9pt;
|
||||
color: #06b6d4;
|
||||
font-weight: 500;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.step-notes {
|
||||
font-size: 9pt;
|
||||
color: #64748b;
|
||||
font-style: italic;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.step-duration {
|
||||
font-size: 8pt;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* --- Supporting Data --- */
|
||||
.supporting-data {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.supporting-item {
|
||||
margin-bottom: 16px;
|
||||
break-inside: avoid;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.supporting-item-label {
|
||||
font-size: 9pt;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.supporting-item-content {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
font-family: "JetBrains Mono", "Fira Code", "Consolas", monospace;
|
||||
font-size: 8pt;
|
||||
line-height: 1.5;
|
||||
background: #f1f5f9;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.screenshot-img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<div class="report-type">{{ report_type }}</div>
|
||||
<div class="flow-title">{{ flow_title }}</div>
|
||||
{% if company_name %}
|
||||
<div class="company-name">{{ company_name }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if logo_data %}
|
||||
<div class="header-logo">
|
||||
<img src="data:{{ logo_content_type }};base64,{{ logo_data }}" alt="Logo">
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Metadata Grid -->
|
||||
<div class="metadata-grid">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Engineer</span>
|
||||
<span class="meta-value">{{ engineer_name or "N/A" }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Client</span>
|
||||
<span class="meta-value">{{ client_name or "N/A" }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Ticket #</span>
|
||||
<span class="meta-value">{{ ticket_number or "N/A" }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Date</span>
|
||||
<span class="meta-value">{{ session_date }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Duration</span>
|
||||
<span class="meta-value">{{ duration }}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Outcome</span>
|
||||
<span class="meta-value {{ outcome_class }}">{{ outcome_display }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
{% if summary %}
|
||||
<div class="summary">
|
||||
<div class="section-title">Summary</div>
|
||||
<div class="summary-text">{{ summary }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Troubleshooting Path -->
|
||||
{% if steps %}
|
||||
<div class="timeline">
|
||||
<div class="section-title">Troubleshooting Path</div>
|
||||
{% for step in steps %}
|
||||
<div class="timeline-step">
|
||||
<div class="step-content">
|
||||
<div class="step-title">{{ loop.index }}. {{ step.title }}</div>
|
||||
{% if step.decision %}
|
||||
<div class="step-decision">{{ step.decision }}</div>
|
||||
{% endif %}
|
||||
{% if step.notes %}
|
||||
<div class="step-notes">{{ step.notes }}</div>
|
||||
{% endif %}
|
||||
{% if step.duration %}
|
||||
<div class="step-duration">{{ step.duration }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Supporting Data -->
|
||||
{% if supporting_data %}
|
||||
<div class="supporting-data">
|
||||
<div class="section-title">Supporting Data</div>
|
||||
{% for item in supporting_data %}
|
||||
<div class="supporting-item">
|
||||
<div class="supporting-item-label">{{ item.label }}</div>
|
||||
<div class="supporting-item-content">
|
||||
{% if item.data_type == "screenshot" %}
|
||||
<img class="screenshot-img" src="data:{{ item.content_type or 'image/png' }};base64,{{ item.content }}" alt="{{ item.label }}">
|
||||
{% else %}
|
||||
<div class="code-block">{{ item.content }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -42,6 +42,10 @@ voyageai>=0.3.0
|
||||
# Monitoring
|
||||
sentry-sdk[fastapi]>=2.54.0
|
||||
|
||||
# PDF Export
|
||||
weasyprint>=62.0
|
||||
jinja2>=3.1.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv==1.0.1
|
||||
croniter>=2.0.0
|
||||
|
||||
249
backend/tests/test_branding.py
Normal file
249
backend/tests/test_branding.py
Normal file
@@ -0,0 +1,249 @@
|
||||
"""Tests for team branding endpoints (logo upload + company display name)."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.security import get_password_hash
|
||||
from app.models.team import Team
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _create_team_with_admin(
|
||||
test_db: AsyncSession,
|
||||
client: AsyncClient,
|
||||
*,
|
||||
team_name: str = "Branding Test Team",
|
||||
) -> tuple[dict, str, Team]:
|
||||
"""Create a team + team admin user. Returns (auth_headers, team_id_str, team)."""
|
||||
team = Team(name=team_name)
|
||||
test_db.add(team)
|
||||
await test_db.flush()
|
||||
|
||||
email = f"admin_{uuid.uuid4().hex[:8]}@test.com"
|
||||
user = User(
|
||||
email=email,
|
||||
password_hash=get_password_hash("Password123!"),
|
||||
name="Team Admin",
|
||||
is_active=True,
|
||||
team_id=team.id,
|
||||
is_team_admin=True,
|
||||
role="engineer",
|
||||
)
|
||||
test_db.add(user)
|
||||
await test_db.commit()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login/json",
|
||||
json={"email": email, "password": "Password123!"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
token = resp.json()["access_token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
return headers, str(team.id), team
|
||||
|
||||
|
||||
async def _create_team_member(
|
||||
test_db: AsyncSession,
|
||||
client: AsyncClient,
|
||||
team: Team,
|
||||
*,
|
||||
is_team_admin: bool = False,
|
||||
) -> dict:
|
||||
"""Create a regular team member. Returns auth_headers."""
|
||||
email = f"member_{uuid.uuid4().hex[:8]}@test.com"
|
||||
user = User(
|
||||
email=email,
|
||||
password_hash=get_password_hash("Password123!"),
|
||||
name="Team Member",
|
||||
is_active=True,
|
||||
team_id=team.id,
|
||||
is_team_admin=is_team_admin,
|
||||
role="engineer",
|
||||
)
|
||||
test_db.add(user)
|
||||
await test_db.commit()
|
||||
|
||||
resp = await client.post(
|
||||
"/api/v1/auth/login/json",
|
||||
json={"email": email, "password": "Password123!"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
token = resp.json()["access_token"]
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_branding_defaults(client: AsyncClient, test_db: AsyncSession):
|
||||
"""GET branding with no logo returns defaults (has_logo=False)."""
|
||||
headers, team_id, _ = await _create_team_with_admin(test_db, client)
|
||||
|
||||
resp = await client.get(f"/api/v1/teams/{team_id}/branding", headers=headers)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["has_logo"] is False
|
||||
assert data["company_display_name"] is None
|
||||
assert data["logo_content_type"] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_logo_with_company_name(client: AsyncClient, test_db: AsyncSession):
|
||||
"""PATCH with valid PNG logo + company name succeeds."""
|
||||
headers, team_id, _ = await _create_team_with_admin(test_db, client)
|
||||
|
||||
# 1x1 transparent PNG (67 bytes)
|
||||
png_bytes = (
|
||||
b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01"
|
||||
b"\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89"
|
||||
b"\x00\x00\x00\nIDATx\x9cc\x00\x01\x00\x00\x05\x00\x01"
|
||||
b"\r\n\xb4\x00\x00\x00\x00IEND\xaeB`\x82"
|
||||
)
|
||||
|
||||
resp = await client.patch(
|
||||
f"/api/v1/teams/{team_id}/branding",
|
||||
headers=headers,
|
||||
files={"logo": ("logo.png", png_bytes, "image/png")},
|
||||
data={"company_display_name": "Acme MSP"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()
|
||||
assert data["has_logo"] is True
|
||||
assert data["logo_content_type"] == "image/png"
|
||||
assert data["company_display_name"] == "Acme MSP"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_oversized_logo(client: AsyncClient, test_db: AsyncSession):
|
||||
"""PATCH with >2MB file returns 400."""
|
||||
headers, team_id, _ = await _create_team_with_admin(test_db, client)
|
||||
|
||||
big_bytes = b"\x00" * (2 * 1024 * 1024 + 1) # 2MB + 1 byte
|
||||
|
||||
resp = await client.patch(
|
||||
f"/api/v1/teams/{team_id}/branding",
|
||||
headers=headers,
|
||||
files={"logo": ("big.png", big_bytes, "image/png")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "maximum size" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_upload_invalid_content_type(client: AsyncClient, test_db: AsyncSession):
|
||||
"""PATCH with application/pdf content type returns 400."""
|
||||
headers, team_id, _ = await _create_team_with_admin(test_db, client)
|
||||
|
||||
resp = await client.patch(
|
||||
f"/api/v1/teams/{team_id}/branding",
|
||||
headers=headers,
|
||||
files={"logo": ("doc.pdf", b"%PDF-fake", "application/pdf")},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "content type" in resp.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_logo(client: AsyncClient, test_db: AsyncSession):
|
||||
"""DELETE logo clears logo_data while keeping company_display_name."""
|
||||
headers, team_id, _ = await _create_team_with_admin(test_db, client)
|
||||
|
||||
# Upload a logo + name first
|
||||
png_bytes = b"\x89PNG\r\n\x1a\n" + b"\x00" * 50
|
||||
await client.patch(
|
||||
f"/api/v1/teams/{team_id}/branding",
|
||||
headers=headers,
|
||||
files={"logo": ("logo.png", png_bytes, "image/png")},
|
||||
data={"company_display_name": "Keep This Name"},
|
||||
)
|
||||
|
||||
# Delete logo
|
||||
resp = await client.delete(
|
||||
f"/api/v1/teams/{team_id}/branding/logo",
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["has_logo"] is False
|
||||
assert data["logo_content_type"] is None
|
||||
assert data["company_display_name"] == "Keep This Name"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_cannot_update(client: AsyncClient, test_db: AsyncSession):
|
||||
"""Regular team member (non-admin) cannot PATCH branding — returns 403."""
|
||||
admin_headers, team_id, team = await _create_team_with_admin(test_db, client)
|
||||
member_headers = await _create_team_member(test_db, client, team)
|
||||
|
||||
resp = await client.patch(
|
||||
f"/api/v1/teams/{team_id}/branding",
|
||||
headers=member_headers,
|
||||
data={"company_display_name": "Should Fail"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_cannot_delete_logo(client: AsyncClient, test_db: AsyncSession):
|
||||
"""Regular team member cannot DELETE logo — returns 403."""
|
||||
admin_headers, team_id, team = await _create_team_with_admin(test_db, client)
|
||||
member_headers = await _create_team_member(test_db, client, team)
|
||||
|
||||
resp = await client.delete(
|
||||
f"/api/v1/teams/{team_id}/branding/logo",
|
||||
headers=member_headers,
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_member_cannot_read(client: AsyncClient, test_db: AsyncSession):
|
||||
"""User from a different team cannot GET branding — returns 403."""
|
||||
_, team_id, _ = await _create_team_with_admin(test_db, client, team_name="Team A")
|
||||
other_headers, _, _ = await _create_team_with_admin(test_db, client, team_name="Team B")
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/v1/teams/{team_id}/branding",
|
||||
headers=other_headers,
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_member_can_read_branding(client: AsyncClient, test_db: AsyncSession):
|
||||
"""Regular team member CAN read branding."""
|
||||
admin_headers, team_id, team = await _create_team_with_admin(test_db, client)
|
||||
member_headers = await _create_team_member(test_db, client, team)
|
||||
|
||||
resp = await client.get(
|
||||
f"/api/v1/teams/{team_id}/branding",
|
||||
headers=member_headers,
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["has_logo"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_display_name_only(client: AsyncClient, test_db: AsyncSession):
|
||||
"""PATCH with only company_display_name (no logo) succeeds."""
|
||||
headers, team_id, _ = await _create_team_with_admin(test_db, client)
|
||||
|
||||
resp = await client.patch(
|
||||
f"/api/v1/teams/{team_id}/branding",
|
||||
headers=headers,
|
||||
data={"company_display_name": "Just A Name"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["company_display_name"] == "Just A Name"
|
||||
assert data["has_logo"] is False
|
||||
72
backend/tests/test_onboarding.py
Normal file
72
backend/tests/test_onboarding.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Tests for onboarding status endpoints."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onboarding_status_fresh_user(client, auth_headers):
|
||||
"""Fresh user should have all onboarding items false."""
|
||||
response = await client.get(
|
||||
"/api/v1/users/onboarding-status",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
|
||||
assert data["created_flow"] is False
|
||||
assert data["ran_session"] is False
|
||||
assert data["exported_session"] is False
|
||||
assert data["tried_ai_assistant"] is False
|
||||
assert data["invited_teammate"] is False
|
||||
assert data["connected_psa"] is False
|
||||
assert data["is_team_user"] is False
|
||||
assert data["dismissed"] is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onboarding_dismiss(client, auth_headers):
|
||||
"""Dismiss endpoint should set dismissed to true."""
|
||||
# Verify starts as false
|
||||
response = await client.get(
|
||||
"/api/v1/users/onboarding-status",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dismissed"] is False
|
||||
|
||||
# Dismiss
|
||||
response = await client.post(
|
||||
"/api/v1/users/onboarding-status/dismiss",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dismissed"] is True
|
||||
|
||||
# Verify persisted
|
||||
response = await client.get(
|
||||
"/api/v1/users/onboarding-status",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["dismissed"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onboarding_created_flow_after_tree_creation(client, auth_headers, test_tree):
|
||||
"""After creating a tree, created_flow should be true."""
|
||||
response = await client.get(
|
||||
"/api/v1/users/onboarding-status",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["created_flow"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_onboarding_requires_auth(client):
|
||||
"""Unauthenticated requests should be rejected."""
|
||||
response = await client.get("/api/v1/users/onboarding-status")
|
||||
assert response.status_code == 401
|
||||
|
||||
response = await client.post("/api/v1/users/onboarding-status/dismiss")
|
||||
assert response.status_code == 401
|
||||
96
backend/tests/test_pdf_export.py
Normal file
96
backend/tests/test_pdf_export.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Tests for PDF export via WeasyPrint."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestPDFExport:
|
||||
"""Test PDF export endpoint."""
|
||||
|
||||
async def test_export_pdf_returns_pdf_content(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test that PDF export returns application/pdf content starting with %PDF."""
|
||||
# Create a session
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"], "ticket_number": "PDF-001"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert create_response.status_code in (200, 201)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
# Add a decision so there's content
|
||||
await client.put(
|
||||
f"/api/v1/sessions/{session_id}",
|
||||
json={
|
||||
"decisions": [
|
||||
{
|
||||
"node_id": "root",
|
||||
"question": "Is this a test?",
|
||||
"answer": "Yes",
|
||||
"notes": "PDF export test",
|
||||
"timestamp": "2026-03-17T10:00:00Z",
|
||||
}
|
||||
]
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
# Export as PDF
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "pdf", "include_tree_info": True},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
assert "session-export-" in response.headers.get("content-disposition", "")
|
||||
# PDF files start with %PDF
|
||||
assert response.content[:5] == b"%PDF-"
|
||||
|
||||
async def test_export_pdf_with_no_supporting_data(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Test PDF export works when session has no supporting data."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert create_response.status_code in (200, 201)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "pdf"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-type"] == "application/pdf"
|
||||
assert response.content[:5] == b"%PDF-"
|
||||
|
||||
async def test_existing_markdown_export_still_works(
|
||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||
):
|
||||
"""Verify markdown export is unaffected by PDF addition."""
|
||||
create_response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"], "ticket_number": "MD-001"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert create_response.status_code in (200, 201)
|
||||
session_id = create_response.json()["id"]
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{session_id}/export",
|
||||
json={"format": "markdown", "include_tree_info": True},
|
||||
headers=auth_headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "text/markdown" in response.headers["content-type"]
|
||||
assert "MD-001" in response.text
|
||||
217
backend/tests/test_supporting_data.py
Normal file
217
backend/tests/test_supporting_data.py
Normal file
@@ -0,0 +1,217 @@
|
||||
import base64
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_session(client: AsyncClient, auth_headers: dict, test_tree: dict):
|
||||
"""Create a test session from the test tree."""
|
||||
response = await client.post(
|
||||
"/api/v1/sessions",
|
||||
json={"tree_id": test_tree["id"]},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 201, f"Failed to create session: {response.text}"
|
||||
return response.json()
|
||||
|
||||
|
||||
# --- Create ---
|
||||
|
||||
|
||||
async def test_create_text_snippet(client: AsyncClient, auth_headers: dict, test_session: dict):
|
||||
"""Create a text snippet supporting data item — returns 201."""
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{test_session['id']}/supporting-data",
|
||||
json={
|
||||
"label": "Error log",
|
||||
"data_type": "text_snippet",
|
||||
"content": "NullReferenceException at line 42",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["label"] == "Error log"
|
||||
assert data["data_type"] == "text_snippet"
|
||||
assert data["content"] == "NullReferenceException at line 42"
|
||||
assert data["sort_order"] == 1
|
||||
assert data["session_id"] == test_session["id"]
|
||||
|
||||
|
||||
async def test_create_screenshot(client: AsyncClient, auth_headers: dict, test_session: dict):
|
||||
"""Create a screenshot supporting data item — returns 201."""
|
||||
# Small valid base64 content (a tiny PNG-like payload)
|
||||
small_content = base64.b64encode(b"\x89PNG\r\n\x1a\n" + b"\x00" * 100).decode()
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{test_session['id']}/supporting-data",
|
||||
json={
|
||||
"label": "Error screenshot",
|
||||
"data_type": "screenshot",
|
||||
"content": small_content,
|
||||
"content_type": "image/png",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["label"] == "Error screenshot"
|
||||
assert data["data_type"] == "screenshot"
|
||||
assert data["content_type"] == "image/png"
|
||||
|
||||
|
||||
# --- List ---
|
||||
|
||||
|
||||
async def test_list_items_in_sort_order(client: AsyncClient, auth_headers: dict, test_session: dict):
|
||||
"""List returns items ordered by sort_order."""
|
||||
# Create 3 items
|
||||
for i in range(3):
|
||||
resp = await client.post(
|
||||
f"/api/v1/sessions/{test_session['id']}/supporting-data",
|
||||
json={
|
||||
"label": f"Item {i}",
|
||||
"data_type": "text_snippet",
|
||||
"content": f"Content {i}",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
response = await client.get(
|
||||
f"/api/v1/sessions/{test_session['id']}/supporting-data",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
items = response.json()
|
||||
assert len(items) == 3
|
||||
assert items[0]["label"] == "Item 0"
|
||||
assert items[1]["label"] == "Item 1"
|
||||
assert items[2]["label"] == "Item 2"
|
||||
assert items[0]["sort_order"] < items[1]["sort_order"] < items[2]["sort_order"]
|
||||
|
||||
|
||||
# --- Delete ---
|
||||
|
||||
|
||||
async def test_delete_item(client: AsyncClient, auth_headers: dict, test_session: dict):
|
||||
"""Delete removes the item."""
|
||||
create_resp = await client.post(
|
||||
f"/api/v1/sessions/{test_session['id']}/supporting-data",
|
||||
json={
|
||||
"label": "To delete",
|
||||
"data_type": "text_snippet",
|
||||
"content": "Will be removed",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
item_id = create_resp.json()["id"]
|
||||
|
||||
delete_resp = await client.delete(
|
||||
f"/api/v1/sessions/{test_session['id']}/supporting-data/{item_id}",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert delete_resp.status_code == 204
|
||||
|
||||
# Verify it's gone
|
||||
list_resp = await client.get(
|
||||
f"/api/v1/sessions/{test_session['id']}/supporting-data",
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert list_resp.status_code == 200
|
||||
assert len(list_resp.json()) == 0
|
||||
|
||||
|
||||
# --- Validation ---
|
||||
|
||||
|
||||
async def test_exceed_20_item_limit(client: AsyncClient, auth_headers: dict, test_session: dict):
|
||||
"""Cannot exceed 20 items per session — returns 400."""
|
||||
for i in range(20):
|
||||
resp = await client.post(
|
||||
f"/api/v1/sessions/{test_session['id']}/supporting-data",
|
||||
json={
|
||||
"label": f"Item {i}",
|
||||
"data_type": "text_snippet",
|
||||
"content": f"Content {i}",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert resp.status_code == 201, f"Failed creating item {i}: {resp.text}"
|
||||
|
||||
# 21st should fail
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{test_session['id']}/supporting-data",
|
||||
json={
|
||||
"label": "One too many",
|
||||
"data_type": "text_snippet",
|
||||
"content": "Should fail",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "20" in response.json()["detail"]
|
||||
|
||||
|
||||
async def test_screenshot_exceeds_2mb(client: AsyncClient, auth_headers: dict, test_session: dict):
|
||||
"""Screenshot over 2MB raw (base64 decoded) — returns 400."""
|
||||
# Create content that decodes to > 2MB
|
||||
large_raw = b"\x00" * (2 * 1024 * 1024 + 1) # 2MB + 1 byte
|
||||
large_b64 = base64.b64encode(large_raw).decode()
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{test_session['id']}/supporting-data",
|
||||
json={
|
||||
"label": "Large screenshot",
|
||||
"data_type": "screenshot",
|
||||
"content": large_b64,
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "2MB" in response.json()["detail"]
|
||||
|
||||
|
||||
async def test_text_snippet_over_50k_chars(client: AsyncClient, auth_headers: dict, test_session: dict):
|
||||
"""Text snippet over 50,000 characters — returns 400."""
|
||||
response = await client.post(
|
||||
f"/api/v1/sessions/{test_session['id']}/supporting-data",
|
||||
json={
|
||||
"label": "Huge text",
|
||||
"data_type": "text_snippet",
|
||||
"content": "x" * 50_001,
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "50000" in response.json()["detail"]
|
||||
|
||||
|
||||
# --- Update ---
|
||||
|
||||
|
||||
async def test_patch_update_label(client: AsyncClient, auth_headers: dict, test_session: dict):
|
||||
"""PATCH to update label returns updated item."""
|
||||
create_resp = await client.post(
|
||||
f"/api/v1/sessions/{test_session['id']}/supporting-data",
|
||||
json={
|
||||
"label": "Original label",
|
||||
"data_type": "text_snippet",
|
||||
"content": "Some content",
|
||||
},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert create_resp.status_code == 201
|
||||
item_id = create_resp.json()["id"]
|
||||
|
||||
patch_resp = await client.patch(
|
||||
f"/api/v1/sessions/{test_session['id']}/supporting-data/{item_id}",
|
||||
json={"label": "Updated label"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert patch_resp.status_code == 200
|
||||
data = patch_resp.json()
|
||||
assert data["label"] == "Updated label"
|
||||
assert data["content"] == "Some content" # unchanged
|
||||
2650
docs/superpowers/plans/2026-03-16-empty-states-onboarding-exports.md
Normal file
2650
docs/superpowers/plans/2026-03-16-empty-states-onboarding-exports.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,440 @@
|
||||
# Empty States, Onboarding & Professional Exports — Design Spec
|
||||
|
||||
> **Date:** 2026-03-16
|
||||
> **Product:** ResolutionFlow
|
||||
> **Approach:** Bottom-up (foundation → empty states → onboarding → exports)
|
||||
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
Make ResolutionFlow feel polished and professional by eliminating dead-end empty pages, guiding new users through setup, and providing client-ready PDF exports that MSPs can hand directly to customers.
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
1. Illustrative empty states across 8 pages with benefit-oriented copy and "Learn more" guide links
|
||||
2. Onboarding starter checklist widget on QuickStartPage (solo and team variants)
|
||||
3. Team branding settings (logo upload, company display name)
|
||||
4. PDF export via WeasyPrint with branded templates
|
||||
5. Supporting data capture during sessions (text snippets + screenshots)
|
||||
6. 7 in-app user guides linked from empty states
|
||||
7. Tests: backend integration, frontend unit, Playwright e2e
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Bring-your-own-storage (S3/Azure) for supporting data — future feature
|
||||
- Full file attachments beyond screenshots
|
||||
- Removing "Powered by ResolutionFlow" footer (potential premium tier)
|
||||
- Multi-browser Playwright matrix
|
||||
|
||||
---
|
||||
|
||||
## 1. Empty States
|
||||
|
||||
### Component Upgrade
|
||||
|
||||
Extend the existing `EmptyState.tsx` component to support the illustrative style:
|
||||
|
||||
- **SVG illustration slot** — optional prop, renders a brand-colored line-art illustration above the title
|
||||
- **Benefit-oriented description** — explains what the page does and why it matters, not just "no data"
|
||||
- **Primary CTA button** — navigates to the action that populates the page
|
||||
- **Secondary "Learn more" link** — navigates to the relevant in-app guide
|
||||
|
||||
### Pages (8 total)
|
||||
|
||||
| Page | Title | Description | CTA | Guide Link |
|
||||
|------|-------|-------------|-----|------------|
|
||||
| Flow Library (no flows) | Build your first troubleshooting flow | Flows guide your team through proven resolution paths, capturing every decision along the way. | Create a Flow | `/guides/creating-flows` |
|
||||
| Flow Library (no filter results) | No flows match your filters | Try adjusting your search or filters. | Clear Filters | — |
|
||||
| Analytics (My/Team) | Track your troubleshooting performance | Analytics show resolution times, common paths, and team efficiency. Data appears automatically as you complete sessions. | Run Your First Session | `/guides/understanding-analytics` |
|
||||
| Session History (empty) | Your session history will appear here | Every troubleshooting session is recorded with decisions, timing, and outcomes — ready for export or review. | Start a Session | `/guides/running-sessions` |
|
||||
| Integrations | Connect your PSA for seamless workflows | Link ConnectWise or other PSA tools to pull ticket context into sessions and push documentation back automatically. | Connect Integration | `/guides/psa-setup` |
|
||||
| Step Library (empty) | Build a reusable step library | Save common troubleshooting steps once, reuse them across flows. Keeps your team consistent and saves build time. | Browse Steps | `/guides/step-library` |
|
||||
| Script Library (empty) | Automate with script templates | Pre-built and custom scripts your team can reference during sessions. PowerShell, bash, and more. | Explore Templates | `/guides/script-templates` |
|
||||
| My Shares (empty) | Share session results with your team | Create shareable links to completed sessions for knowledge sharing and client communication. | View Sessions | `/guides/sharing-sessions` |
|
||||
|
||||
### Illustrations
|
||||
|
||||
Simple SVG line art using the cyan brand color palette (`#06b6d4` → `#22d3ee`). Each page gets a unique illustration relevant to its content. Lightweight — no complex animations or heavy graphics.
|
||||
|
||||
### Visual Style
|
||||
|
||||
- Container: centered content within the page's existing layout
|
||||
- Illustration: 60-80px height, `opacity: 0.4` → `0.7` range (needs to be visible on `#101114` dark background)
|
||||
- Title: `text-foreground`, `text-lg` (18px), `font-semibold` (matches existing `EmptyState.tsx`)
|
||||
- Description: `text-muted-foreground`, 13px, max-width ~400px for readability
|
||||
- CTA: `bg-gradient-brand` primary button style
|
||||
- Learn more: `text-muted-foreground` with `→` arrow, hover brightens
|
||||
|
||||
---
|
||||
|
||||
## 2. Onboarding Starter Checklist
|
||||
|
||||
### Location
|
||||
|
||||
Dismissible `.glass-card` widget on QuickStartPage, positioned below the greeting and above the stats/activity sections.
|
||||
|
||||
### Visibility Rules
|
||||
|
||||
- Shows for users who haven't dismissed it and haven't completed all items
|
||||
- Auto-hides with a brief "You're all set!" state once all items are checked, then disappears
|
||||
- Dismissible at any time via "×" button
|
||||
- Dismissed/completed state stored in a new `onboarding_dismissed` Boolean column on the `users` table (requires migration). Not using JSON — a simple column is clearer and queryable.
|
||||
- Never reappears once dismissed or completed
|
||||
|
||||
### Completion Tracking
|
||||
|
||||
No new database table. A single API endpoint queries existing data to determine completion status.
|
||||
|
||||
**Endpoint:** `GET /api/v1/users/onboarding-status`
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"created_flow": true,
|
||||
"ran_session": false,
|
||||
"exported_session": false,
|
||||
"tried_ai_assistant": false,
|
||||
"invited_teammate": false,
|
||||
"connected_psa": false,
|
||||
"is_team_user": true,
|
||||
"dismissed": false
|
||||
}
|
||||
```
|
||||
|
||||
**Completion queries:**
|
||||
|
||||
| Item | Condition |
|
||||
|------|-----------|
|
||||
| `created_flow` | User owns at least 1 tree |
|
||||
| `ran_session` | User has at least 1 session |
|
||||
| `exported_session` | User has at least 1 session with `exported=True` |
|
||||
| `tried_ai_assistant` | User has at least 1 assistant chat |
|
||||
| `invited_teammate` | Team has more than 1 member |
|
||||
| `connected_psa` | Team has at least 1 PSA connection |
|
||||
|
||||
**Dismiss endpoint:** `POST /api/v1/users/onboarding-status/dismiss` — sets `onboarding_dismissed=True` on the user record.
|
||||
|
||||
### Checklist Variants
|
||||
|
||||
**Solo pro (4 items):**
|
||||
|
||||
1. Create your first flow → navigates to Flow Library
|
||||
2. Run your first session → navigates to Flow Library
|
||||
3. Export a session → navigates to Session History
|
||||
4. Try the AI assistant → navigates to AI Chat
|
||||
|
||||
**Team admin (5 items):**
|
||||
|
||||
1. Create your first flow → navigates to Flow Library
|
||||
2. Invite a team member → navigates to Team Settings
|
||||
3. Run your first session → navigates to Flow Library
|
||||
4. Connect a PSA integration → navigates to Integrations
|
||||
5. Export a session → navigates to Session History
|
||||
|
||||
### Visual Design
|
||||
|
||||
- `.glass-card` container with `border-radius: 16px`
|
||||
- Cyan progress bar at top showing completion (e.g., "2 of 5 complete")
|
||||
- Section label: "Getting Started" in `font-label text-[0.625rem] uppercase tracking-[0.1em]`
|
||||
- Each item: checkbox (auto-checked with cyan fill when complete) + label + subtle navigation arrow
|
||||
- Completed items: muted text with cyan checkmark
|
||||
- Uncompleted items: `text-foreground` with hover highlight, clickable to navigate
|
||||
|
||||
---
|
||||
|
||||
## 3. Team Branding & Logo Upload
|
||||
|
||||
### Location
|
||||
|
||||
New "Branding" section on the existing Team Settings page. Team admin only. Solo pros get a simpler version on their Account Settings page.
|
||||
|
||||
### Fields
|
||||
|
||||
- **Company logo** — image upload (PNG, JPG, or SVG, max 2MB)
|
||||
- **Company display name** — text field, falls back to team name if empty
|
||||
- **Logo preview** — shows how the logo will appear on exports
|
||||
|
||||
### Backend
|
||||
|
||||
**New columns on `teams` table:**
|
||||
|
||||
- `logo_data` — Text, base64-encoded image data, nullable
|
||||
- `logo_content_type` — String (e.g., `image/png`), nullable
|
||||
- `company_display_name` — String, nullable (falls back to `team.name`)
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
- `PATCH /api/v1/teams/{team_id}/branding` — upload logo (multipart form) + display name. Team admin only.
|
||||
- `GET /api/v1/teams/{team_id}/branding` — retrieve logo data + display name. Any team member.
|
||||
- `DELETE /api/v1/teams/{team_id}/branding/logo` — remove logo. Team admin only.
|
||||
|
||||
**Validation:**
|
||||
|
||||
- File size: max 2MB
|
||||
- Content type: `image/png`, `image/jpeg`, `image/svg+xml`
|
||||
- Solo pros: branding columns (`logo_data`, `logo_content_type`, `company_display_name`) added directly to the `users` table. Same schema as teams. Solo pros without a team still get branded exports.
|
||||
|
||||
### Why Base64 in DB
|
||||
|
||||
Logos are small (< 2MB raw, ~2.67MB base64-encoded) and there's one per team/user. The 2MB validation limit applies to the raw uploaded file size (before base64 encoding). Avoids S3/file storage dependency entirely. Easy to migrate to object storage later when BYOS is implemented for supporting data.
|
||||
|
||||
---
|
||||
|
||||
## 4. PDF Export via WeasyPrint
|
||||
|
||||
### Backend
|
||||
|
||||
**New dependency:** `weasyprint` in `requirements.txt`
|
||||
|
||||
**System dependencies (required by WeasyPrint):**
|
||||
- `libpango1.0-dev`, `libcairo2-dev`, `libgdk-pixbuf2.0-dev`, `libffi-dev`
|
||||
- Must be added to the Railway Dockerfile via `apt-get install`
|
||||
- Local dev: install via system package manager (`apt-get` on Ubuntu/Debian)
|
||||
- CI: add to the e2e job's setup step
|
||||
|
||||
**Export service changes:**
|
||||
|
||||
- New `generate_pdf()` method in `export_service.py`
|
||||
- Renders a Jinja2 HTML template with session data + branding, then converts to PDF via WeasyPrint
|
||||
- Template location: `backend/app/templates/export_pdf.html`
|
||||
|
||||
**Existing endpoint change:**
|
||||
|
||||
- `POST /sessions/{session_id}/export` gains `format: "pdf"` option
|
||||
- Update `SessionExport` schema: change `format` field pattern from `^(text|markdown|html|psa)$` to `^(text|markdown|html|psa|pdf)$`
|
||||
- PDF format returns `Response(content=pdf_bytes, media_type="application/pdf")` with `Content-Disposition: attachment; filename="session-export-{id}.pdf"` header (different return type from the existing `PlainTextResponse` used by other formats — endpoint must branch on format)
|
||||
- Non-PDF formats continue returning `PlainTextResponse` as before
|
||||
|
||||
### PDF Template Structure
|
||||
|
||||
Matches the approved mockup layout:
|
||||
|
||||
1. **Header** — Report type label (e.g., "Troubleshooting Report"), flow title, MSP logo or ResolutionFlow logo, company name
|
||||
2. **Metadata grid** — Engineer, Client, Ticket #, Date, Duration, Outcome (3×2 grid)
|
||||
3. **Summary** — AI-generated session summary (from existing feature)
|
||||
4. **Troubleshooting Path** — Visual timeline with cyan step dots, step titles, and decisions at each node. Final resolution step uses green dot.
|
||||
5. **Supporting Data** — Labeled text snippets (rendered as code blocks) + embedded screenshot images
|
||||
6. **Footer** — Generation timestamp (left) + "Powered by ResolutionFlow" (right)
|
||||
|
||||
### CSS/Styling
|
||||
|
||||
- White background, dark text (print-optimized)
|
||||
- Cyan accent color (`#06b6d4`) for section borders, timeline dots, and branding
|
||||
- `@page` rules for margins, header/footer positioning
|
||||
- Page break before Supporting Data section if content runs long
|
||||
- `break-inside: avoid` on individual supporting data items
|
||||
- JetBrains Mono for code/command output blocks
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
- Add "PDF" to the format selector in `ExportPreviewModal`
|
||||
- PDF option triggers a direct file download (no textarea preview — PDFs aren't editable inline). The modal should switch to a "download-only" mode when PDF is selected: hide the textarea, show a download button with loading state. The format selector stays visible for switching between formats.
|
||||
- Show a loading spinner while PDF generates server-side
|
||||
- Existing formats (markdown, text, HTML, PSA) continue to work as before with the textarea preview
|
||||
|
||||
### Branding Logic
|
||||
|
||||
1. If team has a logo → use team logo + company display name in header, "Powered by ResolutionFlow" in footer
|
||||
2. If no team logo → use ResolutionFlow logo in header, no "Powered by" footer (it's already the primary brand)
|
||||
3. Solo pro with logo → same as team logo behavior
|
||||
|
||||
---
|
||||
|
||||
## 5. Supporting Data Capture
|
||||
|
||||
### Database
|
||||
|
||||
**New table: `session_supporting_data`**
|
||||
|
||||
| Column | Type | Notes |
|
||||
|--------|------|-------|
|
||||
| `id` | UUID | Primary key |
|
||||
| `session_id` | UUID | FK to sessions |
|
||||
| `label` | String(255) | User-provided label (e.g., "Port Scan Output") |
|
||||
| `data_type` | Enum | `text_snippet` or `screenshot` |
|
||||
| `content` | Text | Raw text or base64-encoded image |
|
||||
| `content_type` | String(50) | Nullable. e.g., `image/png` for screenshots |
|
||||
| `sort_order` | Integer | Display ordering |
|
||||
| `created_at` | DateTime(timezone=True) | Auto-set |
|
||||
| `updated_at` | DateTime(timezone=True) | Auto-set, auto-update |
|
||||
|
||||
### API Endpoints
|
||||
|
||||
- `POST /api/v1/sessions/{session_id}/supporting-data` — add an item (label, type, content). Returns created item.
|
||||
- `GET /api/v1/sessions/{session_id}/supporting-data` — list all items for a session, ordered by `sort_order`.
|
||||
- `PATCH /api/v1/sessions/{session_id}/supporting-data/{id}` — update label or content.
|
||||
- `DELETE /api/v1/sessions/{session_id}/supporting-data/{id}` — remove an item.
|
||||
|
||||
### Validation
|
||||
|
||||
- Image size: max 2MB per screenshot (keeps DB growth manageable — at 20 items × 2.67MB base64 = ~53MB worst case per session)
|
||||
- Text snippet: max 50,000 characters
|
||||
- Max 20 items per session
|
||||
- Only the session owner or team admins can add/delete
|
||||
- Monitor DB size growth in production — if supporting data exceeds expectations, prioritize BYOS migration
|
||||
|
||||
### Session Runner UI
|
||||
|
||||
- **"Add Supporting Data" button** — positioned near the existing notes input in both troubleshooting and procedural session runners
|
||||
- **Add modal** with two tabs/options:
|
||||
- **Text Snippet** — label input + multiline textarea
|
||||
- **Screenshot** — label input + drag-and-drop zone / file picker + clipboard paste (`Ctrl+V`) support
|
||||
- **Supporting data list** — collapsible section below session notes showing added items:
|
||||
- Each item: type icon (code bracket for text, image icon for screenshot) + label + preview (truncated text or thumbnail) + delete button
|
||||
- No reordering in v1 — items display in creation order
|
||||
|
||||
### Export Integration
|
||||
|
||||
Supporting data is included in all export formats:
|
||||
|
||||
| Format | Text Snippets | Screenshots |
|
||||
|--------|--------------|-------------|
|
||||
| Markdown | Labeled fenced code blocks | `` or `[Screenshot: label]` |
|
||||
| Plain Text | Labeled indented blocks | `[Screenshot: {label}]` placeholder |
|
||||
| HTML | `<pre>` blocks with labels | `<img>` tags with base64 src |
|
||||
| PSA | Labeled code blocks (markdown) | `[Screenshot: {label}]` placeholder |
|
||||
| PDF | Styled code blocks matching mockup | Embedded images |
|
||||
|
||||
---
|
||||
|
||||
## 6. User Guides
|
||||
|
||||
### Route
|
||||
|
||||
`/guides/:slug` — new frontend route inside the authenticated app shell.
|
||||
|
||||
### Implementation
|
||||
|
||||
- Markdown files stored in `frontend/src/content/guides/`
|
||||
- Written as React components in `frontend/src/pages/guides/` (avoids `react-markdown` dependency for only 7 short pages). Each guide is a simple functional component using existing typography classes.
|
||||
- Displayed in a `.glass-card-static` container within the standard app shell layout
|
||||
- Simple breadcrumb: "Guides → {title}"
|
||||
- Images/illustrations stored in `frontend/public/guides/` and referenced via absolute paths
|
||||
- Unknown slugs show a "Guide not found" empty state with a link back to the dashboard
|
||||
|
||||
### Guides (7)
|
||||
|
||||
| Slug | Title | Content Covers |
|
||||
|------|-------|---------------|
|
||||
| `creating-flows` | Creating Flows | Manual flow creation, AI-assisted creation, flow types (troubleshooting, procedural, maintenance), basic editor usage |
|
||||
| `understanding-analytics` | Understanding Analytics | What each metric means, how data populates over time, team vs personal views |
|
||||
| `running-sessions` | Running Sessions | Starting a session, navigating decisions, adding notes, adding supporting data, completing and exporting |
|
||||
| `psa-setup` | Connecting Your PSA | ConnectWise setup walkthrough, where to find API credentials, what the integration enables |
|
||||
| `step-library` | Using the Step Library | Browsing shared steps, adding steps to flows, creating reusable steps |
|
||||
| `script-templates` | Script Templates | Browsing templates, using scripts during sessions, creating custom templates |
|
||||
| `sharing-sessions` | Sharing Sessions | Creating share links, public vs account-only access, revoking shares |
|
||||
|
||||
### Guide Content Style
|
||||
|
||||
- Concise — each guide should be 300-600 words
|
||||
- Task-oriented — "How to do X" structure, not reference documentation
|
||||
- Include relevant screenshots/illustrations where helpful
|
||||
- End with a CTA that links back to the relevant feature page
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing
|
||||
|
||||
### Backend Integration Tests (pytest)
|
||||
|
||||
**Onboarding status:**
|
||||
- Returns correct booleans for a fresh user (all false)
|
||||
- Returns `created_flow: true` after user creates a tree
|
||||
- Returns `ran_session: true` after user starts a session
|
||||
- Returns correct `is_team_user` flag
|
||||
- Dismiss endpoint sets `dismissed: true`
|
||||
|
||||
**Team branding:**
|
||||
- Upload logo — stores base64, returns success
|
||||
- Upload oversized file — returns 400
|
||||
- Upload invalid content type — returns 400
|
||||
- Retrieve branding — returns logo data + display name
|
||||
- Delete logo — clears logo data
|
||||
- Non-admin cannot update branding — returns 403
|
||||
|
||||
**Supporting data:**
|
||||
- Create text snippet — stores and returns item
|
||||
- Create screenshot — stores base64 and returns item
|
||||
- List items — returns in sort order
|
||||
- Delete item — removes from DB
|
||||
- Exceed 20 item limit — returns 400
|
||||
- Exceed 2MB screenshot — returns 400
|
||||
- Non-owner cannot add to session — returns 403
|
||||
|
||||
**PDF export:**
|
||||
- Generate PDF — returns valid PDF bytes with correct content type
|
||||
- PDF includes branding when team has logo
|
||||
- PDF uses ResolutionFlow defaults when no team logo
|
||||
- PDF includes supporting data items
|
||||
- PDF handles session with no supporting data gracefully
|
||||
|
||||
### Frontend Unit Tests (Vitest)
|
||||
|
||||
- `EmptyState` component — renders illustration, title, description, CTA, and learn more link with correct props
|
||||
- `EmptyState` without optional props — renders without illustration or learn more link
|
||||
- Onboarding checklist — renders correct items for solo vs team user
|
||||
- Onboarding checklist — completed items show cyan checkmark and muted style
|
||||
- Onboarding checklist — dismiss button calls dismiss endpoint
|
||||
- Export format selector — includes PDF option
|
||||
- Export modal — PDF selection triggers download behavior instead of textarea preview
|
||||
|
||||
### Playwright E2E Tests
|
||||
|
||||
- **Empty state flow** — log in as fresh user, navigate to Flow Library, verify empty state renders with CTA and "Learn more" link
|
||||
- **Onboarding checklist** — log in as fresh user, verify checklist visible on dashboard, create a flow, verify checklist item updates
|
||||
- **PDF export** — complete a session, navigate to session detail, select PDF format, verify download triggers
|
||||
- **Guide page** — click "Learn more" from an empty state, verify guide page loads with content
|
||||
|
||||
These Playwright tests focus on happy paths only — one representative flow per feature area.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order (Bottom-Up)
|
||||
|
||||
### PR 1: Backend Foundation
|
||||
- `onboarding_dismissed` column on `users` table + migration
|
||||
- Team branding columns (`logo_data`, `logo_content_type`, `company_display_name`) on `teams` table + migration
|
||||
- Solo pro branding columns on `users` table + migration (can combine with onboarding migration)
|
||||
- Branding CRUD endpoints
|
||||
- Supporting data table + migration
|
||||
- Supporting data CRUD endpoints (POST, GET, PATCH, DELETE)
|
||||
- Onboarding status endpoint + dismiss endpoint
|
||||
- WeasyPrint dependency + system deps in Dockerfile + PDF generation in export service
|
||||
- Update `SessionExport` schema format pattern to include `pdf`
|
||||
- Backend tests for all of the above
|
||||
|
||||
### PR 2: Empty States + Guides
|
||||
- Upgrade `EmptyState.tsx` component
|
||||
- Roll out across 8 pages
|
||||
- Create 7 markdown guides
|
||||
- Add `/guides/:slug` route
|
||||
- Frontend unit tests for EmptyState
|
||||
|
||||
### PR 3: Onboarding Checklist
|
||||
- QuickStartPage checklist widget
|
||||
- Solo vs team variant logic
|
||||
- Dismiss and auto-complete behavior
|
||||
- Frontend unit tests
|
||||
- Playwright test for checklist
|
||||
|
||||
### PR 4: PDF Export + Supporting Data UI
|
||||
- Supporting data capture in session runner
|
||||
- PDF format option in ExportPreviewModal
|
||||
- Team branding section on Team Settings page
|
||||
- Playwright tests for export and empty states
|
||||
|
||||
---
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- **BYOS (Bring Your Own Storage):** Allow MSPs to configure their own S3/Azure blob storage for supporting data. Removes our storage burden and addresses data sovereignty.
|
||||
- **Premium branding tier:** Option to remove "Powered by ResolutionFlow" footer for higher-tier plans.
|
||||
- **Full file attachments:** Extend supporting data to accept arbitrary file types (logs, configs, CSVs) once object storage is in place.
|
||||
- **Export templates:** Let teams customize the PDF template layout, colors, and sections included.
|
||||
- **Onboarding expansion:** Feature tours, tooltips, and contextual help beyond the starter checklist.
|
||||
23
frontend/src/api/branding.ts
Normal file
23
frontend/src/api/branding.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
export interface BrandingInfo {
|
||||
company_display_name: string | null
|
||||
logo_content_type: string | null
|
||||
has_logo: boolean
|
||||
}
|
||||
|
||||
export async function getBranding(teamId: string): Promise<BrandingInfo> {
|
||||
const response = await apiClient.get(`/teams/${teamId}/branding`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function updateBranding(teamId: string, formData: FormData): Promise<BrandingInfo> {
|
||||
const response = await apiClient.patch(`/teams/${teamId}/branding`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function deleteLogo(teamId: string): Promise<void> {
|
||||
await apiClient.delete(`/teams/${teamId}/branding/logo`)
|
||||
}
|
||||
21
frontend/src/api/onboarding.ts
Normal file
21
frontend/src/api/onboarding.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
export interface OnboardingStatus {
|
||||
created_flow: boolean
|
||||
ran_session: boolean
|
||||
exported_session: boolean
|
||||
tried_ai_assistant: boolean
|
||||
invited_teammate: boolean
|
||||
connected_psa: boolean
|
||||
is_team_user: boolean
|
||||
dismissed: boolean
|
||||
}
|
||||
|
||||
export async function getOnboardingStatus(): Promise<OnboardingStatus> {
|
||||
const response = await apiClient.get('/users/onboarding-status')
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function dismissOnboarding(): Promise<void> {
|
||||
await apiClient.post('/users/onboarding-status/dismiss')
|
||||
}
|
||||
39
frontend/src/api/supportingData.ts
Normal file
39
frontend/src/api/supportingData.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { apiClient } from './client'
|
||||
|
||||
export interface SupportingDataItem {
|
||||
id: string
|
||||
session_id: string
|
||||
label: string
|
||||
data_type: 'text_snippet' | 'screenshot'
|
||||
content: string
|
||||
content_type: string | null
|
||||
sort_order: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export async function getSupportingData(sessionId: string): Promise<SupportingDataItem[]> {
|
||||
const response = await apiClient.get(`/sessions/${sessionId}/supporting-data`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function createSupportingData(
|
||||
sessionId: string,
|
||||
data: { label: string; data_type: string; content: string; content_type?: string }
|
||||
): Promise<SupportingDataItem> {
|
||||
const response = await apiClient.post(`/sessions/${sessionId}/supporting-data`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function updateSupportingData(
|
||||
sessionId: string,
|
||||
itemId: string,
|
||||
data: { label?: string; content?: string }
|
||||
): Promise<SupportingDataItem> {
|
||||
const response = await apiClient.patch(`/sessions/${sessionId}/supporting-data/${itemId}`, data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export async function deleteSupportingData(sessionId: string, itemId: string): Promise<void> {
|
||||
await apiClient.delete(`/sessions/${sessionId}/supporting-data/${itemId}`)
|
||||
}
|
||||
@@ -1,23 +1,51 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: ReactNode
|
||||
illustration?: ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
action?: ReactNode
|
||||
learnMoreLink?: string
|
||||
learnMoreText?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
||||
export function EmptyState({
|
||||
icon,
|
||||
illustration,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
learnMoreLink,
|
||||
learnMoreText = 'Learn more',
|
||||
className,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center justify-center py-12 text-center', className)}>
|
||||
{icon && <div className="mb-4 text-muted-foreground">{icon}</div>}
|
||||
{illustration && (
|
||||
<div className="mb-6 opacity-60">
|
||||
{illustration}
|
||||
</div>
|
||||
)}
|
||||
{!illustration && icon && (
|
||||
<div className="mb-4 text-muted-foreground">{icon}</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-foreground">{title}</h3>
|
||||
{description && (
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">{description}</p>
|
||||
<p className="mt-2 max-w-sm text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{action && <div className="mt-4">{action}</div>}
|
||||
{learnMoreLink && (
|
||||
<Link
|
||||
to={learnMoreLink}
|
||||
className="mt-3 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{learnMoreText} →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
130
frontend/src/components/common/EmptyStateIllustrations.tsx
Normal file
130
frontend/src/components/common/EmptyStateIllustrations.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* SVG illustrations for EmptyState components.
|
||||
* Each uses the brand cyan palette (#06b6d4 / #22d3ee) at low opacity.
|
||||
* ViewBox: 80x60, simple line art style.
|
||||
*/
|
||||
|
||||
export function FlowIllustration() {
|
||||
return (
|
||||
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Root node */}
|
||||
<circle cx="40" cy="10" r="6" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
|
||||
{/* Branches */}
|
||||
<line x1="40" y1="16" x2="20" y2="34" stroke="#06b6d4" strokeWidth="1.5" />
|
||||
<line x1="40" y1="16" x2="60" y2="34" stroke="#06b6d4" strokeWidth="1.5" />
|
||||
{/* Left child */}
|
||||
<circle cx="20" cy="38" r="5" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
|
||||
{/* Right child */}
|
||||
<circle cx="60" cy="38" r="5" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
|
||||
{/* Leaf branches */}
|
||||
<line x1="20" y1="43" x2="12" y2="52" stroke="#22d3ee" strokeWidth="1" />
|
||||
<line x1="20" y1="43" x2="28" y2="52" stroke="#22d3ee" strokeWidth="1" />
|
||||
<circle cx="12" cy="54" r="3" fill="rgba(34,211,238,0.15)" stroke="#22d3ee" strokeWidth="1" />
|
||||
<circle cx="28" cy="54" r="3" fill="rgba(34,211,238,0.15)" stroke="#22d3ee" strokeWidth="1" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function AnalyticsIllustration() {
|
||||
return (
|
||||
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Bars */}
|
||||
<rect x="12" y="38" width="10" height="16" rx="2" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
|
||||
<rect x="26" y="28" width="10" height="26" rx="2" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
|
||||
<rect x="40" y="20" width="10" height="34" rx="2" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
|
||||
<rect x="54" y="10" width="10" height="44" rx="2" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
|
||||
{/* Baseline */}
|
||||
<line x1="8" y1="56" x2="72" y2="56" stroke="#06b6d4" strokeWidth="1" opacity="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionIllustration() {
|
||||
return (
|
||||
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Card 1 */}
|
||||
<rect x="12" y="8" width="56" height="12" rx="3" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
|
||||
<circle cx="22" cy="14" r="2" fill="#06b6d4" />
|
||||
<line x1="28" y1="14" x2="56" y2="14" stroke="#06b6d4" strokeWidth="1" opacity="0.5" />
|
||||
{/* Card 2 */}
|
||||
<rect x="12" y="24" width="56" height="12" rx="3" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
|
||||
<circle cx="22" cy="30" r="2" fill="#22d3ee" />
|
||||
<line x1="28" y1="30" x2="52" y2="30" stroke="#22d3ee" strokeWidth="1" opacity="0.5" />
|
||||
{/* Card 3 */}
|
||||
<rect x="12" y="40" width="56" height="12" rx="3" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
|
||||
<circle cx="22" cy="46" r="2" fill="#06b6d4" />
|
||||
<line x1="28" y1="46" x2="48" y2="46" stroke="#06b6d4" strokeWidth="1" opacity="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function IntegrationIllustration() {
|
||||
return (
|
||||
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Left box */}
|
||||
<rect x="6" y="18" width="22" height="24" rx="4" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
|
||||
<line x1="12" y1="26" x2="22" y2="26" stroke="#06b6d4" strokeWidth="1" opacity="0.6" />
|
||||
<line x1="12" y1="30" x2="20" y2="30" stroke="#06b6d4" strokeWidth="1" opacity="0.4" />
|
||||
{/* Right box */}
|
||||
<rect x="52" y="18" width="22" height="24" rx="4" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
|
||||
<line x1="58" y1="26" x2="68" y2="26" stroke="#22d3ee" strokeWidth="1" opacity="0.6" />
|
||||
<line x1="58" y1="30" x2="66" y2="30" stroke="#22d3ee" strokeWidth="1" opacity="0.4" />
|
||||
{/* Dashed arrows */}
|
||||
<line x1="30" y1="26" x2="50" y2="26" stroke="#06b6d4" strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
<line x1="50" y1="34" x2="30" y2="34" stroke="#22d3ee" strokeWidth="1.5" strokeDasharray="3 2" />
|
||||
{/* Arrow tips */}
|
||||
<path d="M48 23 L52 26 L48 29" stroke="#06b6d4" strokeWidth="1.5" fill="none" />
|
||||
<path d="M32 31 L28 34 L32 37" stroke="#22d3ee" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function StepLibraryIllustration() {
|
||||
return (
|
||||
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* List items */}
|
||||
<circle cx="18" cy="14" r="3" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
|
||||
<line x1="26" y1="14" x2="62" y2="14" stroke="#06b6d4" strokeWidth="1.5" opacity="0.5" />
|
||||
<circle cx="18" cy="27" r="3" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
|
||||
<line x1="26" y1="27" x2="58" y2="27" stroke="#22d3ee" strokeWidth="1.5" opacity="0.5" />
|
||||
<circle cx="18" cy="40" r="3" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
|
||||
<line x1="26" y1="40" x2="54" y2="40" stroke="#06b6d4" strokeWidth="1.5" opacity="0.5" />
|
||||
<circle cx="18" cy="53" r="3" fill="rgba(34,211,238,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
|
||||
<line x1="26" y1="53" x2="50" y2="53" stroke="#22d3ee" strokeWidth="1.5" opacity="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ScriptIllustration() {
|
||||
return (
|
||||
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Terminal window */}
|
||||
<rect x="8" y="4" width="64" height="52" rx="4" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
|
||||
{/* Title bar */}
|
||||
<line x1="8" y1="14" x2="72" y2="14" stroke="#06b6d4" strokeWidth="1" opacity="0.3" />
|
||||
<circle cx="16" cy="9" r="2" fill="#06b6d4" opacity="0.4" />
|
||||
<circle cx="23" cy="9" r="2" fill="#22d3ee" opacity="0.4" />
|
||||
{/* Code lines */}
|
||||
<line x1="16" y1="22" x2="40" y2="22" stroke="#06b6d4" strokeWidth="1.5" opacity="0.6" />
|
||||
<line x1="20" y1="30" x2="52" y2="30" stroke="#22d3ee" strokeWidth="1.5" opacity="0.5" />
|
||||
<line x1="20" y1="38" x2="46" y2="38" stroke="#06b6d4" strokeWidth="1.5" opacity="0.4" />
|
||||
<line x1="16" y1="46" x2="36" y2="46" stroke="#22d3ee" strokeWidth="1.5" opacity="0.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShareIllustration() {
|
||||
return (
|
||||
<svg width="80" height="60" viewBox="0 0 80 60" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
{/* Center node */}
|
||||
<circle cx="28" cy="30" r="8" fill="rgba(6,182,212,0.15)" stroke="#06b6d4" strokeWidth="1.5" />
|
||||
{/* Top-right node */}
|
||||
<circle cx="58" cy="14" r="6" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
|
||||
{/* Bottom-right node */}
|
||||
<circle cx="58" cy="46" r="6" fill="rgba(6,182,212,0.15)" stroke="#22d3ee" strokeWidth="1.5" />
|
||||
{/* Connecting lines */}
|
||||
<line x1="35" y1="25" x2="52" y2="17" stroke="#06b6d4" strokeWidth="1.5" />
|
||||
<line x1="35" y1="35" x2="52" y2="43" stroke="#06b6d4" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
92
frontend/src/components/common/__tests__/EmptyState.test.tsx
Normal file
92
frontend/src/components/common/__tests__/EmptyState.test.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { EmptyState } from '../EmptyState'
|
||||
import { FlowIllustration } from '../EmptyStateIllustrations'
|
||||
|
||||
function renderWithRouter(ui: React.ReactElement) {
|
||||
return render(<BrowserRouter>{ui}</BrowserRouter>)
|
||||
}
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('renders title and description', () => {
|
||||
renderWithRouter(
|
||||
<EmptyState
|
||||
title="No items found"
|
||||
description="Try adjusting your filters."
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('No items found')).toBeInTheDocument()
|
||||
expect(screen.getByText('Try adjusting your filters.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders illustration when provided', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<EmptyState
|
||||
title="Empty"
|
||||
illustration={<FlowIllustration />}
|
||||
/>
|
||||
)
|
||||
|
||||
const svg = container.querySelector('svg')
|
||||
expect(svg).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders action button', () => {
|
||||
renderWithRouter(
|
||||
<EmptyState
|
||||
title="No data"
|
||||
action={<button>Create New</button>}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Create New' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders learn more link with correct href', () => {
|
||||
renderWithRouter(
|
||||
<EmptyState
|
||||
title="Get started"
|
||||
learnMoreLink="/guides/creating-flows"
|
||||
/>
|
||||
)
|
||||
|
||||
const link = screen.getByText(/Learn more/i)
|
||||
expect(link).toBeInTheDocument()
|
||||
expect(link).toHaveAttribute('href', '/guides/creating-flows')
|
||||
})
|
||||
|
||||
it('renders custom learn more text', () => {
|
||||
renderWithRouter(
|
||||
<EmptyState
|
||||
title="Get started"
|
||||
learnMoreLink="/guides/test"
|
||||
learnMoreText="View guide"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText(/View guide/i)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders without optional props', () => {
|
||||
renderWithRouter(<EmptyState title="Just a title" />)
|
||||
|
||||
expect(screen.getByText('Just a title')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('link')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('prefers illustration over icon when both provided', () => {
|
||||
const { container } = renderWithRouter(
|
||||
<EmptyState
|
||||
title="Test"
|
||||
icon={<span data-testid="icon">icon</span>}
|
||||
illustration={<FlowIllustration />}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(container.querySelector('svg')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('icon')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
160
frontend/src/components/dashboard/OnboardingChecklist.tsx
Normal file
160
frontend/src/components/dashboard/OnboardingChecklist.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Check, X, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getOnboardingStatus, dismissOnboarding } from '@/api/onboarding'
|
||||
import type { OnboardingStatus } from '@/api/onboarding'
|
||||
|
||||
interface ChecklistItem {
|
||||
key: keyof OnboardingStatus
|
||||
label: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const SOLO_ITEMS: ChecklistItem[] = [
|
||||
{ key: 'created_flow', label: 'Create your first flow', path: '/trees' },
|
||||
{ key: 'ran_session', label: 'Run your first session', path: '/trees' },
|
||||
{ key: 'exported_session', label: 'Export a session', path: '/sessions' },
|
||||
{ key: 'tried_ai_assistant', label: 'Try the AI assistant', path: '/assistant' },
|
||||
]
|
||||
|
||||
const TEAM_ITEMS: ChecklistItem[] = [
|
||||
{ key: 'created_flow', label: 'Create your first flow', path: '/trees' },
|
||||
{ key: 'invited_teammate', label: 'Invite a team member', path: '/account' },
|
||||
{ key: 'ran_session', label: 'Run your first session', path: '/trees' },
|
||||
{ key: 'connected_psa', label: 'Connect a PSA integration', path: '/account/integrations' },
|
||||
{ key: 'exported_session', label: 'Export a session', path: '/sessions' },
|
||||
]
|
||||
|
||||
export function OnboardingChecklist() {
|
||||
const navigate = useNavigate()
|
||||
const [status, setStatus] = useState<OnboardingStatus | null>(null)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [allComplete, setAllComplete] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
getOnboardingStatus()
|
||||
.then(setStatus)
|
||||
.catch(() => {
|
||||
// Silently fail — don't show checklist if endpoint unavailable
|
||||
})
|
||||
}, [])
|
||||
|
||||
const items = status?.is_team_user ? TEAM_ITEMS : SOLO_ITEMS
|
||||
const completedCount = status
|
||||
? items.filter((item) => status[item.key]).length
|
||||
: 0
|
||||
const totalCount = items.length
|
||||
const isAllDone = completedCount === totalCount && status !== null
|
||||
|
||||
useEffect(() => {
|
||||
if (isAllDone) {
|
||||
const timer = setTimeout(() => setAllComplete(true), 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isAllDone])
|
||||
|
||||
// Don't render if dismissed, fully complete, or not loaded yet
|
||||
if (!status || status.dismissed || dismissed || allComplete) return null
|
||||
|
||||
const progressPercent = totalCount > 0 ? (completedCount / totalCount) * 100 : 0
|
||||
|
||||
const handleDismiss = async () => {
|
||||
setDismissed(true)
|
||||
try {
|
||||
await dismissOnboarding()
|
||||
} catch {
|
||||
// Already hidden locally
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="glass-card overflow-hidden fade-in" style={{ animationDelay: '150ms' }}>
|
||||
{/* Progress bar */}
|
||||
<div className="h-1 w-full bg-[rgba(255,255,255,0.04)]">
|
||||
<div
|
||||
className="h-full bg-gradient-brand transition-all duration-500 ease-out"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Getting Started
|
||||
</p>
|
||||
<p className="text-sm text-foreground mt-0.5">
|
||||
{isAllDone ? (
|
||||
<span className="text-gradient-brand font-semibold">You're all set!</span>
|
||||
) : (
|
||||
<span>
|
||||
<span className="text-gradient-brand font-semibold">{completedCount}</span>
|
||||
{' '}of {totalCount} complete
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
|
||||
aria-label="Dismiss onboarding checklist"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Checklist items */}
|
||||
<ul className="space-y-1">
|
||||
{items.map((item) => {
|
||||
const done = status[item.key]
|
||||
return (
|
||||
<li key={item.key}>
|
||||
<button
|
||||
onClick={() => !done && navigate(item.path)}
|
||||
disabled={done}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors text-left',
|
||||
done
|
||||
? 'cursor-default'
|
||||
: 'hover:bg-[rgba(255,255,255,0.04)]'
|
||||
)}
|
||||
>
|
||||
{/* Checkbox */}
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-colors',
|
||||
done
|
||||
? 'bg-gradient-brand border-transparent'
|
||||
: 'border-border'
|
||||
)}
|
||||
>
|
||||
{done && <Check size={12} className="text-[#101114]" />}
|
||||
</span>
|
||||
|
||||
{/* Label */}
|
||||
<span
|
||||
className={cn(
|
||||
'flex-1',
|
||||
done
|
||||
? 'text-muted-foreground line-through'
|
||||
: 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
|
||||
{/* Arrow for incomplete items */}
|
||||
{!done && (
|
||||
<ChevronRight size={14} className="text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FileCode, Search } from 'lucide-react'
|
||||
import { Search } from 'lucide-react'
|
||||
import { useScriptGeneratorStore } from '@/store/scriptGeneratorStore'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { ScriptIllustration } from '@/components/common/EmptyStateIllustrations'
|
||||
import { TemplateCard } from './TemplateCard'
|
||||
|
||||
interface Props {
|
||||
@@ -52,10 +54,13 @@ export function ScriptTemplateList({ inputValue, onClearSearch, onConfigure }: P
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center px-4">
|
||||
<FileCode size={32} className="text-muted-foreground/40" />
|
||||
<p className="text-sm text-muted-foreground">No templates found</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
illustration={<ScriptIllustration />}
|
||||
title="Automate with script templates"
|
||||
description="Pre-built and custom scripts your team can reference during sessions. PowerShell, bash, and more."
|
||||
learnMoreLink="/guides/script-templates"
|
||||
className="px-4"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
263
frontend/src/components/session/AddSupportingDataModal.tsx
Normal file
263
frontend/src/components/session/AddSupportingDataModal.tsx
Normal file
@@ -0,0 +1,263 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { Code2, ImageIcon, Upload } from 'lucide-react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createSupportingData } from '@/api/supportingData'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface AddSupportingDataModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
sessionId: string
|
||||
onAdded: () => void
|
||||
}
|
||||
|
||||
type TabType = 'text_snippet' | 'screenshot'
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
|
||||
export function AddSupportingDataModal({ isOpen, onClose, sessionId, onAdded }: AddSupportingDataModalProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('text_snippet')
|
||||
const [label, setLabel] = useState('')
|
||||
const [textContent, setTextContent] = useState('')
|
||||
const [imageBase64, setImageBase64] = useState<string | null>(null)
|
||||
const [imageContentType, setImageContentType] = useState<string | null>(null)
|
||||
const [imageFileName, setImageFileName] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const resetForm = () => {
|
||||
setLabel('')
|
||||
setTextContent('')
|
||||
setImageBase64(null)
|
||||
setImageContentType(null)
|
||||
setImageFileName(null)
|
||||
setError(null)
|
||||
setActiveTab('text_snippet')
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const processFile = useCallback((file: File) => {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
setError('File must be under 2MB')
|
||||
return
|
||||
}
|
||||
if (!['image/png', 'image/jpeg', 'image/svg+xml'].includes(file.type)) {
|
||||
setError('Only PNG, JPEG, and SVG files are supported')
|
||||
return
|
||||
}
|
||||
setError(null)
|
||||
setImageFileName(file.name)
|
||||
setImageContentType(file.type)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string
|
||||
// Strip the data:... prefix to get raw base64
|
||||
const base64 = result.includes(',') ? result.split(',')[1] : result
|
||||
setImageBase64(base64)
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}, [])
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) processFile(file)
|
||||
}
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items
|
||||
if (!items) return
|
||||
for (const item of items) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
e.preventDefault()
|
||||
const file = item.getAsFile()
|
||||
if (file) processFile(file)
|
||||
return
|
||||
}
|
||||
}
|
||||
}, [processFile])
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!label.trim()) {
|
||||
setError('Label is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (activeTab === 'text_snippet' && !textContent.trim()) {
|
||||
setError('Content is required')
|
||||
return
|
||||
}
|
||||
|
||||
if (activeTab === 'screenshot' && !imageBase64) {
|
||||
setError('Please select or paste an image')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
await createSupportingData(sessionId, {
|
||||
label: label.trim(),
|
||||
data_type: activeTab,
|
||||
content: activeTab === 'text_snippet' ? textContent : imageBase64!,
|
||||
content_type: activeTab === 'screenshot' ? (imageContentType ?? undefined) : undefined,
|
||||
})
|
||||
toast.success('Supporting data added')
|
||||
onAdded()
|
||||
handleClose()
|
||||
} catch (err) {
|
||||
console.error('Failed to add supporting data:', err)
|
||||
setError('Failed to save. Please try again.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} title="Add Supporting Data">
|
||||
{/* Tabs */}
|
||||
<div className="mb-4 flex gap-1 rounded-lg bg-accent p-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('text_snippet')}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
activeTab === 'text_snippet'
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Code2 className="h-4 w-4" />
|
||||
Text Snippet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('screenshot')}
|
||||
className={cn(
|
||||
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
||||
activeTab === 'screenshot'
|
||||
? 'bg-card text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
Screenshot
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<div className="mb-4">
|
||||
<label htmlFor="sd-label" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Label
|
||||
</label>
|
||||
<input
|
||||
id="sd-label"
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => setLabel(e.target.value)}
|
||||
placeholder={activeTab === 'text_snippet' ? 'e.g. Error log output' : 'e.g. Blue screen photo'}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-border bg-card px-3 py-2 text-sm',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Text Snippet Tab Content */}
|
||||
{activeTab === 'text_snippet' && (
|
||||
<div className="mb-4">
|
||||
<label htmlFor="sd-content" className="mb-1 block text-sm font-medium text-foreground">
|
||||
Content
|
||||
</label>
|
||||
<textarea
|
||||
id="sd-content"
|
||||
value={textContent}
|
||||
onChange={(e) => setTextContent(e.target.value)}
|
||||
placeholder="Paste log output, error messages, config snippets..."
|
||||
rows={8}
|
||||
className={cn(
|
||||
'w-full resize-y rounded-md border border-border bg-card px-3 py-2 text-sm',
|
||||
'font-mono text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Screenshot Tab Content */}
|
||||
{activeTab === 'screenshot' && (
|
||||
<div className="mb-4" onPaste={handlePaste}>
|
||||
<label className="mb-1 block text-sm font-medium text-foreground">
|
||||
Image
|
||||
</label>
|
||||
{imageBase64 ? (
|
||||
<div className="relative rounded-md border border-border bg-card p-2">
|
||||
<img
|
||||
src={`data:${imageContentType};base64,${imageBase64}`}
|
||||
alt="Preview"
|
||||
className="mx-auto max-h-48 rounded object-contain"
|
||||
/>
|
||||
<p className="mt-2 text-center text-xs text-muted-foreground">{imageFileName}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setImageBase64(null)
|
||||
setImageContentType(null)
|
||||
setImageFileName(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}}
|
||||
className="mt-2 w-full text-center text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Remove and choose another
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed border-border',
|
||||
'bg-card/50 py-10 transition-colors hover:border-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Upload className="h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Click to upload or paste from clipboard</p>
|
||||
<p className="text-xs text-muted-foreground">PNG, JPEG, or SVG - max 2MB</p>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="mb-4 text-sm text-rose-500">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button variant="secondary" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} loading={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Add'}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddSupportingDataModal
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Copy, Download, Check, RotateCcw } from 'lucide-react'
|
||||
import { Copy, Download, Check, RotateCcw, FileDown } from 'lucide-react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { RedactionSummary } from '@/types'
|
||||
@@ -9,8 +9,10 @@ interface ExportPreviewModalProps {
|
||||
onClose: () => void
|
||||
content: string
|
||||
filename: string
|
||||
format: 'markdown' | 'text' | 'html' | 'psa'
|
||||
format: 'markdown' | 'text' | 'html' | 'psa' | 'pdf'
|
||||
onDownload: (content: string) => void
|
||||
onDownloadPdf?: () => void
|
||||
pdfLoading?: boolean
|
||||
includeSummary?: boolean
|
||||
onToggleSummary?: (include: boolean) => void
|
||||
redactionEnabled?: boolean
|
||||
@@ -25,6 +27,8 @@ export function ExportPreviewModal({
|
||||
filename,
|
||||
format,
|
||||
onDownload,
|
||||
onDownloadPdf,
|
||||
pdfLoading = false,
|
||||
includeSummary = false,
|
||||
onToggleSummary,
|
||||
redactionEnabled = false,
|
||||
@@ -72,7 +76,7 @@ export function ExportPreviewModal({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Filename: <span className="font-mono text-foreground">{filename}</span>
|
||||
<span className="ml-3 rounded bg-accent px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : format === 'psa' ? 'PSA' : 'Plain Text'}
|
||||
{format === 'markdown' ? 'Markdown' : format === 'html' ? 'HTML' : format === 'psa' ? 'PSA' : format === 'pdf' ? 'PDF' : 'Plain Text'}
|
||||
</span>
|
||||
{isModified && (
|
||||
<span className="ml-2 text-xs text-yellow-400">(edited)</span>
|
||||
@@ -130,56 +134,79 @@ export function ExportPreviewModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editable Content */}
|
||||
<label htmlFor="export-content" className="sr-only">
|
||||
Export content
|
||||
</label>
|
||||
<textarea
|
||||
id="export-content"
|
||||
value={editedContent}
|
||||
onChange={(e) => setEditedContent(e.target.value)}
|
||||
className={cn(
|
||||
'h-96 w-full resize-y rounded-md border border-border bg-card p-4',
|
||||
'font-mono text-sm text-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
{format === 'pdf' ? (
|
||||
/* PDF download-only UI */
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<FileDown className="mb-4 h-12 w-12 text-muted-foreground" />
|
||||
<p className="text-muted-foreground mb-4">PDF exports are generated server-side with your team's branding.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDownloadPdf}
|
||||
disabled={pdfLoading}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-6 py-3 text-sm font-semibold text-[#101114]',
|
||||
'hover:opacity-90 active:scale-[0.97] transition-all',
|
||||
'disabled:opacity-60 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{pdfLoading ? 'Generating PDF...' : 'Download PDF'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Editable Content */}
|
||||
<label htmlFor="export-content" className="sr-only">
|
||||
Export content
|
||||
</label>
|
||||
<textarea
|
||||
id="export-content"
|
||||
value={editedContent}
|
||||
onChange={(e) => setEditedContent(e.target.value)}
|
||||
className={cn(
|
||||
'h-96 w-full resize-y rounded-md border border-border bg-card p-4',
|
||||
'font-mono text-sm text-foreground',
|
||||
'focus:border-primary focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium',
|
||||
'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy to Clipboard
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium',
|
||||
'hover:opacity-90 focus:outline-hidden focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div className="mt-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium',
|
||||
'text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||
'focus:outline-hidden focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 text-emerald-400" />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4" />
|
||||
Copy to Clipboard
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownload}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium',
|
||||
'hover:opacity-90 focus:outline-hidden focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
152
frontend/src/components/session/SupportingDataPanel.tsx
Normal file
152
frontend/src/components/session/SupportingDataPanel.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Code2, ImageIcon, Plus, Trash2, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { getSupportingData, deleteSupportingData } from '@/api/supportingData'
|
||||
import type { SupportingDataItem } from '@/api/supportingData'
|
||||
import { AddSupportingDataModal } from './AddSupportingDataModal'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface SupportingDataPanelProps {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export function SupportingDataPanel({ sessionId }: SupportingDataPanelProps) {
|
||||
const [items, setItems] = useState<SupportingDataItem[]>([])
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showAddModal, setShowAddModal] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const loadItems = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await getSupportingData(sessionId)
|
||||
setItems(data)
|
||||
} catch (err) {
|
||||
console.error('Failed to load supporting data:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
loadItems()
|
||||
}, [loadItems])
|
||||
|
||||
const handleDelete = async (itemId: string) => {
|
||||
try {
|
||||
await deleteSupportingData(sessionId, itemId)
|
||||
setItems((prev) => prev.filter((item) => item.id !== itemId))
|
||||
toast.success('Supporting data removed')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete supporting data:', err)
|
||||
toast.error('Failed to remove item')
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdded = () => {
|
||||
loadItems()
|
||||
setIsExpanded(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card/50">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-foreground hover:text-foreground/80"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
Supporting Data
|
||||
{items.length > 0 && (
|
||||
<span className="rounded-full bg-accent px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{items.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-[10px] px-3 py-1.5 text-xs font-medium',
|
||||
'bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97] transition-all'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Items list */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border px-4 py-2">
|
||||
{isLoading && items.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">Loading...</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">
|
||||
No supporting data yet. Add text snippets or screenshots to include in exports.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2 py-1">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border border-border bg-card/30 px-3 py-2.5',
|
||||
'group transition-colors hover:border-[rgba(255,255,255,0.12)]'
|
||||
)}
|
||||
>
|
||||
<span className="mt-0.5 shrink-0 text-muted-foreground">
|
||||
{item.data_type === 'text_snippet' ? (
|
||||
<Code2 className="h-4 w-4" />
|
||||
) : (
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">{item.label}</p>
|
||||
{item.data_type === 'text_snippet' && item.content && (
|
||||
<p className="mt-0.5 truncate text-xs font-mono text-muted-foreground">
|
||||
{item.content.length > 120 ? item.content.slice(0, 120) + '...' : item.content}
|
||||
</p>
|
||||
)}
|
||||
{item.data_type === 'screenshot' && item.content && (
|
||||
<img
|
||||
src={`data:${item.content_type || 'image/png'};base64,${item.content}`}
|
||||
alt={item.label}
|
||||
className="mt-1 max-h-16 rounded border border-border object-contain"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="shrink-0 text-muted-foreground opacity-0 transition-opacity hover:text-rose-500 group-hover:opacity-100"
|
||||
title="Remove"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add Modal */}
|
||||
<AddSupportingDataModal
|
||||
isOpen={showAddModal}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
sessionId={sessionId}
|
||||
onAdded={handleAdded}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SupportingDataPanel
|
||||
245
frontend/src/components/settings/BrandingSettings.tsx
Normal file
245
frontend/src/components/settings/BrandingSettings.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Palette, Upload, Trash2, Loader2 } from 'lucide-react'
|
||||
import { getBranding, updateBranding, deleteLogo } from '@/api/branding'
|
||||
import type { BrandingInfo } from '@/api/branding'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface BrandingSettingsProps {
|
||||
teamId: string
|
||||
}
|
||||
|
||||
const MAX_LOGO_SIZE = 2 * 1024 * 1024 // 2MB
|
||||
|
||||
export function BrandingSettings({ teamId }: BrandingSettingsProps) {
|
||||
const [branding, setBranding] = useState<BrandingInfo | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [companyName, setCompanyName] = useState('')
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null)
|
||||
const [logoPreview, setLogoPreview] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadBranding()
|
||||
}, [teamId])
|
||||
|
||||
const loadBranding = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const data = await getBranding(teamId)
|
||||
setBranding(data)
|
||||
setCompanyName(data.company_display_name || '')
|
||||
if (data.has_logo) {
|
||||
// Construct logo URL from the API
|
||||
const token = localStorage.getItem('access_token')
|
||||
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
setLogoPreview(`${baseUrl}/api/v1/teams/${teamId}/branding/logo?token=${token}`)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load branding:', err)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (file.size > MAX_LOGO_SIZE) {
|
||||
setError('Logo must be under 2MB')
|
||||
return
|
||||
}
|
||||
if (!['image/png', 'image/jpeg', 'image/svg+xml'].includes(file.type)) {
|
||||
setError('Only PNG, JPEG, and SVG files are supported')
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setLogoFile(file)
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => setLogoPreview(reader.result as string)
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
setError(null)
|
||||
try {
|
||||
const formData = new FormData()
|
||||
if (companyName.trim()) {
|
||||
formData.append('company_display_name', companyName.trim())
|
||||
} else {
|
||||
formData.append('company_display_name', '')
|
||||
}
|
||||
if (logoFile) {
|
||||
formData.append('logo', logoFile)
|
||||
}
|
||||
|
||||
const updated = await updateBranding(teamId, formData)
|
||||
setBranding(updated)
|
||||
setLogoFile(null)
|
||||
toast.success('Branding settings saved')
|
||||
} catch (err) {
|
||||
console.error('Failed to save branding:', err)
|
||||
setError('Failed to save. Please try again.')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteLogo = async () => {
|
||||
setIsDeleting(true)
|
||||
try {
|
||||
await deleteLogo(teamId)
|
||||
setBranding((prev) => prev ? { ...prev, has_logo: false, logo_content_type: null } : prev)
|
||||
setLogoPreview(null)
|
||||
setLogoFile(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
toast.success('Logo removed')
|
||||
} catch (err) {
|
||||
console.error('Failed to delete logo:', err)
|
||||
toast.error('Failed to remove logo')
|
||||
} finally {
|
||||
setIsDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="glass-card-static p-4 sm:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Branding</h2>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasChanges = companyName !== (branding?.company_display_name || '') || logoFile !== null
|
||||
|
||||
return (
|
||||
<div className="glass-card-static p-4 sm:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Branding</h2>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Customize your company branding for PDF exports
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-5">
|
||||
{/* Company Display Name */}
|
||||
<div>
|
||||
<label htmlFor="company-name" className="block text-sm font-medium text-foreground">
|
||||
Company Display Name
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Shown in the header of exported PDF reports
|
||||
</p>
|
||||
<input
|
||||
id="company-name"
|
||||
type="text"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
placeholder="Your Company Name"
|
||||
className={cn(
|
||||
'mt-2 w-full max-w-md rounded-md border border-border bg-card px-3 py-2 text-sm',
|
||||
'text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Company Logo
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
PNG, JPEG, or SVG - max 2MB. Displayed in PDF export headers.
|
||||
</p>
|
||||
|
||||
{logoPreview ? (
|
||||
<div className="mt-2 flex items-start gap-4">
|
||||
<div className="rounded-lg border border-border bg-card/50 p-3">
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Logo preview"
|
||||
className="max-h-16 max-w-[200px] object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Change logo
|
||||
</button>
|
||||
{branding?.has_logo && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDeleteLogo}
|
||||
disabled={isDeleting}
|
||||
className="flex items-center gap-1 text-sm text-rose-500 hover:text-rose-400 disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
)}
|
||||
Remove logo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
'mt-2 flex max-w-md cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-border',
|
||||
'bg-card/30 py-8 transition-colors hover:border-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<Upload className="h-6 w-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">Click to upload logo</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<p className="text-sm text-rose-500">{error}</p>
|
||||
)}
|
||||
|
||||
{/* Save */}
|
||||
{hasChanges && (
|
||||
<div className="pt-2">
|
||||
<Button onClick={handleSave} loading={isSaving}>
|
||||
{isSaving ? 'Saving...' : 'Save Branding'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default BrandingSettings
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Search, ChevronDown, ChevronUp, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { StepLibraryIllustration } from '@/components/common/EmptyStateIllustrations'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { stepsApi } from '@/api/steps'
|
||||
import { stepCategoriesApi } from '@/api/stepCategories'
|
||||
@@ -259,12 +261,24 @@ export function StepLibraryBrowser({ onInsert, onCreateNew, showCreateButton = f
|
||||
</Button>
|
||||
</div>
|
||||
) : steps.length === 0 ? (
|
||||
<div className="rounded-lg border border-border bg-accent/50 p-12 text-center">
|
||||
<p className="mb-2 text-lg font-medium text-foreground">No steps found</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{hasActiveFilters ? 'Try adjusting your filters' : 'Create your first step to get started!'}
|
||||
</p>
|
||||
</div>
|
||||
hasActiveFilters ? (
|
||||
<div className="rounded-lg border border-border bg-accent/50 p-12 text-center">
|
||||
<p className="mb-2 text-lg font-medium text-foreground">No steps found</p>
|
||||
<p className="text-sm text-muted-foreground">Try adjusting your filters</p>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
illustration={<StepLibraryIllustration />}
|
||||
title="Build a reusable step library"
|
||||
description="Save common troubleshooting steps once, reuse them across flows. Keeps your team consistent and saves build time."
|
||||
action={
|
||||
onCreateNew ? (
|
||||
<Button onClick={onCreateNew}>Browse Steps</Button>
|
||||
) : undefined
|
||||
}
|
||||
learnMoreLink="/guides/step-library"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{/* My Steps */}
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
Wrench,
|
||||
Settings,
|
||||
BarChart3,
|
||||
Terminal,
|
||||
Plug,
|
||||
} from 'lucide-react'
|
||||
|
||||
export interface GuideStep {
|
||||
@@ -492,4 +494,82 @@ export const guides: Guide[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'script-templates',
|
||||
title: 'Script Templates',
|
||||
icon: Terminal,
|
||||
summary: 'Browse, configure, and generate scripts from reusable templates.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Browsing Templates',
|
||||
steps: [
|
||||
{ instruction: 'Click **Scripts** in the sidebar to open the Script Library.' },
|
||||
{ instruction: 'The left pane lists all available templates organized by category.' },
|
||||
{ instruction: 'Use the search bar to filter templates by name or keyword.' },
|
||||
{ instruction: 'Click any template to preview its script content in the right pane.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Configuring and Generating Scripts',
|
||||
steps: [
|
||||
{ instruction: 'Click **Configure** on a template to enter parameter values.' },
|
||||
{ instruction: 'Fill in the required fields (e.g., server name, IP address, credentials).' },
|
||||
{ instruction: 'Click **Generate** to produce a ready-to-run script with your values substituted.' },
|
||||
{ instruction: 'Copy the generated script to your clipboard or download it directly.', tip: 'Double-check generated scripts in a test environment before running them in production.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Managing Templates',
|
||||
steps: [
|
||||
{ instruction: 'Click **Manage Templates** at the top of the Script Library page.' },
|
||||
{ instruction: 'Create new templates with a name, category, script body, and configurable parameters.' },
|
||||
{ instruction: 'Edit or delete existing templates from the management page.' },
|
||||
{ instruction: 'Templates support PowerShell, Bash, Python, and other scripting languages.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: 'psa-setup',
|
||||
title: 'PSA Integration Setup',
|
||||
icon: Plug,
|
||||
summary: 'Connect ConnectWise or other PSA tools to ResolutionFlow.',
|
||||
sections: [
|
||||
{
|
||||
title: 'Getting Your API Credentials',
|
||||
steps: [
|
||||
{ instruction: 'Log in to your ConnectWise PSA instance as an admin.' },
|
||||
{ instruction: 'Navigate to **System > Members > API Members** and create a new API member.' },
|
||||
{ instruction: 'Generate an **API key pair** (public key and private key) for the member.' },
|
||||
{ instruction: 'Note your **Company ID** (the company identifier used to log in) and **Site URL** (e.g., na.myconnectwise.net).', tip: 'Create a dedicated API member for ResolutionFlow with minimal permissions for security.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Connecting in ResolutionFlow',
|
||||
steps: [
|
||||
{ instruction: 'Go to **Account > Integrations** in ResolutionFlow.' },
|
||||
{ instruction: 'Enter a display name, your Site URL, Company ID, Public Key, and Private Key.' },
|
||||
{ instruction: 'Click **Connect** to save the connection.' },
|
||||
{ instruction: 'Click **Test Connection** to verify everything is working correctly.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Member Mapping',
|
||||
steps: [
|
||||
{ instruction: 'After connecting, switch to the **Member Mapping** tab.' },
|
||||
{ instruction: 'Click **Auto-Match by Email** to automatically pair ResolutionFlow users with ConnectWise members by email address.' },
|
||||
{ instruction: 'Manually adjust any unmatched or incorrectly matched members using the dropdowns.' },
|
||||
{ instruction: 'Click **Save Mappings** to apply changes. Mapped members are used when posting session notes to tickets.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'What the Integration Enables',
|
||||
steps: [
|
||||
{ instruction: 'Session documentation can be posted directly to ConnectWise tickets as internal notes.' },
|
||||
{ instruction: 'Ticket context (client info, issue details) can be pulled into sessions for AI-assisted troubleshooting.' },
|
||||
{ instruction: 'Posts are attributed to the correct ConnectWise member based on your member mappings.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug } from 'lucide-react'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { BrandingSettings } from '@/components/settings/BrandingSettings'
|
||||
import { accountsApi } from '@/api/accounts'
|
||||
import type { Account, AccountMember, AccountInvite } from '@/types'
|
||||
import { TransferOwnershipModal } from '@/components/account/TransferOwnershipModal'
|
||||
@@ -21,6 +22,7 @@ export function AccountSettingsPage() {
|
||||
const { isAccountOwner } = usePermissions()
|
||||
const { plan, limits, usage } = useSubscription()
|
||||
const { defaultExportFormat, setDefaultExportFormat } = useUserPreferencesStore()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const subscription = useAuthStore((s) => s.subscription)
|
||||
|
||||
const [account, setAccount] = useState<Account | null>(null)
|
||||
@@ -587,6 +589,11 @@ export function AccountSettingsPage() {
|
||||
<span className="text-muted-foreground group-hover:text-foreground transition-colors">→</span>
|
||||
</Link>
|
||||
|
||||
{/* Branding Section (owners only) */}
|
||||
{isAccountOwner && user?.team_id && (
|
||||
<BrandingSettings teamId={user.team_id} />
|
||||
)}
|
||||
|
||||
{/* Preferences Section */}
|
||||
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'recharts'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { AnalyticsIllustration } from '@/components/common/EmptyStateIllustrations'
|
||||
import { analyticsApi } from '@/api'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import type { PersonalAnalyticsResponse, AnalyticsPeriod } from '@/types'
|
||||
@@ -57,6 +58,7 @@ export default function MyAnalyticsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<EmptyState
|
||||
illustration={<AnalyticsIllustration />}
|
||||
title="Analytics unavailable"
|
||||
description="Failed to load analytics data. Please try again."
|
||||
/>
|
||||
@@ -64,6 +66,27 @@ export default function MyAnalyticsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (data.summary.total_sessions === 0) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<EmptyState
|
||||
illustration={<AnalyticsIllustration />}
|
||||
title="Track your troubleshooting performance"
|
||||
description="Analytics show resolution times, common paths, and team efficiency. Data appears automatically as you complete sessions."
|
||||
action={
|
||||
<Link
|
||||
to="/trees"
|
||||
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||
>
|
||||
Run Your First Session
|
||||
</Link>
|
||||
}
|
||||
learnMoreLink="/guides/analytics"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { summary, time_series, top_flows } = data
|
||||
const outcomeBreakdown = summary.outcome_breakdown
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Globe, Users, Copy, Check, Link2, ExternalLink, Trash2, ArrowLeft } from 'lucide-react'
|
||||
import { Globe, Users, Copy, Check, ExternalLink, Trash2, ArrowLeft } from 'lucide-react'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { ShareIllustration } from '@/components/common/EmptyStateIllustrations'
|
||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
@@ -142,18 +143,17 @@ export default function MySharesPage() {
|
||||
|
||||
{/* Empty state */}
|
||||
{shares.length === 0 ? (
|
||||
<div className="bg-card border border-border rounded-xl">
|
||||
<EmptyState
|
||||
icon={<Link2 className="h-12 w-12" />}
|
||||
title="No shared sessions"
|
||||
description="Share a session from the session detail page to create a link"
|
||||
action={
|
||||
<Button onClick={() => navigate('/sessions')}>
|
||||
Go to Sessions
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<EmptyState
|
||||
illustration={<ShareIllustration />}
|
||||
title="Share session results with your team"
|
||||
description="Create shareable links to completed sessions for knowledge sharing and client communication."
|
||||
action={
|
||||
<Button onClick={() => navigate('/sessions')}>
|
||||
View Sessions
|
||||
</Button>
|
||||
}
|
||||
learnMoreLink="/guides/sharing-exports"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{shares.map((share) => {
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
|
||||
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
|
||||
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
|
||||
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
|
||||
import { SupportingDataPanel } from '@/components/session/SupportingDataPanel'
|
||||
import { integrationsApi, sessionPsaApi } from '@/api/integrations'
|
||||
import { TicketPickerModal } from '@/components/session/TicketPickerModal'
|
||||
import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator'
|
||||
@@ -792,6 +793,13 @@ export function ProceduralNavigationPage() {
|
||||
<StepFeedback stepId={currentStep.id} sessionId={session.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supporting Data */}
|
||||
{session && (
|
||||
<div className="mt-4">
|
||||
<SupportingDataPanel sessionId={session.id} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Copilot - in-flow panel */}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { QuickActions } from '@/components/dashboard/QuickActions'
|
||||
import { OpenSessions } from '@/components/dashboard/OpenSessions'
|
||||
import { RecentActivity } from '@/components/dashboard/RecentActivity'
|
||||
import { PreparedSessions } from '@/components/dashboard/PreparedSessions'
|
||||
import { OnboardingChecklist } from '@/components/dashboard/OnboardingChecklist'
|
||||
|
||||
function timeAgo(dateStr: string): string {
|
||||
const now = Date.now()
|
||||
@@ -278,6 +279,9 @@ export function QuickStartPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Onboarding Checklist */}
|
||||
<OnboardingChecklist />
|
||||
|
||||
{/* Row 1: Calendar + Quick Actions */}
|
||||
<div className="flex gap-4" style={{ alignItems: 'stretch' }}>
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
@@ -32,7 +32,7 @@ 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' | 'psa'>(defaultExportFormat)
|
||||
const [exportFormat, setExportFormat] = useState<'markdown' | 'text' | 'html' | 'psa' | 'pdf'>(defaultExportFormat)
|
||||
const [exportContent, setExportContent] = useState<string | null>(null)
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
@@ -51,6 +51,7 @@ export function SessionDetailPage() {
|
||||
const [redactionMode, setRedactionMode] = useState<'none' | 'mask'>('none')
|
||||
const [redactionSummary, setRedactionSummary] = useState<RedactionSummary | null>(null)
|
||||
const [isGeneratingFlow, setIsGeneratingFlow] = useState(false)
|
||||
const [pdfLoading, setPdfLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
@@ -223,6 +224,31 @@ export function SessionDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
if (!session) return
|
||||
setPdfLoading(true)
|
||||
try {
|
||||
const { apiClient } = await import('@/api/client')
|
||||
const response = await apiClient.post(
|
||||
`/sessions/${session.id}/export`,
|
||||
{ format: 'pdf' },
|
||||
{ responseType: 'blob' }
|
||||
)
|
||||
const url = URL.createObjectURL(response.data)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `session-export-${session.id}.pdf`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
analytics.exportGenerated({ session_id: session.id, format: 'pdf' })
|
||||
} catch (error) {
|
||||
console.error('PDF export failed:', error)
|
||||
toast.error('Failed to generate PDF export')
|
||||
} finally {
|
||||
setPdfLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveAsTree = async (data: SaveAsTreeRequest) => {
|
||||
if (!session) return
|
||||
setIsSavingTree(true)
|
||||
@@ -478,6 +504,7 @@ export function SessionDetailPage() {
|
||||
<option value="text">Plain Text</option>
|
||||
<option value="html">HTML</option>
|
||||
<option value="psa">PSA / Ticket Note</option>
|
||||
<option value="pdf">PDF</option>
|
||||
</select>
|
||||
{session.decisions.length > 1 && (
|
||||
<select
|
||||
@@ -546,6 +573,8 @@ export function SessionDetailPage() {
|
||||
filename={getFilename()}
|
||||
format={exportFormat}
|
||||
onDownload={handleDownload}
|
||||
onDownloadPdf={handleDownloadPdf}
|
||||
pdfLoading={pdfLoading}
|
||||
includeSummary={includeSummary}
|
||||
onToggleSummary={handleToggleSummary}
|
||||
redactionEnabled={redactionMode === 'mask'}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import { treesApi } from '@/api/trees'
|
||||
@@ -9,6 +9,7 @@ import { SessionFilters } from '@/components/session/SessionFilters'
|
||||
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { SessionIllustration } from '@/components/common/EmptyStateIllustrations'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { getSessionResumePath } from '@/lib/routing'
|
||||
@@ -259,19 +260,32 @@ export function SessionHistoryPage() {
|
||||
<Spinner />
|
||||
</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No sessions found"
|
||||
description={filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from
|
||||
? "Try adjusting your filters"
|
||||
: "Complete a flow to see it here"}
|
||||
action={
|
||||
(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from) ? (
|
||||
(filters.ticketNumber || filters.clientName || filters.treeName || filters.dateRange?.from) ? (
|
||||
<EmptyState
|
||||
title="No sessions match your filters"
|
||||
description="Try adjusting your search or filters."
|
||||
action={
|
||||
<button onClick={handleClearFilters} className="text-foreground hover:underline text-sm">
|
||||
Clear all filters
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
illustration={<SessionIllustration />}
|
||||
title="Your session history will appear here"
|
||||
description="Every troubleshooting session is recorded with decisions, timing, and outcomes — ready for export or review."
|
||||
action={
|
||||
<Link
|
||||
to="/trees"
|
||||
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||
>
|
||||
Start a Session
|
||||
</Link>
|
||||
}
|
||||
learnMoreLink="/guides/sessions"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'recharts'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { AnalyticsIllustration } from '@/components/common/EmptyStateIllustrations'
|
||||
import { analyticsApi } from '@/api'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { toast } from '@/lib/toast'
|
||||
@@ -70,6 +71,7 @@ export default function TeamAnalyticsPage() {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<EmptyState
|
||||
illustration={<AnalyticsIllustration />}
|
||||
title="Analytics unavailable"
|
||||
description="Failed to load analytics data. Please try again."
|
||||
/>
|
||||
@@ -77,6 +79,27 @@ export default function TeamAnalyticsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (data.summary.total_sessions === 0) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
<EmptyState
|
||||
illustration={<AnalyticsIllustration />}
|
||||
title="Track your troubleshooting performance"
|
||||
description="Analytics show resolution times, common paths, and team efficiency. Data appears automatically as you complete sessions."
|
||||
action={
|
||||
<Link
|
||||
to="/trees"
|
||||
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||
>
|
||||
Run Your First Session
|
||||
</Link>
|
||||
}
|
||||
learnMoreLink="/guides/analytics"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const { summary, time_series, top_flows, top_engineers } = data
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,6 +3,7 @@ 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'
|
||||
@@ -496,14 +497,29 @@ export function TreeLibraryPage() {
|
||||
<Spinner />
|
||||
</div>
|
||||
) : trees.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No flows found"
|
||||
description={
|
||||
(searchQuery || hasActiveFilters)
|
||||
? 'Try adjusting your filters.'
|
||||
: 'Create your first flow to get started.'
|
||||
}
|
||||
/>
|
||||
(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' && (
|
||||
|
||||
@@ -21,6 +21,7 @@ import { StepFeedback } from '@/components/session/StepFeedback'
|
||||
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
|
||||
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
|
||||
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
|
||||
import { SupportingDataPanel } from '@/components/session/SupportingDataPanel'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { integrationsApi, sessionPsaApi } from '@/api/integrations'
|
||||
import { TicketPickerModal } from '@/components/session/TicketPickerModal'
|
||||
@@ -1204,6 +1205,13 @@ export function TreeNavigationPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Supporting Data */}
|
||||
{session && (
|
||||
<div className="mt-4">
|
||||
<SupportingDataPanel sessionId={session.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Back Button */}
|
||||
{pathTaken.length > 1 && (
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Plug, CheckCircle2, AlertCircle, Loader2, Pencil, Trash2, Shield, History, Ticket, Users, Zap, Save } from 'lucide-react'
|
||||
import { analytics } from '@/lib/analytics'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { IntegrationIllustration } from '@/components/common/EmptyStateIllustrations'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { integrationsApi } from '@/api/integrations'
|
||||
import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types'
|
||||
@@ -254,6 +256,18 @@ export function IntegrationsPage() {
|
||||
{/* Connection Tab */}
|
||||
{activeTab === 'connection' && (
|
||||
<div className="max-w-3xl">
|
||||
{/* Illustrative empty state when no connection exists */}
|
||||
{mode === 'setup' && (
|
||||
<div className="mb-6">
|
||||
<EmptyState
|
||||
illustration={<IntegrationIllustration />}
|
||||
title="Connect your PSA for seamless workflows"
|
||||
description="Link ConnectWise or other PSA tools to pull ticket context into sessions and push documentation back automatically."
|
||||
learnMoreLink="/guides/psa-setup"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Setup / Edit Form */}
|
||||
{(mode === 'setup' || mode === 'edit') && (
|
||||
<div className="glass-card-static p-6">
|
||||
|
||||
@@ -103,7 +103,7 @@ export interface SessionUpdate {
|
||||
}
|
||||
|
||||
export interface SessionExport {
|
||||
format: 'text' | 'markdown' | 'html' | 'psa'
|
||||
format: 'text' | 'markdown' | 'html' | 'psa' | 'pdf'
|
||||
include_timestamps?: boolean
|
||||
include_tree_info?: boolean
|
||||
include_outcome_notes?: boolean
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface User {
|
||||
must_change_password: boolean
|
||||
account_id: string | null
|
||||
account_role: 'owner' | 'engineer' | 'viewer' | null
|
||||
team_id: string | null
|
||||
created_at: string
|
||||
last_login: string | null
|
||||
phone: string | null
|
||||
|
||||
Reference in New Issue
Block a user