feat: AI chat conclusion + survey completion & management #95
36
backend/alembic/versions/048_add_chat_conclusion_fields.py
Normal file
36
backend/alembic/versions/048_add_chat_conclusion_fields.py
Normal file
@@ -0,0 +1,36 @@
|
||||
"""Add conclusion fields to assistant_chats.
|
||||
|
||||
Revision ID: 048
|
||||
Revises: 047
|
||||
Create Date: 2026-03-05
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "048"
|
||||
down_revision: str = "047"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"assistant_chats",
|
||||
sa.Column("conclusion_outcome", sa.String(20), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"assistant_chats",
|
||||
sa.Column("conclusion_summary", sa.String, nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"assistant_chats",
|
||||
sa.Column("concluded_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("assistant_chats", "concluded_at")
|
||||
op.drop_column("assistant_chats", "conclusion_summary")
|
||||
op.drop_column("assistant_chats", "conclusion_outcome")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Add is_read and archived_at to survey_responses.
|
||||
|
||||
Revision ID: 049
|
||||
Revises: 048
|
||||
Create Date: 2026-03-05
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision: str = "049"
|
||||
down_revision: str = "048"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"survey_responses",
|
||||
sa.Column("is_read", sa.Boolean(), nullable=False, server_default="false"),
|
||||
)
|
||||
op.add_column(
|
||||
"survey_responses",
|
||||
sa.Column("archived_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("survey_responses", "archived_at")
|
||||
op.drop_column("survey_responses", "is_read")
|
||||
@@ -4,10 +4,11 @@ import io
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Annotated, Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, update, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import require_admin
|
||||
@@ -96,6 +97,7 @@ async def list_survey_invites(
|
||||
async def list_survey_responses(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
include_archived: bool = False,
|
||||
):
|
||||
"""List all survey responses with summary stats."""
|
||||
stmt = (
|
||||
@@ -103,11 +105,15 @@ async def list_survey_responses(
|
||||
.outerjoin(SurveyInvite, SurveyResponse.invite_id == SurveyInvite.id)
|
||||
.order_by(SurveyResponse.created_at.desc())
|
||||
)
|
||||
if not include_archived:
|
||||
stmt = stmt.where(SurveyResponse.archived_at.is_(None))
|
||||
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
one_week_ago = datetime.now(timezone.utc) - timedelta(days=7)
|
||||
this_week = 0
|
||||
unread = 0
|
||||
responses: list[SurveyResponseDetail] = []
|
||||
|
||||
for survey_resp, invite_name in rows:
|
||||
@@ -119,19 +125,168 @@ async def list_survey_responses(
|
||||
responses=survey_resp.responses,
|
||||
source=source,
|
||||
invite_name=invite_name,
|
||||
is_read=survey_resp.is_read,
|
||||
archived_at=survey_resp.archived_at,
|
||||
created_at=survey_resp.created_at,
|
||||
)
|
||||
)
|
||||
if survey_resp.created_at >= one_week_ago:
|
||||
this_week += 1
|
||||
if not survey_resp.is_read:
|
||||
unread += 1
|
||||
|
||||
return SurveyResponseListResponse(
|
||||
responses=responses,
|
||||
total=len(responses),
|
||||
this_week=this_week,
|
||||
unread=unread,
|
||||
)
|
||||
|
||||
|
||||
@router.put("/survey-responses/{response_id}/read", status_code=200)
|
||||
async def mark_response_read(
|
||||
response_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Mark a survey response as read."""
|
||||
result = await db.execute(
|
||||
select(SurveyResponse).where(SurveyResponse.id == response_id)
|
||||
)
|
||||
resp = result.scalar_one_or_none()
|
||||
if not resp:
|
||||
raise HTTPException(status_code=404, detail="Response not found")
|
||||
|
||||
resp.is_read = True
|
||||
await db.commit()
|
||||
return {"id": str(resp.id), "is_read": True}
|
||||
|
||||
|
||||
@router.put("/survey-responses/{response_id}/unread", status_code=200)
|
||||
async def mark_response_unread(
|
||||
response_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Mark a survey response as unread."""
|
||||
result = await db.execute(
|
||||
select(SurveyResponse).where(SurveyResponse.id == response_id)
|
||||
)
|
||||
resp = result.scalar_one_or_none()
|
||||
if not resp:
|
||||
raise HTTPException(status_code=404, detail="Response not found")
|
||||
|
||||
resp.is_read = False
|
||||
await db.commit()
|
||||
return {"id": str(resp.id), "is_read": False}
|
||||
|
||||
|
||||
@router.put("/survey-responses/{response_id}/archive", status_code=200)
|
||||
async def archive_response(
|
||||
response_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Archive a survey response."""
|
||||
result = await db.execute(
|
||||
select(SurveyResponse).where(SurveyResponse.id == response_id)
|
||||
)
|
||||
resp = result.scalar_one_or_none()
|
||||
if not resp:
|
||||
raise HTTPException(status_code=404, detail="Response not found")
|
||||
|
||||
resp.archived_at = datetime.now(timezone.utc)
|
||||
await db.commit()
|
||||
return {"id": str(resp.id), "archived_at": resp.archived_at.isoformat()}
|
||||
|
||||
|
||||
@router.put("/survey-responses/{response_id}/unarchive", status_code=200)
|
||||
async def unarchive_response(
|
||||
response_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Unarchive a survey response."""
|
||||
result = await db.execute(
|
||||
select(SurveyResponse).where(SurveyResponse.id == response_id)
|
||||
)
|
||||
resp = result.scalar_one_or_none()
|
||||
if not resp:
|
||||
raise HTTPException(status_code=404, detail="Response not found")
|
||||
|
||||
resp.archived_at = None
|
||||
await db.commit()
|
||||
return {"id": str(resp.id), "archived_at": None}
|
||||
|
||||
|
||||
@router.delete("/survey-responses/{response_id}", status_code=204)
|
||||
async def delete_response(
|
||||
response_id: UUID,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
):
|
||||
"""Permanently delete a survey response."""
|
||||
result = await db.execute(
|
||||
select(SurveyResponse).where(SurveyResponse.id == response_id)
|
||||
)
|
||||
resp = result.scalar_one_or_none()
|
||||
if not resp:
|
||||
raise HTTPException(status_code=404, detail="Response not found")
|
||||
|
||||
await db.delete(resp)
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.post("/survey-responses/bulk", status_code=200)
|
||||
async def bulk_action_responses(
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
current_user: Annotated[User, Depends(require_admin)],
|
||||
action: str = Body(...),
|
||||
ids: list[str] = Body(...),
|
||||
):
|
||||
"""Bulk action on survey responses. Actions: mark_read, mark_unread, archive, delete."""
|
||||
from uuid import UUID as _UUID
|
||||
|
||||
uuids = []
|
||||
for id_str in ids:
|
||||
try:
|
||||
uuids.append(_UUID(id_str))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if not uuids:
|
||||
raise HTTPException(status_code=400, detail="No valid IDs provided")
|
||||
|
||||
if action == "mark_read":
|
||||
await db.execute(
|
||||
update(SurveyResponse)
|
||||
.where(SurveyResponse.id.in_(uuids))
|
||||
.values(is_read=True)
|
||||
)
|
||||
elif action == "mark_unread":
|
||||
await db.execute(
|
||||
update(SurveyResponse)
|
||||
.where(SurveyResponse.id.in_(uuids))
|
||||
.values(is_read=False)
|
||||
)
|
||||
elif action == "archive":
|
||||
await db.execute(
|
||||
update(SurveyResponse)
|
||||
.where(SurveyResponse.id.in_(uuids))
|
||||
.values(archived_at=datetime.now(timezone.utc))
|
||||
)
|
||||
elif action == "delete":
|
||||
await db.execute(
|
||||
delete(SurveyResponse)
|
||||
.where(SurveyResponse.id.in_(uuids))
|
||||
)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
|
||||
|
||||
await db.commit()
|
||||
return {"action": action, "count": len(uuids)}
|
||||
|
||||
|
||||
# Question IDs in survey order, used for CSV export columns.
|
||||
QUESTION_IDS = [
|
||||
"prereqs",
|
||||
|
||||
@@ -35,6 +35,8 @@ from app.schemas.assistant_chat import (
|
||||
ChatUpdateRequest,
|
||||
RetentionSettingsResponse,
|
||||
RetentionSettingsUpdate,
|
||||
ConcludeChatRequest,
|
||||
ConcludeChatResponse,
|
||||
)
|
||||
from app.schemas.copilot import SuggestedFlow
|
||||
from app.services import assistant_chat_service
|
||||
@@ -203,6 +205,67 @@ async def post_message(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/chats/{chat_id}/conclude", response_model=ConcludeChatResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def conclude_chat(
|
||||
request: Request,
|
||||
chat_id: UUID,
|
||||
data: ConcludeChatRequest,
|
||||
current_user: Annotated[User, Depends(get_current_active_user)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
_: None = Depends(require_engineer_or_admin),
|
||||
):
|
||||
"""Conclude a chat session and generate ticket-ready summary."""
|
||||
_require_ai_enabled()
|
||||
|
||||
result = await db.execute(
|
||||
select(AssistantChat).where(
|
||||
AssistantChat.id == chat_id,
|
||||
AssistantChat.user_id == current_user.id,
|
||||
)
|
||||
)
|
||||
chat = result.scalar_one_or_none()
|
||||
if not chat:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Chat not found")
|
||||
|
||||
if chat.concluded_at:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Chat already concluded",
|
||||
)
|
||||
|
||||
if chat.message_count < 2:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Chat must have at least one exchange before concluding",
|
||||
)
|
||||
|
||||
try:
|
||||
summary = await assistant_chat_service.generate_conclusion_summary(
|
||||
chat=chat,
|
||||
outcome=data.outcome,
|
||||
notes=data.notes,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to generate conclusion summary: %s", e)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="Failed to generate summary. Please try again.",
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
chat.conclusion_outcome = data.outcome
|
||||
chat.conclusion_summary = summary
|
||||
chat.concluded_at = now
|
||||
await db.commit()
|
||||
|
||||
return ConcludeChatResponse(
|
||||
summary=summary,
|
||||
outcome=data.outcome,
|
||||
concluded_at=now,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/chats/{chat_id}", response_model=ChatDetailResponse)
|
||||
async def update_chat(
|
||||
chat_id: UUID,
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.core.email import EmailService
|
||||
from app.core.rate_limit import limiter
|
||||
from app.models.survey_invite import SurveyInvite
|
||||
from app.models.survey_response import SurveyResponse
|
||||
from app.schemas.survey import SurveyInviteStatus, SurveySubmission, SurveySubmissionResponse
|
||||
from app.schemas.survey import SurveyEmailCopyRequest, SurveyInviteStatus, SurveySubmission, SurveySubmissionResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -88,3 +88,79 @@ async def submit_survey(
|
||||
await db.commit()
|
||||
|
||||
return SurveySubmissionResponse(id=str(response.id))
|
||||
|
||||
|
||||
# Question metadata for formatting email copy
|
||||
_QUESTION_LABELS = {
|
||||
"prereqs": "Q1. Before you start troubleshooting, what info do you need?",
|
||||
"verify_fix": "Q2. After you apply a fix, how do you verify it actually worked?",
|
||||
"steps_at_a_time": "Q3. How many steps do you prefer to see at once?",
|
||||
"first_step": "Q4. \"Internet is down.\" What's your FIRST move?",
|
||||
"junior_mistake": "Q5. Most common mistake junior engineers make?",
|
||||
"pivot": "Q6. When do you stop pursuing one theory and pivot?",
|
||||
"scenario_approach": "Q7. First 3 diagnostic steps for this ticket.",
|
||||
"scenario_deeper": "Q8. Server pings fine, you can RDP in. What next?",
|
||||
"doc_pct": "Q9. Percentage of steps you actually document?",
|
||||
"go_to_commands": "Q10. Top 3 go-to PowerShell commands?",
|
||||
"secret_weapon": "Q11. Secret weapon command/tool/technique?",
|
||||
"gotcha": "Q12. Issue where the obvious diagnosis was WRONG?",
|
||||
"hard_rules": "Q13. Which rules do you follow?",
|
||||
"prioritization": "Q14. Rank factors by diagnostic priority.",
|
||||
"detail_level": "Q15. How specific should AI suggestions be?",
|
||||
"ai_personality": "Q16. What makes an AI feel like a useful colleague?",
|
||||
}
|
||||
|
||||
_QUESTION_ORDER = [
|
||||
"prereqs", "verify_fix", "steps_at_a_time", "first_step", "junior_mistake",
|
||||
"pivot", "scenario_approach", "scenario_deeper", "doc_pct", "go_to_commands",
|
||||
"secret_weapon", "gotcha", "hard_rules", "prioritization", "detail_level", "ai_personality",
|
||||
]
|
||||
|
||||
|
||||
@router.post("/survey/email-copy")
|
||||
@limiter.limit("5/hour")
|
||||
async def email_survey_copy(
|
||||
request: Request,
|
||||
data: SurveyEmailCopyRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
):
|
||||
"""Email a copy of survey responses to the respondent."""
|
||||
from uuid import UUID as _UUID
|
||||
|
||||
try:
|
||||
resp_id = _UUID(data.response_id)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid response ID")
|
||||
|
||||
result = await db.execute(
|
||||
select(SurveyResponse).where(SurveyResponse.id == resp_id)
|
||||
)
|
||||
response = result.scalar_one_or_none()
|
||||
if not response:
|
||||
raise HTTPException(status_code=404, detail="Response not found")
|
||||
|
||||
# Build formatted responses for the email
|
||||
answers = response.responses or {}
|
||||
formatted_lines = []
|
||||
for qid in _QUESTION_ORDER:
|
||||
label = _QUESTION_LABELS.get(qid, qid)
|
||||
val = answers.get(qid)
|
||||
if isinstance(val, list):
|
||||
answer_str = ", ".join(str(v) for v in val)
|
||||
elif val is not None:
|
||||
answer_str = str(val)
|
||||
else:
|
||||
answer_str = "(no answer)"
|
||||
formatted_lines.append(f"{label}\n{answer_str}")
|
||||
|
||||
try:
|
||||
await EmailService.send_survey_copy_email(
|
||||
to_email=data.email,
|
||||
respondent_name=response.respondent_name,
|
||||
formatted_responses="\n\n".join(formatted_lines),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send survey copy email to %s", data.email)
|
||||
raise HTTPException(status_code=502, detail="Failed to send email. Please try again.")
|
||||
|
||||
return {"message": "Email sent"}
|
||||
|
||||
@@ -84,6 +84,9 @@ class Settings(BaseSettings):
|
||||
AI_MODEL_GEMINI: str = "gemini-2.5-flash"
|
||||
AI_MODEL_ANTHROPIC: str = "claude-haiku-4-5-20251001"
|
||||
|
||||
# MCP (Model Context Protocol) integrations
|
||||
ENABLE_MCP_MICROSOFT_LEARN: bool = True
|
||||
|
||||
# Embedding / RAG
|
||||
VOYAGE_API_KEY: Optional[str] = None
|
||||
EMBEDDING_MODEL: str = "voyage-3.5"
|
||||
|
||||
@@ -354,6 +354,70 @@ class EmailService:
|
||||
logger.exception("Failed to send survey notification email")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def send_survey_copy_email(
|
||||
to_email: str,
|
||||
respondent_name: str | None,
|
||||
formatted_responses: str,
|
||||
) -> bool:
|
||||
"""Send a copy of survey responses to the respondent."""
|
||||
if not settings.email_enabled:
|
||||
logger.warning("Email not sent — RESEND_API_KEY not configured")
|
||||
return False
|
||||
|
||||
try:
|
||||
import resend
|
||||
import html as html_mod
|
||||
|
||||
resend.api_key = settings.RESEND_API_KEY
|
||||
|
||||
safe_name = html_mod.escape(respondent_name or "there")
|
||||
safe_responses = html_mod.escape(formatted_responses).replace("\n", "<br>")
|
||||
subject = "Your FlowPilot Survey Responses"
|
||||
|
||||
email_html = f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"></head>
|
||||
<body style="margin:0;padding:0;background:#101114;font-family:'Inter',Helvetica,Arial,sans-serif;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" style="background:#101114;padding:40px 0;">
|
||||
<tr><td align="center">
|
||||
<table width="560" cellpadding="0" cellspacing="0" style="background:#14161a;border:1px solid rgba(255,255,255,0.06);border-radius:16px;">
|
||||
<tr><td style="padding:40px 40px 24px;text-align:center;">
|
||||
<h1 style="margin:0;color:#f8fafc;font-size:24px;font-weight:600;">Resolution<span style="color:#06b6d4;">Flow</span></h1>
|
||||
<p style="margin:8px 0 0;color:#5a6170;font-size:14px;">Your Survey Responses</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 24px;">
|
||||
<p style="margin:0;color:#8891a0;font-size:16px;line-height:1.6;">
|
||||
Hi {safe_name}, here's a copy of your FlowPilot survey responses for your records.
|
||||
</p>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 32px;">
|
||||
<div style="background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.06);border-radius:12px;padding:24px;">
|
||||
<p style="margin:0;color:#e0e0e0;font-size:13px;line-height:1.8;">{safe_responses}</p>
|
||||
</div>
|
||||
</td></tr>
|
||||
<tr><td style="padding:0 40px 32px;">
|
||||
<p style="margin:0;color:#5a6170;font-size:12px;text-align:center;">
|
||||
Thank you for your contribution to FlowPilot research.
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body></html>"""
|
||||
|
||||
resend.Emails.send({
|
||||
"from": settings.FROM_EMAIL,
|
||||
"to": [to_email],
|
||||
"subject": subject,
|
||||
"html": email_html,
|
||||
})
|
||||
logger.info("Survey copy email sent to %s", to_email)
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to send survey copy email to %s", to_email)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
async def send_survey_invite_email(
|
||||
to_email: str,
|
||||
|
||||
@@ -49,6 +49,15 @@ class AssistantChat(Base):
|
||||
pinned: Mapped[bool] = mapped_column(
|
||||
Boolean, nullable=False, default=False
|
||||
)
|
||||
conclusion_outcome: Mapped[Optional[str]] = mapped_column(
|
||||
String(20), nullable=True
|
||||
)
|
||||
conclusion_summary: Mapped[Optional[str]] = mapped_column(
|
||||
String, nullable=True
|
||||
)
|
||||
concluded_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Column, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
|
||||
from app.core.database import Base
|
||||
@@ -17,4 +17,6 @@ class SurveyResponse(Base):
|
||||
ip_address = Column(String(45), nullable=True)
|
||||
user_agent = Column(Text, nullable=True)
|
||||
invite_id = Column(UUID(as_uuid=True), ForeignKey("survey_invites.id"), nullable=True)
|
||||
is_read = Column(Boolean, nullable=False, default=False)
|
||||
archived_at = Column(DateTime(timezone=True), nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Pydantic schemas for standalone AI assistant chat."""
|
||||
from typing import Optional, Any
|
||||
from typing import Optional, Any, Literal
|
||||
from uuid import UUID
|
||||
from datetime import datetime
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -57,3 +57,14 @@ class RetentionSettingsResponse(BaseModel):
|
||||
class RetentionSettingsUpdate(BaseModel):
|
||||
chat_retention_days: Optional[int] = Field(None, ge=1, le=365)
|
||||
chat_retention_max_count: Optional[int] = Field(None, ge=10, le=10000)
|
||||
|
||||
|
||||
class ConcludeChatRequest(BaseModel):
|
||||
outcome: Literal["resolved", "escalated", "paused"]
|
||||
notes: Optional[str] = Field(None, max_length=2000)
|
||||
|
||||
|
||||
class ConcludeChatResponse(BaseModel):
|
||||
summary: str
|
||||
outcome: str
|
||||
concluded_at: datetime
|
||||
|
||||
@@ -46,6 +46,12 @@ class SurveyInviteStatus(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
class SurveyEmailCopyRequest(BaseModel):
|
||||
"""Request to email a copy of responses to the respondent."""
|
||||
email: str = Field(..., max_length=255)
|
||||
response_id: str
|
||||
|
||||
|
||||
class SurveyResponseDetail(BaseModel):
|
||||
"""Full survey response returned to admin."""
|
||||
id: str
|
||||
@@ -53,6 +59,8 @@ class SurveyResponseDetail(BaseModel):
|
||||
responses: dict[str, Any]
|
||||
source: str
|
||||
invite_name: Optional[str]
|
||||
is_read: bool = False
|
||||
archived_at: Optional[datetime] = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -61,3 +69,4 @@ class SurveyResponseListResponse(BaseModel):
|
||||
responses: list[SurveyResponseDetail]
|
||||
total: int
|
||||
this_week: int
|
||||
unread: int
|
||||
|
||||
@@ -2,37 +2,224 @@
|
||||
|
||||
Provides persistent conversation history for general IT questions
|
||||
with semantic search over the team's flow library.
|
||||
|
||||
Uses Anthropic prompt caching to reduce cost on multi-turn conversations:
|
||||
- The static system prompt is cached (ephemeral, 5-min TTL)
|
||||
- The conversation history prefix is cached via a breakpoint on the
|
||||
last existing message before the new user input
|
||||
|
||||
Optionally connects to Microsoft Learn via Anthropic's MCP connector
|
||||
for real-time documentation lookups (controlled by ENABLE_MCP_MICROSOFT_LEARN).
|
||||
"""
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.ai_provider import get_ai_provider
|
||||
from app.core.config import settings
|
||||
from app.models.assistant_chat import AssistantChat
|
||||
from app.services.rag_service import search as rag_search, build_rag_context, extract_suggested_flows
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ASSISTANT_SYSTEM_PROMPT = """You are a Senior Systems and Network Engineer with 15+ years of experience working in Managed Service Provider (MSP) environments. You specialize in:
|
||||
- Windows Server, Active Directory, Group Policy, and Hybrid Identity (Entra ID)
|
||||
- Networking (TCP/IP, DNS, DHCP, VPN, firewall troubleshooting, Cisco/Fortinet)
|
||||
- Virtualization (VMware, Hyper-V) and cloud platforms (Azure, AWS, M365)
|
||||
- Endpoint management, RMM tools, and PSA platforms (ConnectWise, Datto, Kaseya)
|
||||
- PowerShell scripting and automation
|
||||
ASSISTANT_SYSTEM_PROMPT = """\
|
||||
You are ResolutionFlow Assistant — an expert IT systems engineer embedded in a \
|
||||
troubleshooting platform built for Managed Service Provider (MSP) teams.
|
||||
|
||||
When answering:
|
||||
- Be direct and actionable — MSP engineers need fast, practical answers
|
||||
- Include specific commands, paths, and config values when relevant
|
||||
- Mention potential risks or gotchas before suggesting changes
|
||||
- If a relevant troubleshooting flow exists in the team's library, reference it
|
||||
- Keep responses concise but thorough — prefer bullet points and code blocks
|
||||
- Format code with proper markdown code blocks
|
||||
## Your Role
|
||||
You are a senior peer helping fellow MSP engineers solve problems fast. You have \
|
||||
deep expertise across the MSP technology stack:
|
||||
- Windows Server, Active Directory, Group Policy, Hybrid Identity (Entra ID / Azure AD)
|
||||
- Networking: TCP/IP, DNS, DHCP, VPN, firewalls (Cisco, Fortinet, Meraki, SonicWall)
|
||||
- Virtualization: VMware vSphere, Hyper-V, Proxmox
|
||||
- Cloud platforms: Microsoft 365, Azure, AWS
|
||||
- Endpoint management, RMM tools, and PSA platforms (ConnectWise, Datto, Kaseya, NinjaRMM)
|
||||
- PowerShell scripting and automation
|
||||
- Security: MFA, Conditional Access, EDR, backup/DR
|
||||
|
||||
## How to Answer
|
||||
- **Be direct and actionable.** Engineers are mid-ticket — give them the answer, \
|
||||
not a lecture. Lead with the fix, then explain why.
|
||||
- **Include specifics.** Exact commands, registry paths, config values, port numbers. \
|
||||
Vague advice wastes time.
|
||||
- **Warn before you wreck.** If a step could cause downtime, data loss, or a lockout, \
|
||||
say so upfront — before the command.
|
||||
- **Use structured formatting.** Bullet points for steps, code blocks for commands, \
|
||||
bold for key terms. Engineers scan, they don't read essays.
|
||||
- **Say when you're unsure.** If you don't know the exact answer, say so. Suggest \
|
||||
where to verify (vendor docs, a specific KB article) rather than guessing.
|
||||
|
||||
## Using the Team's Flow Library
|
||||
Your team has built troubleshooting flows in ResolutionFlow. When relevant flows \
|
||||
appear in the context below, reference them by name so the engineer can launch them \
|
||||
directly. Prefer the team's proven flows over ad-hoc instructions when they exist.
|
||||
|
||||
## Using Microsoft Learn Documentation
|
||||
You have access to Microsoft's official documentation via Microsoft Learn. Use it when:
|
||||
- The question involves exact cmdlet syntax, API parameters, or configuration steps
|
||||
- You need to verify current Microsoft/Azure behavior or requirements
|
||||
- No team flow covers the topic and vendor-specific detail would help
|
||||
Do NOT use Microsoft Learn for every question — only when official docs add real value.
|
||||
|
||||
## Boundaries
|
||||
- Stay focused on IT infrastructure, systems administration, and MSP operations.
|
||||
- If a question is clearly outside your domain, say so briefly and redirect.
|
||||
- Never fabricate error codes, KB article numbers, or CLI flags. If unsure, say so.
|
||||
"""
|
||||
|
||||
|
||||
async def _call_ai(
|
||||
system_base: str,
|
||||
rag_context: str,
|
||||
history: list[dict[str, Any]],
|
||||
new_message: str,
|
||||
max_tokens: int = 4096,
|
||||
) -> tuple[str, int, int]:
|
||||
"""Call the AI with prompt caching when using Anthropic.
|
||||
|
||||
Caching strategy:
|
||||
- System prompt base: cached (stable across all turns)
|
||||
- RAG context: NOT cached (changes per query)
|
||||
- Conversation history prefix: cached via breakpoint on last
|
||||
existing message (stable — only new user message is uncached)
|
||||
"""
|
||||
if settings.AI_PROVIDER == "anthropic" and settings.ANTHROPIC_API_KEY:
|
||||
return await _call_anthropic_cached(
|
||||
system_base, rag_context, history, new_message, max_tokens
|
||||
)
|
||||
|
||||
# Fallback: generic provider (Gemini, etc.)
|
||||
from app.core.ai_provider import get_ai_provider
|
||||
|
||||
system_prompt = system_base + rag_context
|
||||
messages = history + [{"role": "user", "content": new_message}]
|
||||
provider = get_ai_provider()
|
||||
return await provider.generate_text(
|
||||
system_prompt=system_prompt,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
)
|
||||
|
||||
|
||||
async def _call_anthropic_cached(
|
||||
system_base: str,
|
||||
rag_context: str,
|
||||
history: list[dict[str, Any]],
|
||||
new_message: str,
|
||||
max_tokens: int,
|
||||
) -> tuple[str, int, int]:
|
||||
"""Call Anthropic with prompt caching on system prompt and history.
|
||||
|
||||
Uses structured system blocks so the static base prompt is cached
|
||||
independently from the per-query RAG context. Optionally connects
|
||||
to Microsoft Learn via MCP for real-time documentation lookups.
|
||||
"""
|
||||
import anthropic
|
||||
|
||||
client = anthropic.AsyncAnthropic(
|
||||
api_key=settings.ANTHROPIC_API_KEY,
|
||||
timeout=settings.AI_REQUEST_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
# System prompt as structured blocks:
|
||||
# Block 1: static base prompt (cached)
|
||||
# Block 2: RAG context (changes per query, not cached)
|
||||
system_blocks: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "text",
|
||||
"text": system_base,
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
},
|
||||
]
|
||||
if rag_context:
|
||||
system_blocks.append({"type": "text", "text": rag_context})
|
||||
|
||||
# Build messages with cache breakpoint on conversation history
|
||||
messages: list[dict[str, Any]] = []
|
||||
for msg in history:
|
||||
messages.append({"role": msg["role"], "content": msg["content"]})
|
||||
|
||||
# Place cache breakpoint on the last history message so the entire
|
||||
# conversation prefix is cached across turns
|
||||
if messages:
|
||||
last = messages[-1]
|
||||
messages[-1] = {
|
||||
"role": last["role"],
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": last["content"],
|
||||
"cache_control": {"type": "ephemeral"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
# Add the new user message (uncached — it's new each turn)
|
||||
messages.append({"role": "user", "content": new_message})
|
||||
|
||||
# MCP server config (optional — controlled by settings)
|
||||
mcp_servers = anthropic.NOT_GIVEN
|
||||
tools = anthropic.NOT_GIVEN
|
||||
|
||||
if settings.ENABLE_MCP_MICROSOFT_LEARN:
|
||||
mcp_servers = [
|
||||
{
|
||||
"type": "url",
|
||||
"url": "https://learn.microsoft.com/api/mcp",
|
||||
"name": "microsoft-learn",
|
||||
}
|
||||
]
|
||||
tools = [
|
||||
{
|
||||
"type": "mcp_toolset",
|
||||
"mcp_server_name": "microsoft-learn",
|
||||
}
|
||||
]
|
||||
|
||||
response = await client.beta.messages.create(
|
||||
model=settings.AI_MODEL_ANTHROPIC,
|
||||
max_tokens=max_tokens,
|
||||
system=system_blocks,
|
||||
messages=messages,
|
||||
mcp_servers=mcp_servers,
|
||||
tools=tools,
|
||||
betas=["mcp-client-2025-11-20"],
|
||||
)
|
||||
|
||||
# Extract text from response — MCP responses can have multiple block
|
||||
# types (text, mcp_tool_use, mcp_tool_result). We join all text blocks.
|
||||
text_parts = []
|
||||
mcp_tools_used = []
|
||||
for block in response.content:
|
||||
if hasattr(block, "text"):
|
||||
text_parts.append(block.text)
|
||||
if getattr(block, "type", None) == "mcp_tool_use":
|
||||
mcp_tools_used.append(getattr(block, "name", "unknown"))
|
||||
|
||||
text = "\n".join(text_parts) if text_parts else ""
|
||||
|
||||
usage = response.usage
|
||||
input_tokens = usage.input_tokens
|
||||
output_tokens = usage.output_tokens
|
||||
|
||||
# Log MCP tool usage
|
||||
if mcp_tools_used:
|
||||
logger.info("MCP tools used: %s", ", ".join(mcp_tools_used))
|
||||
|
||||
# Log cache performance
|
||||
cache_read = getattr(usage, "cache_read_input_tokens", 0) or 0
|
||||
cache_creation = getattr(usage, "cache_creation_input_tokens", 0) or 0
|
||||
if cache_read or cache_creation:
|
||||
logger.info(
|
||||
"Anthropic cache: read=%d creation=%d input=%d output=%d",
|
||||
cache_read, cache_creation, input_tokens, output_tokens,
|
||||
)
|
||||
|
||||
return text, input_tokens, output_tokens
|
||||
|
||||
|
||||
def _auto_title(message: str) -> str:
|
||||
"""Generate a short title from the first user message."""
|
||||
title = message.strip()[:100]
|
||||
@@ -41,6 +228,117 @@ def _auto_title(message: str) -> str:
|
||||
return title
|
||||
|
||||
|
||||
CONCLUSION_SYSTEM_PROMPT = """\
|
||||
You are a ticket documentation specialist for MSP (Managed Service Provider) teams. \
|
||||
Your job is to transform an AI troubleshooting conversation into clean, professional \
|
||||
ticket notes that can be pasted directly into a PSA/ticketing system (ConnectWise, \
|
||||
Autotask, HaloPSA, etc.).
|
||||
|
||||
## Output Format
|
||||
|
||||
Generate a structured summary using this exact format:
|
||||
|
||||
**Subject:** [One-line summary of the issue]
|
||||
|
||||
**Outcome:** {outcome_label}
|
||||
|
||||
**Problem Description:**
|
||||
[2-3 sentence summary of the original problem]
|
||||
|
||||
**Steps Taken:**
|
||||
1. [Step] — [Result/finding]
|
||||
2. [Step] — [Result/finding]
|
||||
(list all troubleshooting steps from the conversation)
|
||||
|
||||
**Current Status:**
|
||||
[Where things stand now — what was resolved, what remains]
|
||||
|
||||
{notes_section}
|
||||
|
||||
**Key Findings:**
|
||||
- [Important discovery or configuration detail]
|
||||
- [Any relevant error codes, settings, or values identified]
|
||||
|
||||
{resume_section}
|
||||
|
||||
## Rules
|
||||
- Be concise but thorough — these notes will be read by another engineer
|
||||
- Include specific technical details (commands run, error messages, config values)
|
||||
- Use plain text formatting (no HTML) — bold with ** is fine
|
||||
- Do NOT include conversational filler, greetings, or meta-commentary
|
||||
- Extract ALL actionable steps from the conversation, in chronological order
|
||||
- If the conversation identified root cause, state it clearly
|
||||
"""
|
||||
|
||||
|
||||
async def generate_conclusion_summary(
|
||||
chat: "AssistantChat",
|
||||
outcome: str,
|
||||
notes: str | None = None,
|
||||
) -> str:
|
||||
"""Generate a ticket-ready summary from a concluded chat conversation."""
|
||||
outcome_labels = {
|
||||
"resolved": "Resolved",
|
||||
"escalated": "Escalated",
|
||||
"paused": "Paused — To Be Continued",
|
||||
}
|
||||
outcome_label = outcome_labels.get(outcome, outcome)
|
||||
|
||||
notes_section = ""
|
||||
if notes:
|
||||
notes_section = f"\n**Engineer Notes:**\n{notes}\n"
|
||||
|
||||
resume_section = ""
|
||||
if outcome == "paused":
|
||||
resume_section = (
|
||||
"\n**Next Steps (for resumption):**\n"
|
||||
"- [What needs to happen next]\n"
|
||||
"- [Any pending actions or follow-ups]\n"
|
||||
)
|
||||
elif outcome == "escalated":
|
||||
resume_section = (
|
||||
"\n**Escalation Details:**\n"
|
||||
"- [Reason for escalation]\n"
|
||||
"- [Recommended next steps for receiving team/tier]\n"
|
||||
)
|
||||
|
||||
# Build the conversation transcript for the AI
|
||||
transcript_lines = []
|
||||
for msg in chat.messages:
|
||||
role_label = "ENGINEER" if msg["role"] == "user" else "AI ASSISTANT"
|
||||
transcript_lines.append(f"[{role_label}]: {msg['content']}")
|
||||
|
||||
transcript = "\n\n".join(transcript_lines)
|
||||
|
||||
prompt = (
|
||||
f"Outcome: {outcome_label}\n\n"
|
||||
f"{'Engineer Notes: ' + notes if notes else '(No additional notes)'}\n\n"
|
||||
f"--- CONVERSATION TRANSCRIPT ---\n\n{transcript}\n\n"
|
||||
f"--- END TRANSCRIPT ---\n\n"
|
||||
f"Generate the ticket notes now. Replace all placeholder brackets with actual content from the conversation. "
|
||||
f"The notes_section placeholder should be: {notes_section or '(omit this section)'}\n"
|
||||
f"The resume_section placeholder should be filled based on the conversation context."
|
||||
)
|
||||
|
||||
system_with_vars = CONCLUSION_SYSTEM_PROMPT.replace(
|
||||
"{outcome_label}", outcome_label
|
||||
).replace(
|
||||
"{notes_section}", notes_section or ""
|
||||
).replace(
|
||||
"{resume_section}", resume_section
|
||||
)
|
||||
|
||||
content, _, _ = await _call_ai(
|
||||
system_base=system_with_vars,
|
||||
rag_context="",
|
||||
history=[],
|
||||
new_message=prompt,
|
||||
max_tokens=2048,
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
async def create_chat(
|
||||
user_id: UUID,
|
||||
account_id: UUID,
|
||||
@@ -90,22 +388,20 @@ async def send_message(
|
||||
limit=8,
|
||||
)
|
||||
|
||||
# Build system prompt
|
||||
system_prompt = ASSISTANT_SYSTEM_PROMPT + build_rag_context(rag_results)
|
||||
rag_context = build_rag_context(rag_results)
|
||||
|
||||
# Build messages for AI
|
||||
ai_messages = []
|
||||
ai_messages: list[dict[str, Any]] = []
|
||||
for msg in chat.messages:
|
||||
if msg["role"] in ("user", "assistant"):
|
||||
ai_messages.append({"role": msg["role"], "content": msg["content"]})
|
||||
ai_messages.append({"role": "user", "content": message})
|
||||
|
||||
# Call AI
|
||||
provider = get_ai_provider()
|
||||
ai_content, input_tokens, output_tokens = await provider.generate_text(
|
||||
system_prompt=system_prompt,
|
||||
messages=ai_messages,
|
||||
max_tokens=4096,
|
||||
# Call AI with prompt caching (Anthropic) or generic provider
|
||||
ai_content, input_tokens, output_tokens = await _call_ai(
|
||||
system_base=ASSISTANT_SYSTEM_PROMPT,
|
||||
rag_context=rag_context,
|
||||
history=ai_messages,
|
||||
new_message=message,
|
||||
)
|
||||
|
||||
# Update chat
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface SurveyResponseDetail {
|
||||
responses: Record<string, string | string[]>
|
||||
source: 'invite' | 'direct'
|
||||
invite_name: string | null
|
||||
is_read: boolean
|
||||
archived_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -45,6 +47,7 @@ export interface SurveyResponseListResponse {
|
||||
responses: SurveyResponseDetail[]
|
||||
total: number
|
||||
this_week: number
|
||||
unread: number
|
||||
}
|
||||
|
||||
export const adminApi = {
|
||||
@@ -175,10 +178,22 @@ export const adminApi = {
|
||||
api.post<SurveyInviteResponse>('/admin/survey-invites', data).then(r => r.data),
|
||||
|
||||
// Survey Responses
|
||||
listSurveyResponses: () =>
|
||||
api.get<SurveyResponseListResponse>('/admin/survey-responses').then(r => r.data),
|
||||
listSurveyResponses: (includeArchived = false) =>
|
||||
api.get<SurveyResponseListResponse>('/admin/survey-responses', { params: { include_archived: includeArchived } }).then(r => r.data),
|
||||
exportSurveyResponsesCsv: () =>
|
||||
api.get('/admin/survey-responses/export', { responseType: 'blob' }).then(r => r.data),
|
||||
markResponseRead: (id: string) =>
|
||||
api.put(`/admin/survey-responses/${id}/read`).then(r => r.data),
|
||||
markResponseUnread: (id: string) =>
|
||||
api.put(`/admin/survey-responses/${id}/unread`).then(r => r.data),
|
||||
archiveResponse: (id: string) =>
|
||||
api.put(`/admin/survey-responses/${id}/archive`).then(r => r.data),
|
||||
unarchiveResponse: (id: string) =>
|
||||
api.put(`/admin/survey-responses/${id}/unarchive`).then(r => r.data),
|
||||
deleteResponse: (id: string) =>
|
||||
api.delete(`/admin/survey-responses/${id}`),
|
||||
bulkActionResponses: (action: string, ids: string[]) =>
|
||||
api.post('/admin/survey-responses/bulk', { action, ids }).then(r => r.data),
|
||||
}
|
||||
|
||||
export default adminApi
|
||||
|
||||
@@ -4,6 +4,8 @@ import type {
|
||||
ChatListItem,
|
||||
ChatMessageResponse,
|
||||
RetentionSettings,
|
||||
ConcludeChatRequest,
|
||||
ConcludeChatResponse,
|
||||
} from '@/types/assistant-chat'
|
||||
|
||||
export const assistantChatApi = {
|
||||
@@ -54,6 +56,14 @@ export const assistantChatApi = {
|
||||
const response = await apiClient.patch<RetentionSettings>('/assistant/retention', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async concludeChat(chatId: string, data: ConcludeChatRequest): Promise<ConcludeChatResponse> {
|
||||
const response = await apiClient.post<ConcludeChatResponse>(
|
||||
`/assistant/chats/${chatId}/conclude`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
||||
export default assistantChatApi
|
||||
|
||||
421
frontend/src/components/assistant/ConcludeSessionModal.tsx
Normal file
421
frontend/src/components/assistant/ConcludeSessionModal.tsx
Normal file
@@ -0,0 +1,421 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
X,
|
||||
CheckCircle2,
|
||||
ArrowUpRight,
|
||||
Pause,
|
||||
Loader2,
|
||||
Copy,
|
||||
Check,
|
||||
RefreshCw,
|
||||
ClipboardList,
|
||||
Sparkles,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
|
||||
type ConclusionOutcome = 'resolved' | 'escalated' | 'paused'
|
||||
|
||||
interface ConcludeSessionModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConclude: (outcome: ConclusionOutcome, notes: string) => Promise<string>
|
||||
onResumeNew: (summary: string) => void
|
||||
chatTitle: string
|
||||
}
|
||||
|
||||
const OUTCOMES: { value: ConclusionOutcome; label: string; description: string; icon: typeof CheckCircle2; color: string; bg: string; border: string }[] = [
|
||||
{
|
||||
value: 'resolved',
|
||||
label: 'Resolved',
|
||||
description: 'Issue has been fixed or answered',
|
||||
icon: CheckCircle2,
|
||||
color: 'text-emerald-400',
|
||||
bg: 'bg-emerald-400/10',
|
||||
border: 'border-emerald-400/30',
|
||||
},
|
||||
{
|
||||
value: 'escalated',
|
||||
label: 'Escalate',
|
||||
description: 'Needs to be handed off or escalated',
|
||||
icon: ArrowUpRight,
|
||||
color: 'text-amber-400',
|
||||
bg: 'bg-amber-400/10',
|
||||
border: 'border-amber-400/30',
|
||||
},
|
||||
{
|
||||
value: 'paused',
|
||||
label: 'Paused',
|
||||
description: 'Continuing later — saving progress',
|
||||
icon: Pause,
|
||||
color: 'text-blue-400',
|
||||
bg: 'bg-blue-400/10',
|
||||
border: 'border-blue-400/30',
|
||||
},
|
||||
]
|
||||
|
||||
type ModalStep = 'select-outcome' | 'add-notes' | 'summary'
|
||||
|
||||
export function ConcludeSessionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConclude,
|
||||
onResumeNew,
|
||||
chatTitle,
|
||||
}: ConcludeSessionModalProps) {
|
||||
const [step, setStep] = useState<ModalStep>('select-outcome')
|
||||
const [outcome, setOutcome] = useState<ConclusionOutcome | null>(null)
|
||||
const [notes, setNotes] = useState('')
|
||||
const [summary, setSummary] = useState('')
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Reset state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setStep('select-outcome')
|
||||
setOutcome(null)
|
||||
setNotes('')
|
||||
setSummary('')
|
||||
setGenerating(false)
|
||||
setCopied(false)
|
||||
setError(null)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const handleOutcomeSelect = (selected: ConclusionOutcome) => {
|
||||
setOutcome(selected)
|
||||
setStep('add-notes')
|
||||
}
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!outcome) return
|
||||
setGenerating(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await onConclude(outcome, notes)
|
||||
setSummary(result)
|
||||
setStep('summary')
|
||||
} catch {
|
||||
setError('Failed to generate summary. Please try again.')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(summary)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// Fallback
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = summary
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResumeNew = () => {
|
||||
onResumeNew(summary)
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const selectedOutcome = OUTCOMES.find(o => o.value === outcome)
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div
|
||||
className="relative w-full max-w-2xl mx-4 glass-card-static overflow-hidden animate-in fade-in zoom-in-95 duration-200"
|
||||
style={{
|
||||
maxHeight: 'calc(100vh - 4rem)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-6 py-4 border-b shrink-0"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-primary/10 flex items-center justify-center">
|
||||
<ClipboardList size={18} className="text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-heading font-semibold text-foreground">
|
||||
Conclude Session
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground truncate max-w-[300px]">
|
||||
{chatTitle}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div
|
||||
className="px-6 py-3 border-b shrink-0 flex items-center gap-2"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
>
|
||||
{(['select-outcome', 'add-notes', 'summary'] as ModalStep[]).map((s, i) => (
|
||||
<div key={s} className="flex items-center gap-2">
|
||||
{i > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-px',
|
||||
step === s || (i === 1 && step === 'summary') || (i === 2 && step === 'summary')
|
||||
? 'bg-primary/40'
|
||||
: 'bg-[rgba(255,255,255,0.06)]'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'w-6 h-6 rounded-full flex items-center justify-center text-[0.6875rem] font-label font-medium transition-colors',
|
||||
step === s
|
||||
? 'bg-gradient-brand text-[#101114]'
|
||||
: (i < ['select-outcome', 'add-notes', 'summary'].indexOf(step))
|
||||
? 'bg-primary/20 text-primary'
|
||||
: 'bg-[rgba(255,255,255,0.06)] text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-label',
|
||||
step === s ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{s === 'select-outcome' ? 'Outcome' : s === 'add-notes' ? 'Notes' : 'Summary'}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-5">
|
||||
{/* Step 1: Select Outcome */}
|
||||
{step === 'select-outcome' && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
How did this session end?
|
||||
</p>
|
||||
{OUTCOMES.map(o => {
|
||||
const Icon = o.icon
|
||||
return (
|
||||
<button
|
||||
key={o.value}
|
||||
onClick={() => handleOutcomeSelect(o.value)}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-4 p-4 rounded-xl border transition-all text-left',
|
||||
'hover:scale-[1.01] active:scale-[0.99]',
|
||||
'bg-[rgba(255,255,255,0.02)] border-[rgba(255,255,255,0.06)]',
|
||||
'hover:border-[rgba(255,255,255,0.12)] hover:bg-[rgba(255,255,255,0.04)]'
|
||||
)}
|
||||
>
|
||||
<div className={cn('w-10 h-10 rounded-xl flex items-center justify-center', o.bg)}>
|
||||
<Icon size={20} className={o.color} />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-foreground block">{o.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{o.description}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Add Notes */}
|
||||
{step === 'add-notes' && selectedOutcome && (
|
||||
<div className="space-y-4">
|
||||
{/* Selected outcome badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={cn('px-3 py-1.5 rounded-lg flex items-center gap-2 text-xs font-label', selectedOutcome.bg, selectedOutcome.border, 'border')}>
|
||||
<selectedOutcome.icon size={14} className={selectedOutcome.color} />
|
||||
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setStep('select-outcome')}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground block mb-2">
|
||||
Additional Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={e => setNotes(e.target.value)}
|
||||
placeholder={
|
||||
outcome === 'resolved'
|
||||
? 'Any additional context about the resolution...'
|
||||
: outcome === 'escalated'
|
||||
? 'Reason for escalation, who to assign to...'
|
||||
: 'What still needs to be done, where you left off...'
|
||||
}
|
||||
rows={4}
|
||||
className="w-full resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-rose-400 bg-rose-400/10 border border-rose-400/20 rounded-lg px-4 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Summary */}
|
||||
{step === 'summary' && (
|
||||
<div className="space-y-4">
|
||||
{/* Outcome badge */}
|
||||
{selectedOutcome && (
|
||||
<div className={cn('px-3 py-1.5 rounded-lg inline-flex items-center gap-2 text-xs font-label', selectedOutcome.bg, selectedOutcome.border, 'border')}>
|
||||
<selectedOutcome.icon size={14} className={selectedOutcome.color} />
|
||||
<span className={selectedOutcome.color}>{selectedOutcome.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generated summary */}
|
||||
<div
|
||||
className="rounded-xl border p-5 bg-[rgba(255,255,255,0.02)]"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground flex items-center gap-1.5">
|
||||
<Sparkles size={10} className="text-primary" />
|
||||
Generated Ticket Notes
|
||||
</span>
|
||||
</div>
|
||||
<div className="prose-sm text-foreground">
|
||||
<MarkdownContent content={summary} className="text-[0.8125rem] leading-relaxed" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div
|
||||
className="px-6 py-4 border-t shrink-0 flex items-center justify-between gap-3"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
>
|
||||
{step === 'select-outcome' && (
|
||||
<>
|
||||
<div />
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] transition-all"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'add-notes' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setStep('select-outcome')}
|
||||
className="px-4 py-2 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] transition-all"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={generating}
|
||||
className="flex items-center gap-2 bg-gradient-brand text-[#101114] font-semibold text-sm rounded-[10px] px-5 py-2.5 hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-50"
|
||||
>
|
||||
{generating ? (
|
||||
<>
|
||||
<Loader2 size={15} className="animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={15} />
|
||||
Generate Summary
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'summary' && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{outcome === 'paused' && (
|
||||
<button
|
||||
onClick={handleResumeNew}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-[10px] text-sm font-medium text-blue-400 bg-blue-400/10 border border-blue-400/20 hover:bg-blue-400/15 transition-all"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
Resume in New Chat
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2.5 rounded-[10px] text-sm font-semibold transition-all',
|
||||
copied
|
||||
? 'bg-emerald-400/15 text-emerald-400 border border-emerald-400/30'
|
||||
: 'bg-gradient-brand text-[#101114] hover:opacity-90 active:scale-[0.97]'
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check size={15} />
|
||||
Copied!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy size={15} />
|
||||
Copy to Clipboard
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 rounded-[10px] text-sm text-muted-foreground hover:text-foreground bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)] transition-all"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Sparkles, Send, Loader2 } from 'lucide-react'
|
||||
import { Sparkles, Send, Loader2, Flag } from 'lucide-react'
|
||||
import { assistantChatApi } from '@/api/assistantChat'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { ChatSidebar } from '@/components/assistant/ChatSidebar'
|
||||
import { ChatMessage } from '@/components/assistant/ChatMessage'
|
||||
import type { ChatListItem, AssistantChatMessage as ChatMessageType } from '@/types/assistant-chat'
|
||||
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
||||
import type { ChatListItem, AssistantChatMessage as ChatMessageType, ConclusionOutcome } from '@/types/assistant-chat'
|
||||
import type { SuggestedFlow } from '@/types/copilot'
|
||||
|
||||
interface MessageWithMeta extends ChatMessageType {
|
||||
@@ -17,6 +18,7 @@ export default function AssistantChatPage() {
|
||||
const [messages, setMessages] = useState<MessageWithMeta[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showConclude, setShowConclude] = useState(false)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
@@ -120,6 +122,55 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleConclude = async (outcome: ConclusionOutcome, notes: string): Promise<string> => {
|
||||
if (!activeChatId) throw new Error('No active chat')
|
||||
const response = await assistantChatApi.concludeChat(activeChatId, { outcome, notes: notes || undefined })
|
||||
// Update chat in sidebar to show concluded status
|
||||
setChats(prev =>
|
||||
prev.map(c =>
|
||||
c.id === activeChatId
|
||||
? { ...c, concluded_at: response.concluded_at, conclusion_outcome: outcome }
|
||||
: c
|
||||
)
|
||||
)
|
||||
return response.summary
|
||||
}
|
||||
|
||||
const handleResumeNew = async (summary: string) => {
|
||||
try {
|
||||
const chat = await assistantChatApi.createChat()
|
||||
setChats(prev => [
|
||||
{ id: chat.id, title: chat.title, message_count: 0, pinned: false, created_at: chat.created_at, updated_at: chat.updated_at },
|
||||
...prev,
|
||||
])
|
||||
setActiveChatId(chat.id)
|
||||
setMessages([])
|
||||
|
||||
// Send the summary as the first message to prime the new chat
|
||||
const resumePrompt = `I'm continuing a previous troubleshooting session. Here's where we left off:\n\n${summary}\n\nPlease review this context and help me continue from where we stopped.`
|
||||
setInput('')
|
||||
setMessages([{ role: 'user', content: resumePrompt }])
|
||||
setLoading(true)
|
||||
|
||||
const response = await assistantChatApi.sendMessage(chat.id, resumePrompt)
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{ role: 'assistant', content: response.content, suggestedFlows: response.suggested_flows },
|
||||
])
|
||||
setChats(prev =>
|
||||
prev.map(c =>
|
||||
c.id === chat.id
|
||||
? { ...c, message_count: 2, title: resumePrompt.slice(0, 100), updated_at: new Date().toISOString() }
|
||||
: c
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
toast.error('Failed to create resume chat')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
@@ -189,18 +240,32 @@ export default function AssistantChatPage() {
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about IT, networking, troubleshooting..."
|
||||
rows={1}
|
||||
rows={3}
|
||||
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || loading}
|
||||
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || loading}
|
||||
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
|
||||
title="Send message"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
{messages.length >= 2 && (
|
||||
<button
|
||||
onClick={() => setShowConclude(true)}
|
||||
disabled={loading}
|
||||
className="p-3 rounded-xl border text-muted-foreground hover:text-amber-400 hover:border-amber-400/30 hover:bg-amber-400/10 transition-all disabled:opacity-40"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
title="Conclude session"
|
||||
>
|
||||
<Flag size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
@@ -225,6 +290,15 @@ export default function AssistantChatPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conclude Session Modal */}
|
||||
<ConcludeSessionModal
|
||||
isOpen={showConclude}
|
||||
onClose={() => setShowConclude(false)}
|
||||
onConclude={handleConclude}
|
||||
onResumeNew={handleResumeNew}
|
||||
chatTitle={chats.find(c => c.id === activeChatId)?.title ?? 'Chat'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom'
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
|
||||
// ── Survey Data Types ──
|
||||
@@ -147,6 +147,12 @@ export default function SurveyPage() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [submitError, setSubmitError] = useState('')
|
||||
const [emailInput, setEmailInput] = useState('')
|
||||
const [emailSending, setEmailSending] = useState(false)
|
||||
const [emailSent, setEmailSent] = useState(false)
|
||||
const [emailError, setEmailError] = useState('')
|
||||
const [responseId, setResponseId] = useState<string | null>(null)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [searchParams] = useSearchParams()
|
||||
const token = searchParams.get('t')
|
||||
@@ -203,6 +209,8 @@ export default function SurveyPage() {
|
||||
const errData = await res.json().catch(() => null)
|
||||
throw new Error(errData?.detail || `Submission failed (${res.status})`)
|
||||
}
|
||||
const data = await res.json()
|
||||
setResponseId(data.id)
|
||||
setIsComplete(true)
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
} catch (err) {
|
||||
@@ -257,9 +265,20 @@ export default function SurveyPage() {
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</div>
|
||||
<h2 className="font-heading text-2xl font-bold mb-2.5">Already Submitted</h2>
|
||||
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto leading-relaxed">
|
||||
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto leading-relaxed mb-3">
|
||||
{inviteName ? `Thanks ${inviteName} — y` : 'Y'}our response has already been recorded. We appreciate your time!
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70 max-w-[400px] mx-auto leading-relaxed mb-8">
|
||||
You can safely close this browser window now.
|
||||
</p>
|
||||
<div className="glass-card-static p-5 max-w-[400px] mx-auto text-center">
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
Have feedback unrelated to the survey?{' '}
|
||||
<a href="mailto:feedback@resolutionflow.com" className="text-primary hover:underline font-medium">
|
||||
feedback@resolutionflow.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -385,13 +404,86 @@ export default function SurveyPage() {
|
||||
<div className="w-16 h-16 mx-auto mb-5 rounded-full flex items-center justify-center" style={{ background: 'rgba(52, 211, 153, 0.1)' }}>
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2.5"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
</div>
|
||||
<h2 className="font-heading text-2xl font-bold mb-2.5">Done — Thank You!</h2>
|
||||
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto mb-7 leading-relaxed">
|
||||
Your answers will directly shape how FlowPilot troubleshoots. We truly appreciate your time and expertise.
|
||||
<h2 className="font-heading text-2xl font-bold mb-2.5">Response Submitted!</h2>
|
||||
<p className="text-muted-foreground text-sm max-w-[440px] mx-auto mb-8 leading-relaxed">
|
||||
Your answers will directly shape how FlowPilot troubleshoots. Would you like a copy of your responses?
|
||||
</p>
|
||||
|
||||
{/* Email a copy */}
|
||||
<div className="glass-card-static p-6 max-w-[420px] mx-auto mb-5">
|
||||
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-3">
|
||||
Email a copy to yourself
|
||||
</p>
|
||||
{!emailSent ? (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="email"
|
||||
value={emailInput}
|
||||
onChange={e => setEmailInput(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
className="flex-1 rounded-[9px] px-3.5 py-2.5 text-sm text-foreground placeholder:text-[#5a6170] focus:outline-none"
|
||||
style={{ background: 'rgba(16, 17, 20, 0.6)', border: '1px solid var(--glass-border)' }}
|
||||
onFocus={e => { e.currentTarget.style.borderColor = 'rgba(6, 182, 212, 0.4)' }}
|
||||
onBlur={e => { e.currentTarget.style.borderColor = 'var(--glass-border)' }}
|
||||
disabled={emailSending}
|
||||
/>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!emailInput.trim() || !responseId) return
|
||||
setEmailSending(true)
|
||||
setEmailError('')
|
||||
try {
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
const res = await fetch(`${apiUrl}/api/v1/survey/email-copy`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: emailInput.trim(), response_id: responseId }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => null)
|
||||
throw new Error(err?.detail || 'Failed to send')
|
||||
}
|
||||
setEmailSent(true)
|
||||
} catch (err) {
|
||||
setEmailError(err instanceof Error ? err.message : 'Failed to send email')
|
||||
} finally {
|
||||
setEmailSending(false)
|
||||
}
|
||||
}}
|
||||
disabled={!emailInput.trim() || emailSending}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-[9px] text-sm font-semibold bg-gradient-brand text-[#101114] transition-all duration-150 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
{emailSending ? (
|
||||
<>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="animate-spin"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||
Sending...
|
||||
</>
|
||||
) : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2 py-2 text-sm text-emerald-400">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 6L9 17l-5-5"/></svg>
|
||||
Email sent! Check your inbox.
|
||||
</div>
|
||||
)}
|
||||
{emailError && (
|
||||
<p className="text-xs text-rose-400 mt-2">{emailError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Copy + Finish buttons */}
|
||||
<div className="flex gap-2.5 justify-center flex-wrap">
|
||||
<button onClick={copyAll} className="inline-flex items-center gap-2 px-6 py-3 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97]">
|
||||
Copy Responses to Clipboard
|
||||
<button onClick={copyAll} className="inline-flex items-center gap-2 px-5 py-2.5 rounded-[10px] text-sm font-semibold transition-all duration-150 text-muted-foreground hover:text-foreground" style={{ background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.06)' }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
Copy to Clipboard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/survey/thank-you')}
|
||||
className="inline-flex items-center gap-2 px-6 py-2.5 rounded-[10px] text-sm font-semibold bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20 transition-all duration-150 hover:opacity-90 active:scale-[0.97]"
|
||||
>
|
||||
Finish
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
76
frontend/src/pages/SurveyThankYouPage.tsx
Normal file
76
frontend/src/pages/SurveyThankYouPage.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||
|
||||
export default function SurveyThankYouPage() {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
{/* Atmosphere orbs */}
|
||||
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden="true">
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ top: '-200px', left: '-100px', width: '600px', height: '600px', borderRadius: '50%', background: 'radial-gradient(circle, rgba(6, 182, 212, 0.12) 0%, rgba(6, 182, 212, 0.03) 40%, transparent 70%)', filter: 'blur(80px)' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute"
|
||||
style={{ bottom: '-150px', right: '-100px', width: '500px', height: '500px', borderRadius: '50%', background: 'radial-gradient(circle, rgba(52, 211, 153, 0.08) 0%, rgba(52, 211, 153, 0.02) 40%, transparent 70%)', filter: 'blur(80px)' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Top bar */}
|
||||
<div className="sticky top-0 z-50" style={{ backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)', background: 'rgba(16, 17, 20, 0.85)', borderBottom: '1px solid var(--glass-border)' }}>
|
||||
<div className="mx-auto flex max-w-[680px] items-center justify-between gap-3 px-5 py-3.5">
|
||||
<a href="https://resolutionflow.com" target="_blank" rel="noreferrer" className="flex items-center gap-2.5 text-sm font-heading font-bold text-muted-foreground no-underline">
|
||||
<BrandLogo size="sm" />
|
||||
<span>Resolution<span className="text-gradient-brand">Flow</span></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 mx-auto max-w-[680px] px-5">
|
||||
<div className="text-center pt-[120px] animate-fade-in-up">
|
||||
{/* Success icon */}
|
||||
<div className="w-20 h-20 mx-auto mb-6 rounded-full flex items-center justify-center" style={{ background: 'rgba(52, 211, 153, 0.1)', border: '1px solid rgba(52, 211, 153, 0.15)' }}>
|
||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="#34d399" strokeWidth="2">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h1 className="font-heading text-[clamp(24px,4vw,32px)] font-extrabold leading-tight mb-3">
|
||||
Thank You!
|
||||
</h1>
|
||||
<p className="text-[15px] text-muted-foreground max-w-[460px] mx-auto leading-relaxed mb-3">
|
||||
Your response has been recorded. Your expertise will directly shape how FlowPilot thinks about troubleshooting.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70 max-w-[400px] mx-auto leading-relaxed mb-10">
|
||||
You can safely close this browser window now.
|
||||
</p>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="mx-auto w-12 h-px mb-10" style={{ background: 'var(--glass-border)' }} />
|
||||
|
||||
{/* Feedback callout */}
|
||||
<div
|
||||
className="glass-card-static p-6 text-center max-w-[480px] mx-auto"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 mb-3">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#06b6d4" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||
</svg>
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-primary font-semibold">
|
||||
Have Feedback?
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
If you have any feedback unrelated to the survey, we'd love to hear from you at{' '}
|
||||
<a
|
||||
href="mailto:feedback@resolutionflow.com"
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
feedback@resolutionflow.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -7,15 +7,11 @@ import { cn } from '@/lib/utils'
|
||||
export function VerifyEmailPage() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [status, setStatus] = useState<'loading' | 'success' | 'error'>(token ? 'loading' : 'error')
|
||||
const [errorMessage, setErrorMessage] = useState(token ? '' : 'No verification token provided')
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus('error')
|
||||
setErrorMessage('No verification token provided')
|
||||
return
|
||||
}
|
||||
if (!token) return
|
||||
|
||||
authApi.verifyEmail(token)
|
||||
.then(() => setStatus('success'))
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { adminApi, type SurveyResponseDetail, type SurveyResponseListResponse } from '@/api/admin'
|
||||
import { PageHeader } from '@/components/admin'
|
||||
import { ChevronDown, Download, User, Link2, Loader2 } from 'lucide-react'
|
||||
import {
|
||||
ChevronDown,
|
||||
Download,
|
||||
User,
|
||||
Link2,
|
||||
Loader2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Trash2,
|
||||
CheckSquare,
|
||||
Square,
|
||||
MoreHorizontal,
|
||||
Circle,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
const QUESTIONS: { id: string; num: string; text: string; type: 'mc' | 'mc-multi' | 'range' | 'text' | 'rank' }[] = [
|
||||
{ id: 'prereqs', num: '1', text: 'Before you start troubleshooting, what info do you need?', type: 'mc-multi' },
|
||||
@@ -70,7 +86,7 @@ function AnswerDisplay({ value, type }: { value: string | string[] | undefined;
|
||||
function ExpandedDetail({ response }: { response: SurveyResponseDetail }) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={6} className="p-0">
|
||||
<td colSpan={8} className="p-0">
|
||||
<div
|
||||
className="px-6 py-5"
|
||||
style={{
|
||||
@@ -106,25 +122,57 @@ function ResponseRow({
|
||||
response,
|
||||
index,
|
||||
isExpanded,
|
||||
isSelected,
|
||||
onToggle,
|
||||
onSelect,
|
||||
onMarkRead,
|
||||
onArchive,
|
||||
onDelete,
|
||||
}: {
|
||||
response: SurveyResponseDetail
|
||||
index: number
|
||||
isExpanded: boolean
|
||||
isSelected: boolean
|
||||
onToggle: () => void
|
||||
onSelect: () => void
|
||||
onMarkRead: () => void
|
||||
onArchive: () => void
|
||||
onDelete: () => void
|
||||
}) {
|
||||
const answeredCount = QUESTIONS.filter((q) => {
|
||||
const val = response.responses[q.id]
|
||||
return val !== undefined && val !== null && val !== '' && !(Array.isArray(val) && val.length === 0)
|
||||
}).length
|
||||
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr
|
||||
className="border-b border-border/50 hover:bg-[rgba(255,255,255,0.02)] transition-colors cursor-pointer"
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
'border-b border-border/50 transition-colors cursor-pointer',
|
||||
!response.is_read && 'bg-primary/[0.03]',
|
||||
'hover:bg-[rgba(255,255,255,0.02)]'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3 w-8">
|
||||
{/* Checkbox */}
|
||||
<td className="px-2 py-3 w-8" onClick={e => { e.stopPropagation(); onSelect() }}>
|
||||
{isSelected ? (
|
||||
<CheckSquare className="h-4 w-4 text-primary cursor-pointer" />
|
||||
) : (
|
||||
<Square className="h-4 w-4 text-muted-foreground/40 cursor-pointer hover:text-muted-foreground" />
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Unread dot */}
|
||||
<td className="px-1 py-3 w-6" onClick={onToggle}>
|
||||
{!response.is_read && (
|
||||
<Circle className="h-2.5 w-2.5 fill-primary text-primary" />
|
||||
)}
|
||||
</td>
|
||||
|
||||
{/* Expand chevron */}
|
||||
<td className="px-2 py-3 w-8" onClick={onToggle}>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-4 w-4 text-muted-foreground transition-transform',
|
||||
@@ -132,11 +180,11 @@ function ResponseRow({
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-label text-xs text-muted-foreground">{index + 1}</td>
|
||||
<td className="px-4 py-3 text-sm text-foreground">
|
||||
<td className="px-4 py-3 font-label text-xs text-muted-foreground" onClick={onToggle}>{index + 1}</td>
|
||||
<td className={cn('px-4 py-3 text-sm', !response.is_read ? 'text-foreground font-medium' : 'text-foreground')} onClick={onToggle}>
|
||||
{response.respondent_name || <span className="text-muted-foreground italic">Anonymous</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<td className="px-4 py-3" onClick={onToggle}>
|
||||
{response.source === 'invite' ? (
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 font-label text-[0.625rem] uppercase tracking-wider bg-primary/10 text-primary">
|
||||
<User className="h-3 w-3" />
|
||||
@@ -152,16 +200,57 @@ function ResponseRow({
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-label text-xs text-muted-foreground">
|
||||
<td className="px-4 py-3 font-label text-xs text-muted-foreground" onClick={onToggle}>
|
||||
{new Date(response.created_at).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground" onClick={onToggle}>
|
||||
{answeredCount} / {QUESTIONS.length}
|
||||
</td>
|
||||
{/* Actions */}
|
||||
<td className="px-3 py-3 w-10 relative">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setShowMenu(!showMenu) }}
|
||||
className="p-1.5 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
{showMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowMenu(false)} />
|
||||
<div
|
||||
className="absolute right-3 top-full z-50 mt-1 w-44 rounded-xl py-1 shadow-xl"
|
||||
style={{ background: 'rgba(24, 26, 31, 0.95)', border: '1px solid var(--glass-border)', backdropFilter: 'blur(16px)' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => { onMarkRead(); setShowMenu(false) }}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
|
||||
>
|
||||
{response.is_read ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
|
||||
{response.is_read ? 'Mark Unread' : 'Mark Read'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { onArchive(); setShowMenu(false) }}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.04)] transition-colors"
|
||||
>
|
||||
{response.archived_at ? <ArchiveRestore className="h-3.5 w-3.5" /> : <Archive className="h-3.5 w-3.5" />}
|
||||
{response.archived_at ? 'Unarchive' : 'Archive'}
|
||||
</button>
|
||||
<div className="my-1 border-t" style={{ borderColor: 'var(--glass-border)' }} />
|
||||
<button
|
||||
onClick={() => { onDelete(); setShowMenu(false) }}
|
||||
className="flex w-full items-center gap-2.5 px-3 py-2 text-xs text-rose-400 hover:bg-rose-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && <ExpandedDetail response={response} />}
|
||||
</>
|
||||
@@ -174,20 +263,24 @@ export default function SurveyResponsesPage() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
try {
|
||||
const result = await adminApi.listSurveyResponses(showArchived)
|
||||
setData(result)
|
||||
} catch {
|
||||
setError('Failed to load survey responses')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [showArchived])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const result = await adminApi.listSurveyResponses()
|
||||
setData(result)
|
||||
} catch {
|
||||
setError('Failed to load survey responses')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
setLoading(true)
|
||||
fetchData()
|
||||
}, [])
|
||||
}, [fetchData])
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true)
|
||||
@@ -206,6 +299,112 @@ export default function SurveyResponsesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleMarkRead = async (id: string, currentlyRead: boolean) => {
|
||||
try {
|
||||
if (currentlyRead) {
|
||||
await adminApi.markResponseUnread(id)
|
||||
} else {
|
||||
await adminApi.markResponseRead(id)
|
||||
}
|
||||
setData(prev => prev ? {
|
||||
...prev,
|
||||
unread: prev.unread + (currentlyRead ? 1 : -1),
|
||||
responses: prev.responses.map(r => r.id === id ? { ...r, is_read: !currentlyRead } : r),
|
||||
} : prev)
|
||||
} catch {
|
||||
toast.error('Failed to update read status')
|
||||
}
|
||||
}
|
||||
|
||||
const handleArchive = async (id: string, currentlyArchived: boolean) => {
|
||||
try {
|
||||
if (currentlyArchived) {
|
||||
await adminApi.unarchiveResponse(id)
|
||||
setData(prev => prev ? {
|
||||
...prev,
|
||||
responses: prev.responses.map(r => r.id === id ? { ...r, archived_at: null } : r),
|
||||
} : prev)
|
||||
} else {
|
||||
await adminApi.archiveResponse(id)
|
||||
if (!showArchived) {
|
||||
setData(prev => prev ? {
|
||||
...prev,
|
||||
total: prev.total - 1,
|
||||
responses: prev.responses.filter(r => r.id !== id),
|
||||
} : prev)
|
||||
} else {
|
||||
setData(prev => prev ? {
|
||||
...prev,
|
||||
responses: prev.responses.map(r => r.id === id ? { ...r, archived_at: new Date().toISOString() } : r),
|
||||
} : prev)
|
||||
}
|
||||
}
|
||||
toast.success(currentlyArchived ? 'Response unarchived' : 'Response archived')
|
||||
} catch {
|
||||
toast.error('Failed to update archive status')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Permanently delete this response? This cannot be undone.')) return
|
||||
try {
|
||||
await adminApi.deleteResponse(id)
|
||||
setData(prev => prev ? {
|
||||
...prev,
|
||||
total: prev.total - 1,
|
||||
responses: prev.responses.filter(r => r.id !== id),
|
||||
} : prev)
|
||||
setSelectedIds(prev => { const next = new Set(prev); next.delete(id); return next })
|
||||
toast.success('Response deleted')
|
||||
} catch {
|
||||
toast.error('Failed to delete response')
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkAction = async (action: string) => {
|
||||
if (selectedIds.size === 0) return
|
||||
if (action === 'delete' && !confirm(`Permanently delete ${selectedIds.size} response(s)?`)) return
|
||||
|
||||
try {
|
||||
await adminApi.bulkActionResponses(action, Array.from(selectedIds))
|
||||
setSelectedIds(new Set())
|
||||
fetchData()
|
||||
toast.success(`${action.replace('_', ' ')} applied to ${selectedIds.size} response(s)`)
|
||||
} catch {
|
||||
toast.error('Bulk action failed')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
const responses = data?.responses ?? []
|
||||
if (selectedIds.size === responses.length) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(responses.map(r => r.id)))
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-mark as read when expanding
|
||||
const handleExpand = (id: string) => {
|
||||
const newId = expandedId === id ? null : id
|
||||
setExpandedId(newId)
|
||||
if (newId) {
|
||||
const resp = data?.responses.find(r => r.id === newId)
|
||||
if (resp && !resp.is_read) {
|
||||
handleMarkRead(newId, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -230,18 +429,33 @@ export default function SurveyResponsesPage() {
|
||||
title="Survey Responses"
|
||||
description={`${data?.total ?? 0} total responses collected`}
|
||||
action={
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || responses.length === 0}
|
||||
className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-[rgba(255,255,255,0.12)] disabled:opacity-50"
|
||||
>
|
||||
{exporting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Export CSV
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Archive toggle */}
|
||||
<button
|
||||
onClick={() => setShowArchived(!showArchived)}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-[10px] px-3 py-2 text-xs font-medium transition-colors border',
|
||||
showArchived
|
||||
? 'bg-primary/10 text-primary border-primary/20'
|
||||
: 'bg-[rgba(255,255,255,0.04)] text-muted-foreground border-[rgba(255,255,255,0.06)] hover:border-[rgba(255,255,255,0.12)]'
|
||||
)}
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
{showArchived ? 'Showing Archived' : 'Show Archived'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting || responses.length === 0}
|
||||
className="inline-flex items-center gap-2 rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm font-medium text-foreground transition-colors hover:border-[rgba(255,255,255,0.12)] disabled:opacity-50"
|
||||
>
|
||||
{exporting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4" />
|
||||
)}
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -269,14 +483,82 @@ export default function SurveyResponsesPage() {
|
||||
{data?.this_week ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="glass-card-static px-5 py-4 flex-1">
|
||||
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground mb-1">
|
||||
Unread
|
||||
</p>
|
||||
<p className={cn(
|
||||
'text-2xl font-heading font-bold',
|
||||
(data?.unread ?? 0) > 0 ? 'text-primary' : 'text-foreground'
|
||||
)}>
|
||||
{data?.unread ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div
|
||||
className="flex items-center gap-3 rounded-xl px-4 py-2.5"
|
||||
style={{ background: 'rgba(6, 182, 212, 0.08)', border: '1px solid rgba(6, 182, 212, 0.15)' }}
|
||||
>
|
||||
<span className="text-sm text-primary font-medium">
|
||||
{selectedIds.size} selected
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => handleBulkAction('mark_read')}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
Mark Read
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulkAction('mark_unread')}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<EyeOff className="h-3.5 w-3.5" />
|
||||
Mark Unread
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulkAction('archive')}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
Archive
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleBulkAction('delete')}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-rose-400 hover:bg-rose-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
className="px-3 py-1.5 rounded-lg text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-[rgba(255,255,255,0.06)] transition-colors"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<div className="glass-card-static overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50">
|
||||
<th className="px-4 py-3 w-8" />
|
||||
<th className="px-2 py-3 w-8">
|
||||
<button onClick={toggleSelectAll} className="text-muted-foreground/40 hover:text-muted-foreground">
|
||||
{selectedIds.size > 0 && selectedIds.size === responses.length ? (
|
||||
<CheckSquare className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Square className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</th>
|
||||
<th className="px-1 py-3 w-6" />
|
||||
<th className="px-2 py-3 w-8" />
|
||||
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
#
|
||||
</th>
|
||||
@@ -292,13 +574,14 @@ export default function SurveyResponsesPage() {
|
||||
<th className="px-4 py-3 text-left font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
Answered
|
||||
</th>
|
||||
<th className="px-3 py-3 w-10" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{responses.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
No survey responses yet.
|
||||
<td colSpan={9} className="px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
{showArchived ? 'No archived responses.' : 'No survey responses yet.'}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -308,9 +591,12 @@ export default function SurveyResponsesPage() {
|
||||
response={response}
|
||||
index={index}
|
||||
isExpanded={expandedId === response.id}
|
||||
onToggle={() =>
|
||||
setExpandedId(expandedId === response.id ? null : response.id)
|
||||
}
|
||||
isSelected={selectedIds.has(response.id)}
|
||||
onToggle={() => handleExpand(response.id)}
|
||||
onSelect={() => toggleSelect(response.id)}
|
||||
onMarkRead={() => handleMarkRead(response.id, response.is_read)}
|
||||
onArchive={() => handleArchive(response.id, !!response.archived_at)}
|
||||
onDelete={() => handleDelete(response.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
// Public pages
|
||||
const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage'))
|
||||
const SurveyPage = lazy(() => import('@/pages/SurveyPage'))
|
||||
const SurveyThankYouPage = lazy(() => import('@/pages/SurveyThankYouPage'))
|
||||
|
||||
// Standalone auth pages
|
||||
const VerifyEmailPage = lazy(() => import('@/pages/VerifyEmailPage'))
|
||||
@@ -108,6 +109,15 @@ export const router = createBrowserRouter([
|
||||
),
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/survey/thank-you',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<SurveyThankYouPage />
|
||||
</Suspense>
|
||||
),
|
||||
errorElement: <RouteError />,
|
||||
},
|
||||
{
|
||||
path: '/share/:shareToken',
|
||||
element: (
|
||||
|
||||
@@ -34,4 +34,17 @@ export interface RetentionSettings {
|
||||
chat_retention_max_count: number | null
|
||||
}
|
||||
|
||||
export type ConclusionOutcome = 'resolved' | 'escalated' | 'paused'
|
||||
|
||||
export interface ConcludeChatRequest {
|
||||
outcome: ConclusionOutcome
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface ConcludeChatResponse {
|
||||
summary: string
|
||||
outcome: ConclusionOutcome
|
||||
concluded_at: string
|
||||
}
|
||||
|
||||
export type { SuggestedFlow }
|
||||
|
||||
Reference in New Issue
Block a user