Implement session outcomes, step timing, and live timer fixes
This commit is contained in:
31
backend/alembic/versions/029_add_session_outcomes.py
Normal file
31
backend/alembic/versions/029_add_session_outcomes.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""add outcome fields to sessions
|
||||||
|
|
||||||
|
Revision ID: 029
|
||||||
|
Revises: 028
|
||||||
|
Create Date: 2026-02-11
|
||||||
|
|
||||||
|
Adds outcome and outcome_notes columns for session completion tracking.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "029"
|
||||||
|
down_revision: Union[str, None] = "028"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column("sessions", sa.Column("outcome", sa.String(length=20), nullable=True))
|
||||||
|
op.add_column("sessions", sa.Column("outcome_notes", sa.Text(), nullable=True))
|
||||||
|
op.create_index(op.f("ix_sessions_outcome"), "sessions", ["outcome"], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_sessions_outcome"), table_name="sessions")
|
||||||
|
op.drop_column("sessions", "outcome_notes")
|
||||||
|
op.drop_column("sessions", "outcome")
|
||||||
@@ -10,7 +10,16 @@ from app.core.database import get_db
|
|||||||
from app.models.tree import Tree
|
from app.models.tree import Tree
|
||||||
from app.models.session import Session
|
from app.models.session import Session
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.session import SessionCreate, SessionUpdate, SessionResponse, SessionExport, ScratchpadUpdate, SaveAsTreeRequest, SaveAsTreeResponse
|
from app.schemas.session import (
|
||||||
|
SessionCreate,
|
||||||
|
SessionUpdate,
|
||||||
|
SessionResponse,
|
||||||
|
SessionExport,
|
||||||
|
ScratchpadUpdate,
|
||||||
|
SaveAsTreeRequest,
|
||||||
|
SaveAsTreeResponse,
|
||||||
|
SessionComplete,
|
||||||
|
)
|
||||||
from app.api.deps import get_current_active_user
|
from app.api.deps import get_current_active_user
|
||||||
from app.core.permissions import can_access_tree
|
from app.core.permissions import can_access_tree
|
||||||
from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export
|
from app.services.export_service import generate_markdown_export, generate_text_export, generate_html_export, generate_psa_export
|
||||||
@@ -198,6 +207,7 @@ async def update_session(
|
|||||||
@router.post("/{session_id}/complete", response_model=SessionResponse)
|
@router.post("/{session_id}/complete", response_model=SessionResponse)
|
||||||
async def complete_session(
|
async def complete_session(
|
||||||
session_id: UUID,
|
session_id: UUID,
|
||||||
|
completion_data: SessionComplete,
|
||||||
db: Annotated[AsyncSession, Depends(get_db)],
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
current_user: Annotated[User, Depends(get_current_active_user)]
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||||
):
|
):
|
||||||
@@ -224,6 +234,8 @@ async def complete_session(
|
|||||||
)
|
)
|
||||||
|
|
||||||
session.completed_at = datetime.now(timezone.utc)
|
session.completed_at = datetime.now(timezone.utc)
|
||||||
|
session.outcome = completion_data.outcome
|
||||||
|
session.outcome_notes = completion_data.outcome_notes
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(session)
|
await db.refresh(session)
|
||||||
return session
|
return session
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ class Session(Base):
|
|||||||
nullable=True,
|
nullable=True,
|
||||||
index=True
|
index=True
|
||||||
)
|
)
|
||||||
|
outcome: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, index=True)
|
||||||
|
outcome_notes: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||||
ticket_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
ticket_number: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||||
client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
client_name: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||||
exported: Mapped[bool] = mapped_column(Boolean, default=False)
|
exported: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from typing import Optional, Any, Literal
|
|||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pydantic import BaseModel, Field, validator
|
from pydantic import BaseModel, Field, validator
|
||||||
|
|
||||||
|
SessionOutcome = Literal["resolved", "escalated", "workaround", "unresolved"]
|
||||||
|
|
||||||
|
|
||||||
class CustomStepSchema(BaseModel):
|
class CustomStepSchema(BaseModel):
|
||||||
"""Enhanced custom step with source tracking.
|
"""Enhanced custom step with source tracking.
|
||||||
@@ -28,6 +30,9 @@ class DecisionRecord(BaseModel):
|
|||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
automation_used: Optional[bool] = False
|
automation_used: Optional[bool] = False
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
|
entered_at: Optional[datetime] = None
|
||||||
|
exited_at: Optional[datetime] = None
|
||||||
|
duration_seconds: Optional[int] = Field(None, ge=0)
|
||||||
attachments: list[str] = Field(default_factory=list)
|
attachments: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -57,6 +62,8 @@ class SessionResponse(BaseModel):
|
|||||||
custom_steps: list[dict[str, Any]] = Field(default_factory=list)
|
custom_steps: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
started_at: datetime
|
started_at: datetime
|
||||||
completed_at: Optional[datetime] = None
|
completed_at: Optional[datetime] = None
|
||||||
|
outcome: Optional[SessionOutcome] = None
|
||||||
|
outcome_notes: Optional[str] = None
|
||||||
ticket_number: Optional[str] = None
|
ticket_number: Optional[str] = None
|
||||||
client_name: Optional[str] = None
|
client_name: Optional[str] = None
|
||||||
exported: bool
|
exported: bool
|
||||||
@@ -77,6 +84,11 @@ class SessionExport(BaseModel):
|
|||||||
include_tree_info: bool = True
|
include_tree_info: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class SessionComplete(BaseModel):
|
||||||
|
outcome: SessionOutcome
|
||||||
|
outcome_notes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ScratchpadUpdate(BaseModel):
|
class ScratchpadUpdate(BaseModel):
|
||||||
scratchpad: str
|
scratchpad: str
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,21 @@ Provides markdown, plain text, HTML, and PSA/ticket note export formatters
|
|||||||
for troubleshooting sessions.
|
for troubleshooting sessions.
|
||||||
"""
|
"""
|
||||||
import html
|
import html
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from app.models.session import Session
|
from app.models.session import Session
|
||||||
from app.schemas.session import SessionExport
|
from app.schemas.session import SessionExport
|
||||||
|
|
||||||
|
|
||||||
|
OUTCOME_LABELS = {
|
||||||
|
"resolved": "Resolved",
|
||||||
|
"escalated": "Escalated",
|
||||||
|
"workaround": "Workaround Applied",
|
||||||
|
"unresolved": "Unresolved",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _format_duration(started_at: datetime, completed_at: datetime | None) -> str:
|
def _format_duration(started_at: datetime, completed_at: datetime | None) -> str:
|
||||||
"""Format duration between two datetimes as human-readable string."""
|
"""Format duration between two datetimes as human-readable string."""
|
||||||
if not completed_at:
|
if not completed_at:
|
||||||
@@ -26,9 +35,64 @@ def _format_duration(started_at: datetime, completed_at: datetime | None) -> str
|
|||||||
return f"{minutes} minutes"
|
return f"{minutes} minutes"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_step_duration(duration_seconds: int) -> str:
|
||||||
|
"""Format step duration seconds as compact human-readable text."""
|
||||||
|
if duration_seconds < 0:
|
||||||
|
return "0s"
|
||||||
|
if duration_seconds < 60:
|
||||||
|
return f"{duration_seconds}s"
|
||||||
|
hours, remainder = divmod(duration_seconds, 3600)
|
||||||
|
minutes, seconds = divmod(remainder, 60)
|
||||||
|
if hours > 0:
|
||||||
|
if seconds > 0:
|
||||||
|
return f"{hours}h {minutes}m {seconds}s"
|
||||||
|
return f"{hours}h {minutes}m"
|
||||||
|
if seconds > 0:
|
||||||
|
return f"{minutes}m {seconds}s"
|
||||||
|
return f"{minutes}m"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_iso_datetime(value: Any) -> datetime | None:
|
||||||
|
"""Parse ISO datetime strings from JSONB with support for trailing Z."""
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
if not isinstance(value, str) or not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_step_duration_seconds(decision: dict[str, Any]) -> int | None:
|
||||||
|
"""Get step duration from explicit field or entered/exited timestamps."""
|
||||||
|
explicit_duration = decision.get("duration_seconds")
|
||||||
|
if isinstance(explicit_duration, (int, float)):
|
||||||
|
duration = int(explicit_duration)
|
||||||
|
if duration >= 0:
|
||||||
|
return duration
|
||||||
|
|
||||||
|
entered_at = _parse_iso_datetime(decision.get("entered_at"))
|
||||||
|
exited_at = _parse_iso_datetime(decision.get("exited_at"))
|
||||||
|
if entered_at and exited_at:
|
||||||
|
total_seconds = int((exited_at - entered_at).total_seconds())
|
||||||
|
if total_seconds >= 0:
|
||||||
|
return total_seconds
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_outcome_label(session: Session) -> str | None:
|
||||||
|
"""Map stored outcome enum to human-friendly label."""
|
||||||
|
outcome = getattr(session, "outcome", None)
|
||||||
|
if not outcome:
|
||||||
|
return None
|
||||||
|
return OUTCOME_LABELS.get(outcome, str(outcome).replace("_", " ").title())
|
||||||
|
|
||||||
|
|
||||||
def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
||||||
"""Generate markdown export."""
|
"""Generate markdown export."""
|
||||||
lines = []
|
lines = []
|
||||||
|
outcome_label = _get_outcome_label(session)
|
||||||
|
|
||||||
if options.include_tree_info:
|
if options.include_tree_info:
|
||||||
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
|
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
|
||||||
@@ -42,6 +106,9 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
|||||||
lines.append(f"**Started:** {session.started_at.strftime('%Y-%m-%d %H:%M')}")
|
lines.append(f"**Started:** {session.started_at.strftime('%Y-%m-%d %H:%M')}")
|
||||||
if session.completed_at:
|
if session.completed_at:
|
||||||
lines.append(f"**Completed:** {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
|
lines.append(f"**Completed:** {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
|
||||||
|
lines.append(f"**Duration:** {_format_duration(session.started_at, session.completed_at)}")
|
||||||
|
if outcome_label:
|
||||||
|
lines.append(f"**Outcome:** {outcome_label}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("---")
|
lines.append("---")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -63,12 +130,15 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
|||||||
question = decision.get("question") or decision.get("action_performed", "Step")
|
question = decision.get("question") or decision.get("action_performed", "Step")
|
||||||
answer = decision.get("answer", "")
|
answer = decision.get("answer", "")
|
||||||
notes = decision.get("notes", "")
|
notes = decision.get("notes", "")
|
||||||
|
duration_seconds = _get_step_duration_seconds(decision)
|
||||||
|
|
||||||
lines.append(f"### Step {i}: {question}")
|
lines.append(f"### Step {i}: {question}")
|
||||||
if answer:
|
if answer:
|
||||||
lines.append(f"**Answer:** {answer}")
|
lines.append(f"**Answer:** {answer}")
|
||||||
if notes:
|
if notes:
|
||||||
lines.append(f"**Notes:** {notes}")
|
lines.append(f"**Notes:** {notes}")
|
||||||
|
if duration_seconds is not None:
|
||||||
|
lines.append(f"**Duration:** {_format_step_duration(duration_seconds)}")
|
||||||
if options.include_timestamps and decision.get("timestamp"):
|
if options.include_timestamps and decision.get("timestamp"):
|
||||||
lines.append(f"*{decision['timestamp']}*")
|
lines.append(f"*{decision['timestamp']}*")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -79,6 +149,7 @@ def generate_markdown_export(session: Session, options: SessionExport) -> str:
|
|||||||
def generate_text_export(session: Session, options: SessionExport) -> str:
|
def generate_text_export(session: Session, options: SessionExport) -> str:
|
||||||
"""Generate plain text export."""
|
"""Generate plain text export."""
|
||||||
lines = []
|
lines = []
|
||||||
|
outcome_label = _get_outcome_label(session)
|
||||||
|
|
||||||
if options.include_tree_info:
|
if options.include_tree_info:
|
||||||
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
|
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
|
||||||
@@ -92,6 +163,9 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
|
|||||||
lines.append(f"Started: {session.started_at.strftime('%Y-%m-%d %H:%M')}")
|
lines.append(f"Started: {session.started_at.strftime('%Y-%m-%d %H:%M')}")
|
||||||
if session.completed_at:
|
if session.completed_at:
|
||||||
lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
|
lines.append(f"Completed: {session.completed_at.strftime('%Y-%m-%d %H:%M')}")
|
||||||
|
lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
|
||||||
|
if outcome_label:
|
||||||
|
lines.append(f"Outcome: {outcome_label}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Scratchpad / Evidence section
|
# Scratchpad / Evidence section
|
||||||
@@ -109,12 +183,15 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
|
|||||||
question = decision.get("question") or decision.get("action_performed", "Step")
|
question = decision.get("question") or decision.get("action_performed", "Step")
|
||||||
answer = decision.get("answer", "")
|
answer = decision.get("answer", "")
|
||||||
notes = decision.get("notes", "")
|
notes = decision.get("notes", "")
|
||||||
|
duration_seconds = _get_step_duration_seconds(decision)
|
||||||
|
|
||||||
lines.append(f"\n{i}. {question}")
|
lines.append(f"\n{i}. {question}")
|
||||||
if answer:
|
if answer:
|
||||||
lines.append(f" Answer: {answer}")
|
lines.append(f" Answer: {answer}")
|
||||||
if notes:
|
if notes:
|
||||||
lines.append(f" Notes: {notes}")
|
lines.append(f" Notes: {notes}")
|
||||||
|
if duration_seconds is not None:
|
||||||
|
lines.append(f" Duration: {_format_step_duration(duration_seconds)}")
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
@@ -122,6 +199,7 @@ def generate_text_export(session: Session, options: SessionExport) -> str:
|
|||||||
def generate_html_export(session: Session, options: SessionExport) -> str:
|
def generate_html_export(session: Session, options: SessionExport) -> str:
|
||||||
"""Generate HTML export."""
|
"""Generate HTML export."""
|
||||||
tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session"))
|
tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session"))
|
||||||
|
outcome_label = _get_outcome_label(session)
|
||||||
|
|
||||||
html_parts = ['<!DOCTYPE html>', '<html>', '<head>',
|
html_parts = ['<!DOCTYPE html>', '<html>', '<head>',
|
||||||
'<meta charset="UTF-8">',
|
'<meta charset="UTF-8">',
|
||||||
@@ -134,6 +212,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
|||||||
'.step h3 { margin: 0 0 10px 0; color: #444; }',
|
'.step h3 { margin: 0 0 10px 0; color: #444; }',
|
||||||
'.answer { font-weight: bold; }',
|
'.answer { font-weight: bold; }',
|
||||||
'.notes { font-style: italic; color: #555; }',
|
'.notes { font-style: italic; color: #555; }',
|
||||||
|
'.duration { color: #444; margin-top: 6px; }',
|
||||||
'.timestamp { font-size: 0.85em; color: #888; }',
|
'.timestamp { font-size: 0.85em; color: #888; }',
|
||||||
'</style>',
|
'</style>',
|
||||||
'</head>', '<body>']
|
'</head>', '<body>']
|
||||||
@@ -149,6 +228,9 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
|||||||
html_parts.append(f'<p><strong>Started:</strong> {session.started_at.strftime("%Y-%m-%d %H:%M")}</p>')
|
html_parts.append(f'<p><strong>Started:</strong> {session.started_at.strftime("%Y-%m-%d %H:%M")}</p>')
|
||||||
if session.completed_at:
|
if session.completed_at:
|
||||||
html_parts.append(f'<p><strong>Completed:</strong> {session.completed_at.strftime("%Y-%m-%d %H:%M")}</p>')
|
html_parts.append(f'<p><strong>Completed:</strong> {session.completed_at.strftime("%Y-%m-%d %H:%M")}</p>')
|
||||||
|
html_parts.append(f'<p><strong>Duration:</strong> {_format_duration(session.started_at, session.completed_at)}</p>')
|
||||||
|
if outcome_label:
|
||||||
|
html_parts.append(f'<p><strong>Outcome:</strong> {html.escape(outcome_label)}</p>')
|
||||||
html_parts.append('</div>')
|
html_parts.append('</div>')
|
||||||
|
|
||||||
# Scratchpad / Evidence section
|
# Scratchpad / Evidence section
|
||||||
@@ -163,6 +245,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
|||||||
question = html.escape(decision.get("question") or decision.get("action_performed", "Step"))
|
question = html.escape(decision.get("question") or decision.get("action_performed", "Step"))
|
||||||
answer = html.escape(decision.get("answer", ""))
|
answer = html.escape(decision.get("answer", ""))
|
||||||
notes = html.escape(decision.get("notes", ""))
|
notes = html.escape(decision.get("notes", ""))
|
||||||
|
duration_seconds = _get_step_duration_seconds(decision)
|
||||||
|
|
||||||
html_parts.append('<div class="step">')
|
html_parts.append('<div class="step">')
|
||||||
html_parts.append(f'<h3>Step {i}: {question}</h3>')
|
html_parts.append(f'<h3>Step {i}: {question}</h3>')
|
||||||
@@ -170,6 +253,8 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
|||||||
html_parts.append(f'<p class="answer">Answer: {answer}</p>')
|
html_parts.append(f'<p class="answer">Answer: {answer}</p>')
|
||||||
if notes:
|
if notes:
|
||||||
html_parts.append(f'<p class="notes">Notes: {notes}</p>')
|
html_parts.append(f'<p class="notes">Notes: {notes}</p>')
|
||||||
|
if duration_seconds is not None:
|
||||||
|
html_parts.append(f'<p class="duration"><strong>Duration:</strong> {_format_step_duration(duration_seconds)}</p>')
|
||||||
if options.include_timestamps and decision.get("timestamp"):
|
if options.include_timestamps and decision.get("timestamp"):
|
||||||
html_parts.append(f'<p class="timestamp">{html.escape(str(decision["timestamp"]))}</p>')
|
html_parts.append(f'<p class="timestamp">{html.escape(str(decision["timestamp"]))}</p>')
|
||||||
html_parts.append('</div>')
|
html_parts.append('</div>')
|
||||||
@@ -181,6 +266,7 @@ def generate_html_export(session: Session, options: SessionExport) -> str:
|
|||||||
def generate_psa_export(session: Session, options: SessionExport) -> str:
|
def generate_psa_export(session: Session, options: SessionExport) -> str:
|
||||||
"""Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools."""
|
"""Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools."""
|
||||||
lines = []
|
lines = []
|
||||||
|
outcome_label = _get_outcome_label(session)
|
||||||
|
|
||||||
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
|
tree_name = session.tree_snapshot.get("name", "Troubleshooting Session")
|
||||||
tree_description = session.tree_snapshot.get("description", "")
|
tree_description = session.tree_snapshot.get("description", "")
|
||||||
@@ -191,6 +277,9 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
|
|||||||
lines.append("=== TROUBLESHOOTING NOTES ===")
|
lines.append("=== TROUBLESHOOTING NOTES ===")
|
||||||
lines.append(f"Ticket: {ticket_number} | Client: {client_name}")
|
lines.append(f"Ticket: {ticket_number} | Client: {client_name}")
|
||||||
lines.append(f"Tree: {tree_name} | Date: {date_str}")
|
lines.append(f"Tree: {tree_name} | Date: {date_str}")
|
||||||
|
lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
|
||||||
|
if outcome_label:
|
||||||
|
lines.append(f"Outcome: {outcome_label}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Problem section
|
# Problem section
|
||||||
@@ -205,10 +294,13 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
|
|||||||
question = decision.get("question") or decision.get("action_performed", "Step")
|
question = decision.get("question") or decision.get("action_performed", "Step")
|
||||||
answer = decision.get("answer", "")
|
answer = decision.get("answer", "")
|
||||||
notes = decision.get("notes", "")
|
notes = decision.get("notes", "")
|
||||||
|
duration_seconds = _get_step_duration_seconds(decision)
|
||||||
|
|
||||||
line = f"{i}. {question}"
|
line = f"{i}. {question}"
|
||||||
if answer:
|
if answer:
|
||||||
line += f" -> {answer}"
|
line += f" -> {answer}"
|
||||||
|
if duration_seconds is not None:
|
||||||
|
line += f" ({_format_step_duration(duration_seconds)})"
|
||||||
lines.append(line)
|
lines.append(line)
|
||||||
if notes:
|
if notes:
|
||||||
lines.append(f" Notes: {notes}")
|
lines.append(f" Notes: {notes}")
|
||||||
@@ -224,6 +316,8 @@ def generate_psa_export(session: Session, options: SessionExport) -> str:
|
|||||||
lines.append(resolution)
|
lines.append(resolution)
|
||||||
else:
|
else:
|
||||||
lines.append("No resolution recorded.")
|
lines.append("No resolution recorded.")
|
||||||
|
if outcome_label:
|
||||||
|
lines.append(f"Outcome: {outcome_label}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
# Time spent
|
# Time spent
|
||||||
|
|||||||
@@ -153,12 +153,34 @@ class TestSessions:
|
|||||||
# Complete session
|
# Complete session
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/api/v1/sessions/{session_id}/complete",
|
f"/api/v1/sessions/{session_id}/complete",
|
||||||
|
json={"outcome": "resolved", "outcome_notes": "Issue fixed after restarting service"},
|
||||||
headers=auth_headers
|
headers=auth_headers
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["completed_at"] is not None
|
assert data["completed_at"] is not None
|
||||||
|
assert data["outcome"] == "resolved"
|
||||||
|
assert data["outcome_notes"] == "Issue fixed after restarting service"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_complete_session_requires_outcome(
|
||||||
|
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||||
|
):
|
||||||
|
"""Test that completion requires an outcome payload."""
|
||||||
|
create_response = await client.post(
|
||||||
|
"/api/v1/sessions",
|
||||||
|
json={"tree_id": test_tree["id"]},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
session_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
f"/api/v1/sessions/{session_id}/complete",
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_complete_already_completed_session(
|
async def test_complete_already_completed_session(
|
||||||
@@ -175,12 +197,14 @@ class TestSessions:
|
|||||||
|
|
||||||
await client.post(
|
await client.post(
|
||||||
f"/api/v1/sessions/{session_id}/complete",
|
f"/api/v1/sessions/{session_id}/complete",
|
||||||
|
json={"outcome": "resolved"},
|
||||||
headers=auth_headers
|
headers=auth_headers
|
||||||
)
|
)
|
||||||
|
|
||||||
# Try to complete again
|
# Try to complete again
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
f"/api/v1/sessions/{session_id}/complete",
|
f"/api/v1/sessions/{session_id}/complete",
|
||||||
|
json={"outcome": "resolved"},
|
||||||
headers=auth_headers
|
headers=auth_headers
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -217,6 +241,59 @@ class TestSessions:
|
|||||||
assert "EXP-001" in content # Should contain ticket number
|
assert "EXP-001" in content # Should contain ticket number
|
||||||
assert "#" in content # Markdown headers
|
assert "#" in content # Markdown headers
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_export_markdown_includes_outcome_and_step_duration(
|
||||||
|
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||||
|
):
|
||||||
|
"""Test markdown export includes session outcome and per-step duration."""
|
||||||
|
create_response = await client.post(
|
||||||
|
"/api/v1/sessions",
|
||||||
|
json={"tree_id": test_tree["id"], "ticket_number": "EXP-OUTCOME-001"},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
session_id = create_response.json()["id"]
|
||||||
|
|
||||||
|
step_timestamp = "2026-02-11T10:10:00Z"
|
||||||
|
update_response = await client.put(
|
||||||
|
f"/api/v1/sessions/{session_id}",
|
||||||
|
json={
|
||||||
|
"decisions": [{
|
||||||
|
"node_id": "root",
|
||||||
|
"question": "Is this a test?",
|
||||||
|
"answer": "Yes",
|
||||||
|
"action_performed": None,
|
||||||
|
"notes": "Validated quickly",
|
||||||
|
"automation_used": False,
|
||||||
|
"timestamp": step_timestamp,
|
||||||
|
"entered_at": "2026-02-11T10:08:30Z",
|
||||||
|
"exited_at": step_timestamp,
|
||||||
|
"duration_seconds": 90,
|
||||||
|
"attachments": []
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
assert update_response.status_code == 200
|
||||||
|
|
||||||
|
complete_response = await client.post(
|
||||||
|
f"/api/v1/sessions/{session_id}/complete",
|
||||||
|
json={"outcome": "workaround", "outcome_notes": "Temporary mitigation applied"},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
assert complete_response.status_code == 200
|
||||||
|
|
||||||
|
export_response = await client.post(
|
||||||
|
f"/api/v1/sessions/{session_id}/export",
|
||||||
|
json={"format": "markdown", "include_timestamps": True, "include_tree_info": True},
|
||||||
|
headers=auth_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
assert export_response.status_code == 200
|
||||||
|
content = export_response.text
|
||||||
|
assert "**Outcome:** Workaround Applied" in content
|
||||||
|
assert "**Duration:**" in content
|
||||||
|
assert "**Duration:** 1m 30s" in content
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_export_session_text(
|
async def test_export_session_text(
|
||||||
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
self, client: AsyncClient, auth_headers: dict, test_tree: dict
|
||||||
@@ -291,6 +368,7 @@ class TestSessions:
|
|||||||
# Complete first session
|
# Complete first session
|
||||||
await client.post(
|
await client.post(
|
||||||
f"/api/v1/sessions/{session1_id}/complete",
|
f"/api/v1/sessions/{session1_id}/complete",
|
||||||
|
json={"outcome": "resolved"},
|
||||||
headers=auth_headers
|
headers=auth_headers
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -449,6 +527,7 @@ class TestSessions:
|
|||||||
|
|
||||||
await client.post(
|
await client.post(
|
||||||
f"/api/v1/sessions/{session_id}/complete",
|
f"/api/v1/sessions/{session_id}/complete",
|
||||||
|
json={"outcome": "resolved"},
|
||||||
headers=auth_headers
|
headers=auth_headers
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -857,6 +936,7 @@ class TestSessions:
|
|||||||
|
|
||||||
await client.post(
|
await client.post(
|
||||||
f"/api/v1/sessions/{session_id}/complete",
|
f"/api/v1/sessions/{session_id}/complete",
|
||||||
|
json={"outcome": "resolved"},
|
||||||
headers=auth_headers
|
headers=auth_headers
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client'
|
||||||
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse } from '@/types'
|
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete } from '@/types'
|
||||||
|
|
||||||
export interface SessionListParams {
|
export interface SessionListParams {
|
||||||
page?: number
|
page?: number
|
||||||
@@ -44,8 +44,8 @@ export const sessionsApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async complete(id: string): Promise<Session> {
|
async complete(id: string, data: SessionComplete): Promise<Session> {
|
||||||
const response = await apiClient.post<Session>(`/sessions/${id}/complete`)
|
const response = await apiClient.post<Session>(`/sessions/${id}/complete`, data)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
122
frontend/src/components/session/SessionOutcomeModal.tsx
Normal file
122
frontend/src/components/session/SessionOutcomeModal.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Modal } from '@/components/common/Modal'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { SessionOutcome } from '@/types'
|
||||||
|
|
||||||
|
interface SessionOutcomeModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSubmit: (data: { outcome: SessionOutcome; outcome_notes?: string }) => Promise<void>
|
||||||
|
isSubmitting?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const OUTCOME_OPTIONS: Array<{ value: SessionOutcome; label: string; description: string }> = [
|
||||||
|
{ value: 'resolved', label: 'Resolved', description: 'Issue fully resolved in this session.' },
|
||||||
|
{ value: 'workaround', label: 'Workaround', description: 'Temporary fix applied, root cause remains.' },
|
||||||
|
{ value: 'escalated', label: 'Escalated', description: 'Handed off to another engineer/team.' },
|
||||||
|
{ value: 'unresolved', label: 'Unresolved', description: 'No fix or workaround identified yet.' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function SessionOutcomeModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onSubmit,
|
||||||
|
isSubmitting = false,
|
||||||
|
}: SessionOutcomeModalProps) {
|
||||||
|
const [outcome, setOutcome] = useState<SessionOutcome>('resolved')
|
||||||
|
const [outcomeNotes, setOutcomeNotes] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
setOutcome('resolved')
|
||||||
|
setOutcomeNotes('')
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
await onSubmit({
|
||||||
|
outcome,
|
||||||
|
outcome_notes: outcomeNotes.trim() || undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onClose={onClose}
|
||||||
|
title="Session Outcome"
|
||||||
|
footer={(
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||||
|
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||||
|
'hover:bg-white/90 disabled:opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Completing...' : 'Complete Session'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-white/70">
|
||||||
|
Select the session outcome before completion.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{OUTCOME_OPTIONS.map((option) => (
|
||||||
|
<label
|
||||||
|
key={option.value}
|
||||||
|
className={cn(
|
||||||
|
'block cursor-pointer rounded-lg border border-white/10 p-3 transition-colors',
|
||||||
|
outcome === option.value ? 'border-white/30 bg-white/10' : 'hover:bg-white/[0.04]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="session-outcome"
|
||||||
|
value={option.value}
|
||||||
|
checked={outcome === option.value}
|
||||||
|
onChange={() => setOutcome(option.value)}
|
||||||
|
className="mt-1 h-4 w-4"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-white">{option.label}</p>
|
||||||
|
<p className="text-xs text-white/50">{option.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-white">Outcome Notes (optional)</label>
|
||||||
|
<textarea
|
||||||
|
value={outcomeNotes}
|
||||||
|
onChange={(e) => setOutcomeNotes(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Add context for this outcome..."
|
||||||
|
className={cn(
|
||||||
|
'mt-1 block w-full rounded-md border border-white/10 bg-black/50 px-3 py-2',
|
||||||
|
'text-sm text-white placeholder:text-white/40',
|
||||||
|
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@ export { PostStepActionModal } from './PostStepActionModal'
|
|||||||
export { ContinuationModal, type DescendantNode } from './ContinuationModal'
|
export { ContinuationModal, type DescendantNode } from './ContinuationModal'
|
||||||
export { ForkTreeModal } from './ForkTreeModal'
|
export { ForkTreeModal } from './ForkTreeModal'
|
||||||
export { ScratchpadSidebar } from './ScratchpadSidebar'
|
export { ScratchpadSidebar } from './ScratchpadSidebar'
|
||||||
|
export { SessionOutcomeModal } from './SessionOutcomeModal'
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ interface UseCustomStepFlowParams {
|
|||||||
setPathTaken: (path: string[]) => void
|
setPathTaken: (path: string[]) => void
|
||||||
setDecisions: (decisions: DecisionRecord[]) => void
|
setDecisions: (decisions: DecisionRecord[]) => void
|
||||||
setNotes: (notes: string) => void
|
setNotes: (notes: string) => void
|
||||||
setIsCompleting: (completing: boolean) => void
|
|
||||||
setError: (error: string | null) => void
|
setError: (error: string | null) => void
|
||||||
|
onEnterNode: (enteredAtIso: string) => void
|
||||||
isCompleting: boolean
|
isCompleting: boolean
|
||||||
|
onRequestCompletion: (completionDecision: DecisionRecord, source: 'custom') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCustomStepFlow({
|
export function useCustomStepFlow({
|
||||||
@@ -36,9 +37,10 @@ export function useCustomStepFlow({
|
|||||||
setPathTaken,
|
setPathTaken,
|
||||||
setDecisions,
|
setDecisions,
|
||||||
setNotes,
|
setNotes,
|
||||||
setIsCompleting,
|
|
||||||
setError,
|
setError,
|
||||||
|
onEnterNode,
|
||||||
isCompleting,
|
isCompleting,
|
||||||
|
onRequestCompletion,
|
||||||
}: UseCustomStepFlowParams) {
|
}: UseCustomStepFlowParams) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
@@ -112,9 +114,11 @@ export function useCustomStepFlow({
|
|||||||
|
|
||||||
// Navigate back to a previously-created custom step from the decision node
|
// Navigate back to a previously-created custom step from the decision node
|
||||||
const handleNavigateToCustomStep = (customStep: CustomStep) => {
|
const handleNavigateToCustomStep = (customStep: CustomStep) => {
|
||||||
|
const enteredAt = new Date().toISOString()
|
||||||
const newPath = [...pathTaken, customStep.id]
|
const newPath = [...pathTaken, customStep.id]
|
||||||
setPathTaken(newPath)
|
setPathTaken(newPath)
|
||||||
setCurrentNodeId(customStep.id)
|
setCurrentNodeId(customStep.id)
|
||||||
|
onEnterNode(enteredAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called when CustomStepModal submits - show action modal instead of inserting directly
|
// Called when CustomStepModal submits - show action modal instead of inserting directly
|
||||||
@@ -169,6 +173,7 @@ export function useCustomStepFlow({
|
|||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const decisionTimestamp = new Date().toISOString()
|
||||||
const newDecision: DecisionRecord = {
|
const newDecision: DecisionRecord = {
|
||||||
node_id: customStep.id,
|
node_id: customStep.id,
|
||||||
question: null,
|
question: null,
|
||||||
@@ -176,7 +181,10 @@ export function useCustomStepFlow({
|
|||||||
action_performed: `Custom Step: ${pendingStep.title}`,
|
action_performed: `Custom Step: ${pendingStep.title}`,
|
||||||
notes: pendingStep.content.instructions || null,
|
notes: pendingStep.content.instructions || null,
|
||||||
automation_used: false,
|
automation_used: false,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: decisionTimestamp,
|
||||||
|
entered_at: decisionTimestamp,
|
||||||
|
exited_at: decisionTimestamp,
|
||||||
|
duration_seconds: 0,
|
||||||
attachments: []
|
attachments: []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +196,7 @@ export function useCustomStepFlow({
|
|||||||
setDecisions(newDecisions)
|
setDecisions(newDecisions)
|
||||||
setPathTaken(newPath)
|
setPathTaken(newPath)
|
||||||
setCurrentNodeId(customStep.id)
|
setCurrentNodeId(customStep.id)
|
||||||
|
onEnterNode(decisionTimestamp)
|
||||||
|
|
||||||
await sessionsApi.update(session.id, {
|
await sessionsApi.update(session.id, {
|
||||||
path_taken: newPath,
|
path_taken: newPath,
|
||||||
@@ -236,9 +245,11 @@ export function useCustomStepFlow({
|
|||||||
const handleContinueToDescendant = async () => {
|
const handleContinueToDescendant = async () => {
|
||||||
if (!pendingContinuationNodeId || !session) return
|
if (!pendingContinuationNodeId || !session) return
|
||||||
|
|
||||||
|
const enteredAt = new Date().toISOString()
|
||||||
const newPath = [...pathTaken, pendingContinuationNodeId]
|
const newPath = [...pathTaken, pendingContinuationNodeId]
|
||||||
setPathTaken(newPath)
|
setPathTaken(newPath)
|
||||||
setCurrentNodeId(pendingContinuationNodeId)
|
setCurrentNodeId(pendingContinuationNodeId)
|
||||||
|
onEnterNode(enteredAt)
|
||||||
setNotes('')
|
setNotes('')
|
||||||
setPendingContinuationNodeId(null)
|
setPendingContinuationNodeId(null)
|
||||||
|
|
||||||
@@ -259,10 +270,7 @@ export function useCustomStepFlow({
|
|||||||
const handleCustomBranchComplete = async () => {
|
const handleCustomBranchComplete = async () => {
|
||||||
if (!session) return
|
if (!session) return
|
||||||
|
|
||||||
setIsCompleting(true)
|
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
|
||||||
const completionDecision: DecisionRecord = {
|
const completionDecision: DecisionRecord = {
|
||||||
node_id: currentNodeId,
|
node_id: currentNodeId,
|
||||||
question: null,
|
question: null,
|
||||||
@@ -273,24 +281,7 @@ export function useCustomStepFlow({
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
attachments: []
|
attachments: []
|
||||||
}
|
}
|
||||||
|
onRequestCompletion(completionDecision, 'custom')
|
||||||
await sessionsApi.update(session.id, {
|
|
||||||
decisions: [...decisions, completionDecision]
|
|
||||||
})
|
|
||||||
|
|
||||||
await sessionsApi.complete(session.id)
|
|
||||||
|
|
||||||
if (customSteps.length > 0) {
|
|
||||||
setShowForkModal(true)
|
|
||||||
} else {
|
|
||||||
navigate(`/sessions/${session.id}`)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to complete session:', err)
|
|
||||||
setError('Failed to complete session. Please try again.')
|
|
||||||
} finally {
|
|
||||||
setIsCompleting(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fork tree with custom branch
|
// Fork tree with custom branch
|
||||||
|
|||||||
@@ -5,12 +5,23 @@ export function useSessionTimer(startedAt: string | undefined | null): string |
|
|||||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Always clear any previous interval before (re)initializing.
|
||||||
|
if (intervalRef.current) {
|
||||||
|
clearInterval(intervalRef.current)
|
||||||
|
intervalRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
if (!startedAt) {
|
if (!startedAt) {
|
||||||
setElapsed(null)
|
setElapsed(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const startTime = new Date(startedAt).getTime()
|
const parsedStartTime = new Date(startedAt).getTime()
|
||||||
|
// If the server timestamp is invalid or ahead of the local clock, fall back to "now"
|
||||||
|
// so the timer still starts ticking immediately for the user.
|
||||||
|
const startTime = Number.isNaN(parsedStartTime) || parsedStartTime > Date.now()
|
||||||
|
? Date.now()
|
||||||
|
: parsedStartTime
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const diff = Math.max(0, Math.floor((Date.now() - startTime) / 1000))
|
const diff = Math.max(0, Math.floor((Date.now() - startTime) / 1000))
|
||||||
|
|||||||
@@ -237,6 +237,31 @@ export function SessionDetailPage() {
|
|||||||
return new Date(dateString).toLocaleString()
|
return new Date(dateString).toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatDuration = (durationSeconds: number | null | undefined) => {
|
||||||
|
if (durationSeconds == null || durationSeconds < 0) return null
|
||||||
|
if (durationSeconds < 60) return `${durationSeconds}s`
|
||||||
|
const hours = Math.floor(durationSeconds / 3600)
|
||||||
|
const minutes = Math.floor((durationSeconds % 3600) / 60)
|
||||||
|
const seconds = durationSeconds % 60
|
||||||
|
if (hours > 0) return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m`
|
||||||
|
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTotalDuration = () => {
|
||||||
|
if (!session?.completed_at) return 'In progress'
|
||||||
|
const startedAtMs = new Date(session.started_at).getTime()
|
||||||
|
const completedAtMs = new Date(session.completed_at).getTime()
|
||||||
|
if (Number.isNaN(startedAtMs) || Number.isNaN(completedAtMs)) return 'Unknown'
|
||||||
|
const seconds = Math.max(0, Math.floor((completedAtMs - startedAtMs) / 1000))
|
||||||
|
return formatDuration(seconds) || '0s'
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcomeLabel = session?.outcome
|
||||||
|
? session.outcome === 'workaround'
|
||||||
|
? 'Workaround'
|
||||||
|
: session.outcome.charAt(0).toUpperCase() + session.outcome.slice(1)
|
||||||
|
: null
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-64 items-center justify-center">
|
<div className="flex h-64 items-center justify-center">
|
||||||
@@ -292,7 +317,20 @@ export function SessionDetailPage() {
|
|||||||
{session.completed_at ? 'Completed' : 'In Progress'}
|
{session.completed_at ? 'Completed' : 'In Progress'}
|
||||||
</span>
|
</span>
|
||||||
{session.client_name && <span>Client: {session.client_name}</span>}
|
{session.client_name && <span>Client: {session.client_name}</span>}
|
||||||
|
{session.completed_at && (
|
||||||
|
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
|
||||||
|
Duration: {getTotalDuration()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{outcomeLabel && (
|
||||||
|
<span className="rounded-full bg-white/10 px-2 py-0.5 text-xs text-white">
|
||||||
|
Outcome: {outcomeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{session.outcome_notes && (
|
||||||
|
<p className="mt-2 text-sm text-white/60">Outcome Notes: {session.outcome_notes}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@@ -401,6 +439,11 @@ export function SessionDetailPage() {
|
|||||||
Notes: {decision.notes}
|
Notes: {decision.notes}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{decision.duration_seconds != null && (
|
||||||
|
<p className="mt-2 text-xs text-white/50">
|
||||||
|
Duration: {formatDuration(decision.duration_seconds)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="mt-2 text-xs text-white/40">
|
<p className="mt-2 text-xs text-white/40">
|
||||||
{formatDate(decision.timestamp)}
|
{formatDate(decision.timestamp)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ export function SessionHistoryPage() {
|
|||||||
return session.tree_snapshot?.name || 'Unknown Tree'
|
return session.tree_snapshot?.name || 'Unknown Tree'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatOutcomeLabel = (outcome: Session['outcome']): string => {
|
||||||
|
if (!outcome) return 'Not set'
|
||||||
|
return outcome === 'workaround'
|
||||||
|
? 'Workaround'
|
||||||
|
: outcome.charAt(0).toUpperCase() + outcome.slice(1)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -226,6 +233,20 @@ export function SessionHistoryPage() {
|
|||||||
{session.client_name}
|
{session.client_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{session.completed_at && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'rounded-full px-2.5 py-0.5 text-xs font-medium',
|
||||||
|
session.outcome === 'resolved' && 'bg-emerald-500/20 text-emerald-300',
|
||||||
|
session.outcome === 'workaround' && 'bg-amber-500/20 text-amber-300',
|
||||||
|
session.outcome === 'escalated' && 'bg-blue-500/20 text-blue-300',
|
||||||
|
session.outcome === 'unresolved' && 'bg-rose-500/20 text-rose-300',
|
||||||
|
!session.outcome && 'bg-white/10 text-white/70'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatOutcomeLabel(session.outcome)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tree Name */}
|
{/* Tree Name */}
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ import { sessionsApi } from '@/api/sessions'
|
|||||||
import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts'
|
import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts'
|
||||||
import { useCustomStepFlow } from '@/hooks/useCustomStepFlow'
|
import { useCustomStepFlow } from '@/hooks/useCustomStepFlow'
|
||||||
import { useSessionTimer } from '@/hooks/useSessionTimer'
|
import { useSessionTimer } from '@/hooks/useSessionTimer'
|
||||||
import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types'
|
import type { Tree, Session, DecisionRecord, TreeStructure, SessionOutcome } from '@/types'
|
||||||
import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
|
import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
|
||||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||||
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
||||||
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar } from '@/components/session'
|
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session'
|
||||||
import { Plus, CheckCircle, ArrowRight, Clock } from 'lucide-react'
|
import { Plus, CheckCircle, ArrowRight, Clock } from 'lucide-react'
|
||||||
|
|
||||||
interface LocationState {
|
interface LocationState {
|
||||||
@@ -18,6 +18,8 @@ interface LocationState {
|
|||||||
prefillTicketNumber?: string
|
prefillTicketNumber?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CompletionSource = 'standard' | 'custom'
|
||||||
|
|
||||||
export function TreeNavigationPage() {
|
export function TreeNavigationPage() {
|
||||||
const { id: treeId } = useParams<{ id: string }>()
|
const { id: treeId } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -29,10 +31,14 @@ export function TreeNavigationPage() {
|
|||||||
const [currentNodeId, setCurrentNodeId] = useState<string>('root')
|
const [currentNodeId, setCurrentNodeId] = useState<string>('root')
|
||||||
const [pathTaken, setPathTaken] = useState<string[]>(['root'])
|
const [pathTaken, setPathTaken] = useState<string[]>(['root'])
|
||||||
const [decisions, setDecisions] = useState<DecisionRecord[]>([])
|
const [decisions, setDecisions] = useState<DecisionRecord[]>([])
|
||||||
|
const [currentStepEnteredAt, setCurrentStepEnteredAt] = useState<string>(new Date().toISOString())
|
||||||
const [notes, setNotes] = useState<string>('')
|
const [notes, setNotes] = useState<string>('')
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [isCompleting, setIsCompleting] = useState(false)
|
const [isCompleting, setIsCompleting] = useState(false)
|
||||||
|
const [showOutcomeModal, setShowOutcomeModal] = useState(false)
|
||||||
|
const [pendingCompletionDecision, setPendingCompletionDecision] = useState<DecisionRecord | null>(null)
|
||||||
|
const [completionSource, setCompletionSource] = useState<CompletionSource>('standard')
|
||||||
|
|
||||||
// Session metadata (prefill from Repeat Last Session)
|
// Session metadata (prefill from Repeat Last Session)
|
||||||
const [ticketNumber, setTicketNumber] = useState<string>(locationState?.prefillTicketNumber || '')
|
const [ticketNumber, setTicketNumber] = useState<string>(locationState?.prefillTicketNumber || '')
|
||||||
@@ -59,6 +65,42 @@ export function TreeNavigationPage() {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const calculateDurationSeconds = (enteredAtIso: string, exitedAtIso: string): number => {
|
||||||
|
const enteredAtMs = new Date(enteredAtIso).getTime()
|
||||||
|
const exitedAtMs = new Date(exitedAtIso).getTime()
|
||||||
|
if (Number.isNaN(enteredAtMs) || Number.isNaN(exitedAtMs)) return 0
|
||||||
|
return Math.max(0, Math.floor((exitedAtMs - enteredAtMs) / 1000))
|
||||||
|
}
|
||||||
|
|
||||||
|
const deriveCurrentStepEnteredAt = (sessionData: Session): string => {
|
||||||
|
if (!sessionData.decisions || sessionData.decisions.length === 0) {
|
||||||
|
return sessionData.started_at
|
||||||
|
}
|
||||||
|
const lastDecision = sessionData.decisions[sessionData.decisions.length - 1]
|
||||||
|
return lastDecision.exited_at || lastDecision.timestamp || sessionData.started_at
|
||||||
|
}
|
||||||
|
|
||||||
|
const openCompletionModal = (completionDecision: DecisionRecord, source: CompletionSource) => {
|
||||||
|
const exitedAt = new Date().toISOString()
|
||||||
|
const enteredAt = currentStepEnteredAt || session?.started_at || exitedAt
|
||||||
|
setPendingCompletionDecision({
|
||||||
|
...completionDecision,
|
||||||
|
timestamp: exitedAt,
|
||||||
|
entered_at: enteredAt,
|
||||||
|
exited_at: exitedAt,
|
||||||
|
duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
|
||||||
|
})
|
||||||
|
setCompletionSource(source)
|
||||||
|
setShowOutcomeModal(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeOutcomeModal = () => {
|
||||||
|
if (isCompleting) return
|
||||||
|
setShowOutcomeModal(false)
|
||||||
|
setPendingCompletionDecision(null)
|
||||||
|
setCompletionSource('standard')
|
||||||
|
}
|
||||||
|
|
||||||
// Custom step flow (creation, post-step actions, continuation, branching, forking)
|
// Custom step flow (creation, post-step actions, continuation, branching, forking)
|
||||||
const customStepFlow = useCustomStepFlow({
|
const customStepFlow = useCustomStepFlow({
|
||||||
tree,
|
tree,
|
||||||
@@ -72,9 +114,12 @@ export function TreeNavigationPage() {
|
|||||||
setPathTaken,
|
setPathTaken,
|
||||||
setDecisions,
|
setDecisions,
|
||||||
setNotes,
|
setNotes,
|
||||||
setIsCompleting,
|
|
||||||
setError,
|
setError,
|
||||||
|
onEnterNode: setCurrentStepEnteredAt,
|
||||||
isCompleting,
|
isCompleting,
|
||||||
|
onRequestCompletion: (completionDecision) => {
|
||||||
|
openCompletionModal(completionDecision, 'custom')
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleScratchpadSave = async (content: string) => {
|
const handleScratchpadSave = async (content: string) => {
|
||||||
@@ -102,6 +147,7 @@ export function TreeNavigationPage() {
|
|||||||
setPathTaken(sessionData.path_taken)
|
setPathTaken(sessionData.path_taken)
|
||||||
setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || 'root')
|
setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || 'root')
|
||||||
setDecisions(sessionData.decisions as DecisionRecord[])
|
setDecisions(sessionData.decisions as DecisionRecord[])
|
||||||
|
setCurrentStepEnteredAt(deriveCurrentStepEnteredAt(sessionData))
|
||||||
customStepFlow.initCustomSteps(sessionData.custom_steps || [])
|
customStepFlow.initCustomSteps(sessionData.custom_steps || [])
|
||||||
setTicketNumber(sessionData.ticket_number || '')
|
setTicketNumber(sessionData.ticket_number || '')
|
||||||
setClientName(sessionData.client_name || '')
|
setClientName(sessionData.client_name || '')
|
||||||
@@ -125,6 +171,7 @@ export function TreeNavigationPage() {
|
|||||||
client_name: clientName || undefined,
|
client_name: clientName || undefined,
|
||||||
})
|
})
|
||||||
setSession(newSession)
|
setSession(newSession)
|
||||||
|
setCurrentStepEnteredAt(newSession.started_at)
|
||||||
setShowMetadataForm(false)
|
setShowMetadataForm(false)
|
||||||
// Save for "Repeat Last Session"
|
// Save for "Repeat Last Session"
|
||||||
safeSetItem('last-session', JSON.stringify({
|
safeSetItem('last-session', JSON.stringify({
|
||||||
@@ -147,6 +194,8 @@ export function TreeNavigationPage() {
|
|||||||
const node = findNode(currentNodeId, tree.tree_structure)
|
const node = findNode(currentNodeId, tree.tree_structure)
|
||||||
if (!node) return
|
if (!node) return
|
||||||
|
|
||||||
|
const exitedAt = new Date().toISOString()
|
||||||
|
const enteredAt = currentStepEnteredAt || session.started_at || exitedAt
|
||||||
const newDecision: DecisionRecord = {
|
const newDecision: DecisionRecord = {
|
||||||
node_id: currentNodeId,
|
node_id: currentNodeId,
|
||||||
question: node.question || null,
|
question: node.question || null,
|
||||||
@@ -154,7 +203,10 @@ export function TreeNavigationPage() {
|
|||||||
action_performed: null,
|
action_performed: null,
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
automation_used: false,
|
automation_used: false,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: exitedAt,
|
||||||
|
entered_at: enteredAt,
|
||||||
|
exited_at: exitedAt,
|
||||||
|
duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
|
||||||
attachments: [],
|
attachments: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,6 +216,7 @@ export function TreeNavigationPage() {
|
|||||||
setPathTaken(newPath)
|
setPathTaken(newPath)
|
||||||
setDecisions(newDecisions)
|
setDecisions(newDecisions)
|
||||||
setCurrentNodeId(nextNodeId)
|
setCurrentNodeId(nextNodeId)
|
||||||
|
setCurrentStepEnteredAt(exitedAt)
|
||||||
setNotes('')
|
setNotes('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -182,6 +235,8 @@ export function TreeNavigationPage() {
|
|||||||
const node = findNode(currentNodeId, tree.tree_structure)
|
const node = findNode(currentNodeId, tree.tree_structure)
|
||||||
if (!node || !node.next_node_id) return
|
if (!node || !node.next_node_id) return
|
||||||
|
|
||||||
|
const exitedAt = new Date().toISOString()
|
||||||
|
const enteredAt = currentStepEnteredAt || session.started_at || exitedAt
|
||||||
const newDecision: DecisionRecord = {
|
const newDecision: DecisionRecord = {
|
||||||
node_id: currentNodeId,
|
node_id: currentNodeId,
|
||||||
question: null,
|
question: null,
|
||||||
@@ -189,7 +244,10 @@ export function TreeNavigationPage() {
|
|||||||
action_performed: actionPerformed || node.title || 'Action completed',
|
action_performed: actionPerformed || node.title || 'Action completed',
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
automation_used: false,
|
automation_used: false,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: exitedAt,
|
||||||
|
entered_at: enteredAt,
|
||||||
|
exited_at: exitedAt,
|
||||||
|
duration_seconds: calculateDurationSeconds(enteredAt, exitedAt),
|
||||||
attachments: [],
|
attachments: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +257,7 @@ export function TreeNavigationPage() {
|
|||||||
setPathTaken(newPath)
|
setPathTaken(newPath)
|
||||||
setDecisions(newDecisions)
|
setDecisions(newDecisions)
|
||||||
setCurrentNodeId(node.next_node_id)
|
setCurrentNodeId(node.next_node_id)
|
||||||
|
setCurrentStepEnteredAt(exitedAt)
|
||||||
setNotes('')
|
setNotes('')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -213,12 +272,9 @@ export function TreeNavigationPage() {
|
|||||||
|
|
||||||
const handleComplete = async () => {
|
const handleComplete = async () => {
|
||||||
if (!session || !tree) return
|
if (!session || !tree) return
|
||||||
setIsCompleting(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const node = findNode(currentNodeId, tree.tree_structure)
|
const node = findNode(currentNodeId, tree.tree_structure)
|
||||||
if (node) {
|
if (!node) return
|
||||||
const finalDecision: DecisionRecord = {
|
const completionDecision: DecisionRecord = {
|
||||||
node_id: currentNodeId,
|
node_id: currentNodeId,
|
||||||
question: null,
|
question: null,
|
||||||
answer: null,
|
answer: null,
|
||||||
@@ -228,13 +284,32 @@ export function TreeNavigationPage() {
|
|||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
attachments: [],
|
attachments: [],
|
||||||
}
|
}
|
||||||
|
openCompletionModal(completionDecision, 'standard')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmitOutcome = async (data: { outcome: SessionOutcome; outcome_notes?: string }) => {
|
||||||
|
if (!session) return
|
||||||
|
setIsCompleting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
let finalDecisions = decisions
|
||||||
|
if (pendingCompletionDecision) {
|
||||||
|
finalDecisions = [...decisions, pendingCompletionDecision]
|
||||||
|
setDecisions(finalDecisions)
|
||||||
await sessionsApi.update(session.id, {
|
await sessionsApi.update(session.id, {
|
||||||
decisions: [...decisions, finalDecision],
|
decisions: finalDecisions,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await sessionsApi.complete(session.id)
|
await sessionsApi.complete(session.id, data)
|
||||||
|
|
||||||
|
setShowOutcomeModal(false)
|
||||||
|
setPendingCompletionDecision(null)
|
||||||
|
if (completionSource === 'custom' && customStepFlow.customSteps.length > 0) {
|
||||||
|
customStepFlow.setShowForkModal(true)
|
||||||
|
} else {
|
||||||
navigate(`/sessions/${session.id}`)
|
navigate(`/sessions/${session.id}`)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to complete session:', err)
|
console.error('Failed to complete session:', err)
|
||||||
setError('Failed to complete session. Check console for details.')
|
setError('Failed to complete session. Check console for details.')
|
||||||
@@ -250,6 +325,7 @@ export function TreeNavigationPage() {
|
|||||||
setPathTaken(newPath)
|
setPathTaken(newPath)
|
||||||
setDecisions(newDecisions)
|
setDecisions(newDecisions)
|
||||||
setCurrentNodeId(newPath[newPath.length - 1])
|
setCurrentNodeId(newPath[newPath.length - 1])
|
||||||
|
setCurrentStepEnteredAt(new Date().toISOString())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute current node for keyboard shortcuts (must be before any returns for hooks rules)
|
// Compute current node for keyboard shortcuts (must be before any returns for hooks rules)
|
||||||
@@ -763,6 +839,13 @@ export function TreeNavigationPage() {
|
|||||||
onFork={customStepFlow.handleForkTree}
|
onFork={customStepFlow.handleForkTree}
|
||||||
onSkip={customStepFlow.handleSkipFork}
|
onSkip={customStepFlow.handleSkipFork}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SessionOutcomeModal
|
||||||
|
isOpen={showOutcomeModal}
|
||||||
|
onClose={closeOutcomeModal}
|
||||||
|
onSubmit={handleSubmitOutcome}
|
||||||
|
isSubmitting={isCompleting}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { TreeStructure } from './tree'
|
import type { TreeStructure } from './tree'
|
||||||
import type { Step, StepContent } from './step'
|
import type { Step, StepContent } from './step'
|
||||||
|
|
||||||
|
export type SessionOutcome = 'resolved' | 'escalated' | 'workaround' | 'unresolved'
|
||||||
|
|
||||||
export interface DecisionRecord {
|
export interface DecisionRecord {
|
||||||
node_id: string
|
node_id: string
|
||||||
question: string | null
|
question: string | null
|
||||||
@@ -9,6 +11,9 @@ export interface DecisionRecord {
|
|||||||
notes: string | null
|
notes: string | null
|
||||||
automation_used: boolean
|
automation_used: boolean
|
||||||
timestamp: string
|
timestamp: string
|
||||||
|
entered_at?: string | null
|
||||||
|
exited_at?: string | null
|
||||||
|
duration_seconds?: number | null
|
||||||
attachments: string[]
|
attachments: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +50,8 @@ export interface Session {
|
|||||||
custom_steps: CustomStep[]
|
custom_steps: CustomStep[]
|
||||||
started_at: string
|
started_at: string
|
||||||
completed_at: string | null
|
completed_at: string | null
|
||||||
|
outcome: SessionOutcome | null
|
||||||
|
outcome_notes: string | null
|
||||||
ticket_number: string | null
|
ticket_number: string | null
|
||||||
client_name: string | null
|
client_name: string | null
|
||||||
exported: boolean
|
exported: boolean
|
||||||
@@ -72,6 +79,11 @@ export interface SessionExport {
|
|||||||
include_tree_info?: boolean
|
include_tree_info?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SessionComplete {
|
||||||
|
outcome: SessionOutcome
|
||||||
|
outcome_notes?: string
|
||||||
|
}
|
||||||
|
|
||||||
// Navigation state for active session
|
// Navigation state for active session
|
||||||
export interface SessionNavigationState {
|
export interface SessionNavigationState {
|
||||||
activeSession: Session | null
|
activeSession: Session | null
|
||||||
|
|||||||
Reference in New Issue
Block a user