feat: flexible intake — deferred variables + prepared sessions
Remove blocking intake form modal. Variables are now filled inline during
flow execution or pre-filled via prepared sessions. Adds PATCH /sessions/{id}/variables
endpoint, POST /sessions/prepare for session pre-staging, inline variable prompts
in StepDetail, editable Session Variables panel, and "Prepared for You" dashboard section.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
40
backend/alembic/versions/053_add_prepared_session_fields.py
Normal file
40
backend/alembic/versions/053_add_prepared_session_fields.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""add prepared session fields
|
||||||
|
|
||||||
|
Revision ID: 053
|
||||||
|
Revises: 052
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
revision = "053"
|
||||||
|
down_revision = "052"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# Add prepared_by_id and assigned_to_id for prepared sessions
|
||||||
|
op.add_column("sessions", sa.Column("prepared_by_id", UUID(as_uuid=True), nullable=True))
|
||||||
|
op.add_column("sessions", sa.Column("assigned_to_id", UUID(as_uuid=True), nullable=True))
|
||||||
|
|
||||||
|
# Make started_at nullable (prepared sessions have started_at = NULL)
|
||||||
|
op.alter_column("sessions", "started_at", existing_type=sa.DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
# Add foreign key constraints
|
||||||
|
op.create_foreign_key("fk_sessions_prepared_by_id", "sessions", "users", ["prepared_by_id"], ["id"])
|
||||||
|
op.create_foreign_key("fk_sessions_assigned_to_id", "sessions", "users", ["assigned_to_id"], ["id"])
|
||||||
|
|
||||||
|
# Add indexes for efficient querying
|
||||||
|
op.create_index("ix_sessions_prepared_by_id", "sessions", ["prepared_by_id"])
|
||||||
|
op.create_index("ix_sessions_assigned_to_id", "sessions", ["assigned_to_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
op.drop_index("ix_sessions_assigned_to_id", table_name="sessions")
|
||||||
|
op.drop_index("ix_sessions_prepared_by_id", table_name="sessions")
|
||||||
|
op.drop_constraint("fk_sessions_assigned_to_id", "sessions", type_="foreignkey")
|
||||||
|
op.drop_constraint("fk_sessions_prepared_by_id", "sessions", type_="foreignkey")
|
||||||
|
op.alter_column("sessions", "started_at", existing_type=sa.DateTime(timezone=True), nullable=False)
|
||||||
|
op.drop_column("sessions", "assigned_to_id")
|
||||||
|
op.drop_column("sessions", "prepared_by_id")
|
||||||
@@ -21,6 +21,8 @@ from app.schemas.session import (
|
|||||||
SaveAsTreeRequest,
|
SaveAsTreeRequest,
|
||||||
SaveAsTreeResponse,
|
SaveAsTreeResponse,
|
||||||
SessionComplete,
|
SessionComplete,
|
||||||
|
SessionVariablesUpdate,
|
||||||
|
PrepareSessionRequest,
|
||||||
)
|
)
|
||||||
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
|
||||||
@@ -39,6 +41,8 @@ async def list_sessions(
|
|||||||
tree_name: Optional[str] = Query(None, description="Filter by tree name from snapshot"),
|
tree_name: Optional[str] = Query(None, description="Filter by tree name from snapshot"),
|
||||||
tree_id: Optional[UUID] = Query(None, description="Filter by tree ID"),
|
tree_id: Optional[UUID] = Query(None, description="Filter by tree ID"),
|
||||||
batch_id: Optional[UUID] = Query(None, description="Filter by batch ID (maintenance batch runs)"),
|
batch_id: Optional[UUID] = Query(None, description="Filter by batch ID (maintenance batch runs)"),
|
||||||
|
assigned_to_id: Optional[UUID] = Query(None, description="Filter by assigned user ID (prepared sessions)"),
|
||||||
|
status: Optional[str] = Query(None, description="Filter by status: prepared, active, completed"),
|
||||||
started_after: Optional[datetime] = Query(None, description="Filter sessions started after this datetime"),
|
started_after: Optional[datetime] = Query(None, description="Filter sessions started after this datetime"),
|
||||||
started_before: Optional[datetime] = Query(None, description="Filter sessions started before this datetime"),
|
started_before: Optional[datetime] = Query(None, description="Filter sessions started before this datetime"),
|
||||||
completed_after: Optional[datetime] = Query(None, description="Filter sessions completed after this datetime"),
|
completed_after: Optional[datetime] = Query(None, description="Filter sessions completed after this datetime"),
|
||||||
@@ -49,7 +53,11 @@ async def list_sessions(
|
|||||||
limit: int = Query(50, ge=1, le=100)
|
limit: int = Query(50, ge=1, le=100)
|
||||||
):
|
):
|
||||||
"""List user's troubleshooting sessions with comprehensive filtering."""
|
"""List user's troubleshooting sessions with comprehensive filtering."""
|
||||||
query = select(Session).where(Session.user_id == current_user.id)
|
from sqlalchemy import or_
|
||||||
|
# Show sessions owned by user OR assigned to user (prepared sessions)
|
||||||
|
query = select(Session).where(
|
||||||
|
or_(Session.user_id == current_user.id, Session.assigned_to_id == current_user.id)
|
||||||
|
)
|
||||||
|
|
||||||
# Completion status filter
|
# Completion status filter
|
||||||
if completed is not None:
|
if completed is not None:
|
||||||
@@ -78,6 +86,18 @@ async def list_sessions(
|
|||||||
if batch_id:
|
if batch_id:
|
||||||
query = query.where(Session.batch_id == batch_id)
|
query = query.where(Session.batch_id == batch_id)
|
||||||
|
|
||||||
|
# Assigned user filter (for prepared sessions)
|
||||||
|
if assigned_to_id:
|
||||||
|
query = query.where(Session.assigned_to_id == assigned_to_id)
|
||||||
|
|
||||||
|
# Status filter: prepared (started_at IS NULL), active, completed
|
||||||
|
if status == "prepared":
|
||||||
|
query = query.where(Session.started_at.is_(None))
|
||||||
|
elif status == "active":
|
||||||
|
query = query.where(Session.started_at.isnot(None), Session.completed_at.is_(None))
|
||||||
|
elif status == "completed":
|
||||||
|
query = query.where(Session.completed_at.isnot(None))
|
||||||
|
|
||||||
# Date range filters
|
# Date range filters
|
||||||
if started_after:
|
if started_after:
|
||||||
query = query.where(Session.started_at >= started_after)
|
query = query.where(Session.started_at >= started_after)
|
||||||
@@ -93,7 +113,7 @@ async def list_sessions(
|
|||||||
if page is not None:
|
if page is not None:
|
||||||
effective_skip = (page - 1) * effective_limit
|
effective_skip = (page - 1) * effective_limit
|
||||||
|
|
||||||
query = query.order_by(Session.started_at.desc())
|
query = query.order_by(Session.started_at.desc().nullslast())
|
||||||
query = query.offset(effective_skip).limit(effective_limit)
|
query = query.offset(effective_skip).limit(effective_limit)
|
||||||
|
|
||||||
result = await db.execute(query)
|
result = await db.execute(query)
|
||||||
@@ -117,7 +137,7 @@ async def get_session(
|
|||||||
detail="Session not found"
|
detail="Session not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
if session.user_id != current_user.id:
|
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="You don't have access to this session"
|
detail="You don't have access to this session"
|
||||||
@@ -155,18 +175,9 @@ async def start_session(
|
|||||||
detail="You don't have access to this tree"
|
detail="You don't have access to this tree"
|
||||||
)
|
)
|
||||||
|
|
||||||
# For procedural trees with intake forms, validate required fields
|
# Deferred variables: sessions can start with empty/partial variables
|
||||||
|
# Variables are filled inline during execution via PATCH /sessions/{id}/variables
|
||||||
session_variables = session_data.session_variables or {}
|
session_variables = session_data.session_variables or {}
|
||||||
if tree.tree_type == 'procedural' and tree.intake_form:
|
|
||||||
missing_fields = []
|
|
||||||
for field in tree.intake_form:
|
|
||||||
if field.get("required") and not session_variables.get(field["variable_name"]):
|
|
||||||
missing_fields.append(field["label"])
|
|
||||||
if missing_fields:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
||||||
detail=f"Missing required intake form fields: {', '.join(missing_fields)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create session with tree snapshot (includes tree metadata for filtering/export)
|
# Create session with tree snapshot (includes tree metadata for filtering/export)
|
||||||
tree_snapshot = {
|
tree_snapshot = {
|
||||||
@@ -217,7 +228,7 @@ async def update_session(
|
|||||||
detail="Session not found"
|
detail="Session not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
if session.user_id != current_user.id:
|
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="You don't have access to this session"
|
detail="You don't have access to this session"
|
||||||
@@ -229,6 +240,13 @@ async def update_session(
|
|||||||
detail="Cannot update a completed session"
|
detail="Cannot update a completed session"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Start a prepared session on first update (set started_at)
|
||||||
|
if session.started_at is None:
|
||||||
|
session.started_at = datetime.now(timezone.utc)
|
||||||
|
# Transfer ownership to the executing user if they're the assignee
|
||||||
|
if session.assigned_to_id == current_user.id and session.user_id != current_user.id:
|
||||||
|
session.user_id = current_user.id
|
||||||
|
|
||||||
# Use mode='json' to ensure datetime fields are serialized as ISO strings for JSONB storage
|
# Use mode='json' to ensure datetime fields are serialized as ISO strings for JSONB storage
|
||||||
update_data = session_data.model_dump(exclude_unset=True, mode='json')
|
update_data = session_data.model_dump(exclude_unset=True, mode='json')
|
||||||
|
|
||||||
@@ -257,7 +275,7 @@ async def complete_session(
|
|||||||
detail="Session not found"
|
detail="Session not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
if session.user_id != current_user.id:
|
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="You don't have access to this session"
|
detail="You don't have access to this session"
|
||||||
@@ -295,7 +313,7 @@ async def update_scratchpad(
|
|||||||
detail="Session not found"
|
detail="Session not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
if session.user_id != current_user.id:
|
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="You don't have access to this session"
|
detail="You don't have access to this session"
|
||||||
@@ -307,6 +325,45 @@ async def update_scratchpad(
|
|||||||
return session
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{session_id}/variables", response_model=SessionResponse)
|
||||||
|
async def update_session_variables(
|
||||||
|
session_id: UUID,
|
||||||
|
data: SessionVariablesUpdate,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||||
|
):
|
||||||
|
"""Update session variables (partial dict merge). Used for deferred variable input."""
|
||||||
|
result = await db.execute(select(Session).where(Session.id == session_id))
|
||||||
|
session = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not session:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Session not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You don't have access to this session"
|
||||||
|
)
|
||||||
|
|
||||||
|
if session.completed_at:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot update variables on a completed session"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge new variables into existing ones
|
||||||
|
existing = session.session_variables or {}
|
||||||
|
merged = {**existing, **data.variables}
|
||||||
|
session.session_variables = merged
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(session)
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{session_id}/export")
|
@router.post("/{session_id}/export")
|
||||||
async def export_session(
|
async def export_session(
|
||||||
session_id: UUID,
|
session_id: UUID,
|
||||||
@@ -324,7 +381,7 @@ async def export_session(
|
|||||||
detail="Session not found"
|
detail="Session not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
if session.user_id != current_user.id:
|
if session.user_id != current_user.id and session.assigned_to_id != current_user.id:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="You don't have access to this session"
|
detail="You don't have access to this session"
|
||||||
@@ -508,6 +565,89 @@ async def save_session_as_tree(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Prepare Session (Flexible Intake) ─────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/prepare", response_model=SessionResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def prepare_session(
|
||||||
|
data: PrepareSessionRequest,
|
||||||
|
db: Annotated[AsyncSession, Depends(get_db)],
|
||||||
|
current_user: Annotated[User, Depends(get_current_active_user)]
|
||||||
|
):
|
||||||
|
"""Create a prepared session with pre-filled variables and optional assignee.
|
||||||
|
|
||||||
|
Prepared sessions have started_at = NULL. They appear in the assigned
|
||||||
|
engineer's queue and can be started later with variables already filled.
|
||||||
|
"""
|
||||||
|
# Get the tree
|
||||||
|
result = await db.execute(select(Tree).where(Tree.id == data.tree_id))
|
||||||
|
tree = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if not tree:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Tree not found"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not tree.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Cannot prepare session with inactive tree"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not can_access_tree(current_user, tree):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="You don't have access to this tree"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate assignee exists and is on the same team (if specified)
|
||||||
|
if data.assigned_to_id:
|
||||||
|
assignee_result = await db.execute(select(User).where(User.id == data.assigned_to_id))
|
||||||
|
assignee = assignee_result.scalar_one_or_none()
|
||||||
|
if not assignee:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Assigned user not found"
|
||||||
|
)
|
||||||
|
if current_user.team_id and assignee.team_id != current_user.team_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Can only assign to users on your team"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create tree snapshot
|
||||||
|
tree_snapshot = {
|
||||||
|
**tree.tree_structure,
|
||||||
|
"name": tree.name,
|
||||||
|
"description": tree.description,
|
||||||
|
"category": tree.category,
|
||||||
|
"version": tree.version,
|
||||||
|
"tree_type": tree.tree_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
session_variables = data.session_variables or {}
|
||||||
|
|
||||||
|
new_session = Session(
|
||||||
|
tree_id=tree.id,
|
||||||
|
user_id=data.assigned_to_id or current_user.id,
|
||||||
|
tree_snapshot=tree_snapshot,
|
||||||
|
path_taken=[],
|
||||||
|
decisions=[],
|
||||||
|
session_variables=session_variables,
|
||||||
|
ticket_number=data.ticket_number,
|
||||||
|
client_name=data.client_name,
|
||||||
|
started_at=None, # NULL = prepared state
|
||||||
|
prepared_by_id=current_user.id,
|
||||||
|
assigned_to_id=data.assigned_to_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(new_session)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(new_session)
|
||||||
|
return new_session
|
||||||
|
|
||||||
|
|
||||||
# ── Batch Launch (Maintenance Flows) ──────────────────────────────────────
|
# ── Batch Launch (Maintenance Flows) ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,9 @@ class Session(Base):
|
|||||||
path_taken: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
|
path_taken: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list)
|
decisions: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
custom_steps: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list)
|
custom_steps: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
started_at: Mapped[datetime] = mapped_column(
|
started_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
DateTime(timezone=True),
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
default=lambda: datetime.now(timezone.utc),
|
default=lambda: datetime.now(timezone.utc),
|
||||||
index=True
|
index=True
|
||||||
)
|
)
|
||||||
@@ -60,9 +61,25 @@ class Session(Base):
|
|||||||
Text, nullable=True, server_default=sa.text("''")
|
Text, nullable=True, server_default=sa.text("''")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Prepared sessions (flexible intake)
|
||||||
|
prepared_by_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=True,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
assigned_to_id: Mapped[Optional[uuid.UUID]] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=True,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
tree: Mapped["Tree"] = relationship("Tree", back_populates="sessions")
|
tree: Mapped["Tree"] = relationship("Tree", back_populates="sessions")
|
||||||
user: Mapped["User"] = relationship("User", back_populates="sessions")
|
user: Mapped["User"] = relationship("User", foreign_keys=[user_id], back_populates="sessions")
|
||||||
|
prepared_by: Mapped[Optional["User"]] = relationship("User", foreign_keys=[prepared_by_id])
|
||||||
|
assigned_to: Mapped[Optional["User"]] = relationship("User", foreign_keys=[assigned_to_id])
|
||||||
attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session")
|
attachments: Mapped[list["Attachment"]] = relationship("Attachment", back_populates="session")
|
||||||
shares: Mapped[list["SessionShare"]] = relationship("SessionShare", back_populates="session", cascade="all, delete-orphan")
|
shares: Mapped[list["SessionShare"]] = relationship("SessionShare", back_populates="session", cascade="all, delete-orphan")
|
||||||
|
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class User(Base):
|
|||||||
owned_account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys="[Account.owner_id]", back_populates="owner", uselist=False)
|
owned_account: Mapped[Optional["Account"]] = relationship("Account", foreign_keys="[Account.owner_id]", back_populates="owner", uselist=False)
|
||||||
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="users")
|
team: Mapped[Optional["Team"]] = relationship("Team", back_populates="users")
|
||||||
trees: Mapped[list["Tree"]] = relationship("Tree", foreign_keys="[Tree.author_id]", back_populates="author")
|
trees: Mapped[list["Tree"]] = relationship("Tree", foreign_keys="[Tree.author_id]", back_populates="author")
|
||||||
sessions: Mapped[list["Session"]] = relationship("Session", back_populates="user")
|
sessions: Mapped[list["Session"]] = relationship("Session", foreign_keys="[Session.user_id]", back_populates="user")
|
||||||
folders: Mapped[list["UserFolder"]] = relationship("UserFolder", back_populates="user")
|
folders: Mapped[list["UserFolder"]] = relationship("UserFolder", back_populates="user")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -55,6 +55,15 @@ class SessionUpdate(BaseModel):
|
|||||||
session_variables: Optional[dict[str, str]] = None
|
session_variables: Optional[dict[str, str]] = None
|
||||||
|
|
||||||
|
|
||||||
|
class PrepareSessionRequest(BaseModel):
|
||||||
|
"""Create a prepared session with pre-filled variables and optional assignee."""
|
||||||
|
tree_id: UUID
|
||||||
|
session_variables: Optional[dict[str, str]] = Field(None, description="Pre-filled intake form values")
|
||||||
|
assigned_to_id: Optional[UUID] = Field(None, description="User ID of the engineer to assign this session to")
|
||||||
|
ticket_number: Optional[str] = Field(None, max_length=100)
|
||||||
|
client_name: Optional[str] = Field(None, max_length=255)
|
||||||
|
|
||||||
|
|
||||||
class SessionResponse(BaseModel):
|
class SessionResponse(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
tree_id: UUID
|
tree_id: UUID
|
||||||
@@ -63,7 +72,7 @@ class SessionResponse(BaseModel):
|
|||||||
path_taken: list[str]
|
path_taken: list[str]
|
||||||
decisions: list[dict[str, Any]]
|
decisions: list[dict[str, Any]]
|
||||||
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: Optional[datetime] = None
|
||||||
completed_at: Optional[datetime] = None
|
completed_at: Optional[datetime] = None
|
||||||
outcome: Optional[SessionOutcome] = None
|
outcome: Optional[SessionOutcome] = None
|
||||||
outcome_notes: Optional[str] = None
|
outcome_notes: Optional[str] = None
|
||||||
@@ -74,6 +83,10 @@ class SessionResponse(BaseModel):
|
|||||||
scratchpad: str = ""
|
scratchpad: str = ""
|
||||||
session_variables: dict[str, str] = Field(default_factory=dict)
|
session_variables: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
# Prepared session fields
|
||||||
|
prepared_by_id: Optional[UUID] = None
|
||||||
|
assigned_to_id: Optional[UUID] = None
|
||||||
|
|
||||||
@validator('scratchpad', 'next_steps', pre=True, always=True)
|
@validator('scratchpad', 'next_steps', pre=True, always=True)
|
||||||
def normalize_text_fields(cls, v):
|
def normalize_text_fields(cls, v):
|
||||||
return v or ""
|
return v or ""
|
||||||
@@ -106,6 +119,11 @@ class SessionComplete(BaseModel):
|
|||||||
next_steps: Optional[str] = None
|
next_steps: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionVariablesUpdate(BaseModel):
|
||||||
|
"""Partial update to session variables (dict merge)."""
|
||||||
|
variables: dict[str, str] = Field(..., description="Key-value pairs to merge into session_variables")
|
||||||
|
|
||||||
|
|
||||||
class ScratchpadUpdate(BaseModel):
|
class ScratchpadUpdate(BaseModel):
|
||||||
scratchpad: str
|
scratchpad: str
|
||||||
|
|
||||||
|
|||||||
@@ -607,8 +607,8 @@ class TestProceduralFlowsAPI:
|
|||||||
assert session_data["session_variables"]["server_name"] == "DC01"
|
assert session_data["session_variables"]["server_name"] == "DC01"
|
||||||
assert session_data["tree_snapshot"]["tree_type"] == "procedural"
|
assert session_data["tree_snapshot"]["tree_type"] == "procedural"
|
||||||
|
|
||||||
async def test_start_session_procedural_missing_required_field(self, client, auth_headers):
|
async def test_start_session_procedural_deferred_variables(self, client, auth_headers):
|
||||||
"""Starting a procedural session without required intake fields should fail."""
|
"""Starting a procedural session without required intake fields should succeed (deferred variables)."""
|
||||||
# Create published procedural tree with required intake form
|
# Create published procedural tree with required intake form
|
||||||
create_resp = await client.post(
|
create_resp = await client.post(
|
||||||
"/api/v1/trees",
|
"/api/v1/trees",
|
||||||
@@ -623,17 +623,27 @@ class TestProceduralFlowsAPI:
|
|||||||
)
|
)
|
||||||
tree_id = create_resp.json()["id"]
|
tree_id = create_resp.json()["id"]
|
||||||
|
|
||||||
# Try to start without required fields
|
# Start without required fields — should succeed (deferred variables)
|
||||||
session_resp = await client.post(
|
session_resp = await client.post(
|
||||||
"/api/v1/sessions",
|
"/api/v1/sessions",
|
||||||
json={
|
json={
|
||||||
"tree_id": tree_id,
|
"tree_id": tree_id,
|
||||||
# Missing server_name and ip_address (both required)
|
# Missing server_name and ip_address — will be filled inline later
|
||||||
},
|
},
|
||||||
headers=auth_headers,
|
headers=auth_headers,
|
||||||
)
|
)
|
||||||
assert session_resp.status_code == 422
|
assert session_resp.status_code == 201
|
||||||
assert "Missing required" in session_resp.json()["detail"]
|
session_id = session_resp.json()["id"]
|
||||||
|
|
||||||
|
# Fill variables via PATCH endpoint
|
||||||
|
patch_resp = await client.patch(
|
||||||
|
f"/api/v1/sessions/{session_id}/variables",
|
||||||
|
json={"variables": {"server_name": "DC-01", "ip_address": "10.0.0.1"}},
|
||||||
|
headers=auth_headers,
|
||||||
|
)
|
||||||
|
assert patch_resp.status_code == 200
|
||||||
|
assert patch_resp.json()["session_variables"]["server_name"] == "DC-01"
|
||||||
|
assert patch_resp.json()["session_variables"]["ip_address"] == "10.0.0.1"
|
||||||
|
|
||||||
async def test_start_session_procedural_optional_fields_ok(self, client, auth_headers):
|
async def test_start_session_procedural_optional_fields_ok(self, client, auth_headers):
|
||||||
"""Starting a session with only required fields (optional missing) should work."""
|
"""Starting a session with only required fields (optional missing) should work."""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client'
|
||||||
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete, RedactionSummary, SessionShareCreate, SessionShare, SharedSessionView } from '@/types'
|
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete, RedactionSummary, SessionShareCreate, SessionShare, SharedSessionView, PrepareSessionRequest } from '@/types'
|
||||||
|
|
||||||
export interface SessionListParams {
|
export interface SessionListParams {
|
||||||
page?: number
|
page?: number
|
||||||
@@ -7,6 +7,8 @@ export interface SessionListParams {
|
|||||||
tree_id?: string
|
tree_id?: string
|
||||||
batch_id?: string
|
batch_id?: string
|
||||||
completed?: boolean
|
completed?: boolean
|
||||||
|
assigned_to_id?: string
|
||||||
|
status?: 'prepared' | 'active' | 'completed'
|
||||||
ticket_number?: string
|
ticket_number?: string
|
||||||
client_name?: string
|
client_name?: string
|
||||||
tree_name?: string
|
tree_name?: string
|
||||||
@@ -69,6 +71,16 @@ export const sessionsApi = {
|
|||||||
return { content: response.data, redactionMode, redactionSummary }
|
return { content: response.data, redactionMode, redactionSummary }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async prepare(data: PrepareSessionRequest): Promise<Session> {
|
||||||
|
const response = await apiClient.post<Session>('/sessions/prepare', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateVariables(id: string, variables: Record<string, string>): Promise<Session> {
|
||||||
|
const response = await apiClient.patch<Session>(`/sessions/${id}/variables`, { variables })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
async updateScratchpad(id: string, content: string): Promise<Session> {
|
async updateScratchpad(id: string, content: string): Promise<Session> {
|
||||||
const response = await apiClient.patch<Session>(`/sessions/${id}/scratchpad`, { scratchpad: content })
|
const response = await apiClient.patch<Session>(`/sessions/${id}/scratchpad`, { scratchpad: content })
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
83
frontend/src/components/dashboard/PreparedSessions.tsx
Normal file
83
frontend/src/components/dashboard/PreparedSessions.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { ClipboardList, ArrowRight, Clock } from 'lucide-react'
|
||||||
|
import { sessionsApi } from '@/api/sessions'
|
||||||
|
import type { Session } from '@/types/session'
|
||||||
|
import { getTreeNavigatePath } from '@/lib/routing'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export function PreparedSessions() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [sessions, setSessions] = useState<Session[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
sessionsApi.list({ status: 'prepared', size: 10 })
|
||||||
|
.then(setSessions)
|
||||||
|
.catch(() => setSessions([]))
|
||||||
|
.finally(() => setIsLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (isLoading || sessions.length === 0) return null
|
||||||
|
|
||||||
|
const handleStart = (session: Session) => {
|
||||||
|
const treeType = (session.tree_snapshot as unknown as Record<string, unknown>)?.tree_type as string | undefined
|
||||||
|
navigate(getTreeNavigatePath(session.tree_id, treeType), {
|
||||||
|
state: { sessionId: session.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card-static p-5 fade-in" style={{ animationDelay: '200ms' }}>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<ClipboardList className="h-4 w-4 text-cyan-400" />
|
||||||
|
<h3 className="font-heading text-sm font-semibold text-foreground">Prepared for You</h3>
|
||||||
|
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-cyan-400/20 text-[0.625rem] font-bold text-cyan-400">
|
||||||
|
{sessions.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{sessions.map(session => {
|
||||||
|
const snapshot = session.tree_snapshot as unknown as Record<string, unknown>
|
||||||
|
const flowName = (snapshot?.name as string) || 'Unknown Flow'
|
||||||
|
const filledVars = Object.values(session.session_variables || {}).filter(v => v?.trim()).length
|
||||||
|
const treeType = snapshot?.tree_type as string | undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={session.id}
|
||||||
|
onClick={() => handleStart(session)}
|
||||||
|
className={cn(
|
||||||
|
'group flex w-full items-center justify-between gap-3 rounded-lg border border-border px-4 py-3',
|
||||||
|
'text-left transition-all hover:border-cyan-500/30 hover:bg-cyan-500/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-foreground truncate">{flowName}</p>
|
||||||
|
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
{session.ticket_number && (
|
||||||
|
<span>{session.ticket_number}</span>
|
||||||
|
)}
|
||||||
|
{session.client_name && (
|
||||||
|
<span>{session.client_name}</span>
|
||||||
|
)}
|
||||||
|
{filledVars > 0 && (
|
||||||
|
<span>{filledVars} variable{filledVars > 1 ? 's' : ''} pre-filled</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{treeType === 'procedural' ? 'Project' : treeType === 'maintenance' ? 'Maintenance' : 'Flow'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ export function NotificationsPanel() {
|
|||||||
setSessions(data)
|
setSessions(data)
|
||||||
// Mark as "new" if any session was updated in the last hour
|
// Mark as "new" if any session was updated in the last hour
|
||||||
const oneHourAgo = Date.now() - 3600000
|
const oneHourAgo = Date.now() - 3600000
|
||||||
setHasNew(data.some(s => new Date(s.started_at).getTime() > oneHourAgo))
|
setHasNew(data.some(s => s.started_at && new Date(s.started_at).getTime() > oneHourAgo))
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
@@ -90,7 +90,7 @@ export function NotificationsPanel() {
|
|||||||
<p className="text-[0.6875rem] text-muted-foreground">
|
<p className="text-[0.6875rem] text-muted-foreground">
|
||||||
{session.completed_at
|
{session.completed_at
|
||||||
? `Completed ${timeAgo(session.completed_at)}`
|
? `Completed ${timeAgo(session.completed_at)}`
|
||||||
: `Started ${timeAgo(session.started_at)}`}
|
: session.started_at ? `Started ${timeAgo(session.started_at)}` : 'Not started'}
|
||||||
{session.client_name && ` · ${session.client_name}`}
|
{session.client_name && ` · ${session.client_name}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
130
frontend/src/components/procedural/InlineVariablePrompt.tsx
Normal file
130
frontend/src/components/procedural/InlineVariablePrompt.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { Variable } from 'lucide-react'
|
||||||
|
import type { IntakeFormField } from '@/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface InlineVariablePromptProps {
|
||||||
|
variableName: string
|
||||||
|
fieldMeta?: IntakeFormField
|
||||||
|
onSubmit: (variableName: string, value: string) => void
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InlineVariablePrompt({
|
||||||
|
variableName,
|
||||||
|
fieldMeta,
|
||||||
|
onSubmit,
|
||||||
|
disabled,
|
||||||
|
}: InlineVariablePromptProps) {
|
||||||
|
const [value, setValue] = useState('')
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const inputRef = useRef<HTMLInputElement | HTMLSelectElement>(null)
|
||||||
|
|
||||||
|
const label = fieldMeta?.label || variableName
|
||||||
|
const placeholder = fieldMeta?.placeholder || `Enter ${label}...`
|
||||||
|
const helpText = fieldMeta?.help_text
|
||||||
|
const fieldType = fieldMeta?.field_type || 'text'
|
||||||
|
const options = fieldMeta?.options || []
|
||||||
|
const isRequired = fieldMeta?.required ?? false
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isEditing && inputRef.current) {
|
||||||
|
inputRef.current.focus()
|
||||||
|
}
|
||||||
|
}, [isEditing])
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
const trimmed = value.trim()
|
||||||
|
if (trimmed) {
|
||||||
|
onSubmit(variableName, trimmed)
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSubmit()
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setIsEditing(false)
|
||||||
|
setValue('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEditing) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1 text-sm font-medium transition-all',
|
||||||
|
'border-cyan-500/40 bg-cyan-500/5 text-cyan-400',
|
||||||
|
'hover:border-cyan-400/60 hover:bg-cyan-500/10 hover:shadow-[0_0_12px_rgba(6,182,212,0.15)]',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
isRequired && 'ring-1 ring-cyan-500/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Variable className="h-3.5 w-3.5" />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select field type
|
||||||
|
if (fieldType === 'select' && options.length > 0) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<select
|
||||||
|
ref={inputRef as React.RefObject<HTMLSelectElement>}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
setValue(e.target.value)
|
||||||
|
if (e.target.value) {
|
||||||
|
onSubmit(variableName, e.target.value)
|
||||||
|
setIsEditing(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!value) setIsEditing(false)
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-cyan-500/40 bg-cyan-500/5 px-2.5 py-1 text-sm text-foreground shadow-[0_0_12px_rgba(6,182,212,0.15)] focus:border-cyan-400 focus:outline-hidden focus:ring-1 focus:ring-cyan-400/30"
|
||||||
|
>
|
||||||
|
<option value="">{placeholder}</option>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Text/other input types
|
||||||
|
const inputType = fieldType === 'email' ? 'email'
|
||||||
|
: fieldType === 'number' ? 'number'
|
||||||
|
: fieldType === 'url' ? 'url'
|
||||||
|
: fieldType === 'ip_address' ? 'text'
|
||||||
|
: 'text'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="relative inline-flex items-center">
|
||||||
|
<input
|
||||||
|
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||||
|
type={inputType}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
onBlur={handleSubmit}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-48 rounded-md border border-cyan-500/40 bg-cyan-500/5 px-2.5 py-1 text-sm text-foreground shadow-[0_0_12px_rgba(6,182,212,0.15)] placeholder:text-muted-foreground focus:border-cyan-400 focus:outline-hidden focus:ring-1 focus:ring-cyan-400/30"
|
||||||
|
/>
|
||||||
|
{helpText && (
|
||||||
|
<span className="absolute -bottom-5 left-0 text-[0.625rem] text-muted-foreground whitespace-nowrap">
|
||||||
|
{helpText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
239
frontend/src/components/procedural/PrepareSessionModal.tsx
Normal file
239
frontend/src/components/procedural/PrepareSessionModal.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { X, UserPlus, FileText } from 'lucide-react'
|
||||||
|
import type { IntakeFormField } from '@/types'
|
||||||
|
import { sessionsApi } from '@/api/sessions'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
|
||||||
|
interface TeamMember {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PrepareSessionModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
treeId: string
|
||||||
|
treeName: string
|
||||||
|
intakeFields: IntakeFormField[]
|
||||||
|
teamMembers?: TeamMember[]
|
||||||
|
onPrepared?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrepareSessionModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
treeId,
|
||||||
|
treeName,
|
||||||
|
intakeFields,
|
||||||
|
teamMembers = [],
|
||||||
|
onPrepared,
|
||||||
|
}: PrepareSessionModalProps) {
|
||||||
|
const [values, setValues] = useState<Record<string, string>>({})
|
||||||
|
const [assignedToId, setAssignedToId] = useState('')
|
||||||
|
const [ticketNumber, setTicketNumber] = useState('')
|
||||||
|
const [clientName, setClientName] = useState('')
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
|
// Reset on open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setValues({})
|
||||||
|
setAssignedToId('')
|
||||||
|
setTicketNumber('')
|
||||||
|
setClientName('')
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const handleFieldChange = (variableName: string, value: string) => {
|
||||||
|
setValues(prev => ({ ...prev, [variableName]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setIsSubmitting(true)
|
||||||
|
try {
|
||||||
|
// Clean empty values
|
||||||
|
const cleanedVars: Record<string, string> = {}
|
||||||
|
for (const [k, v] of Object.entries(values)) {
|
||||||
|
if (v.trim()) cleanedVars[k] = v.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
await sessionsApi.prepare({
|
||||||
|
tree_id: treeId,
|
||||||
|
session_variables: Object.keys(cleanedVars).length > 0 ? cleanedVars : undefined,
|
||||||
|
assigned_to_id: assignedToId || undefined,
|
||||||
|
ticket_number: ticketNumber.trim() || undefined,
|
||||||
|
client_name: clientName.trim() || undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
toast.success('Session prepared successfully')
|
||||||
|
onPrepared?.()
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to prepare session')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group fields by group_name
|
||||||
|
const grouped = new Map<string, IntakeFormField[]>()
|
||||||
|
for (const field of [...intakeFields].sort((a, b) => a.display_order - b.display_order)) {
|
||||||
|
const group = field.group_name || 'General'
|
||||||
|
if (!grouped.has(group)) grouped.set(group, [])
|
||||||
|
grouped.get(group)!.push(field)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<div className="absolute inset-0 bg-background/60 backdrop-blur-xs" onClick={onClose} />
|
||||||
|
<div className="relative w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="h-4 w-4 text-cyan-400" />
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">Prepare Session</h3>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-lg p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto p-5 space-y-5">
|
||||||
|
{/* Flow name */}
|
||||||
|
<div className="rounded-lg bg-accent px-3 py-2">
|
||||||
|
<p className="text-xs text-muted-foreground">Flow</p>
|
||||||
|
<p className="text-sm font-medium text-foreground">{treeName}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Context fields */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted-foreground">Ticket Number</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={ticketNumber}
|
||||||
|
onChange={(e) => setTicketNumber(e.target.value)}
|
||||||
|
placeholder="e.g. TKT-12345"
|
||||||
|
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted-foreground">Client Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={clientName}
|
||||||
|
onChange={(e) => setClientName(e.target.value)}
|
||||||
|
placeholder="e.g. Acme Corp"
|
||||||
|
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assignee */}
|
||||||
|
{teamMembers.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
<UserPlus className="h-3.5 w-3.5" />
|
||||||
|
Assign to Engineer
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={assignedToId}
|
||||||
|
onChange={(e) => setAssignedToId(e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||||
|
>
|
||||||
|
<option value="">Unassigned (visible to all)</option>
|
||||||
|
{teamMembers.map(m => (
|
||||||
|
<option key={m.id} value={m.id}>{m.name} ({m.email})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Intake form fields */}
|
||||||
|
{intakeFields.length > 0 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h4 className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||||
|
Variables (optional — can be filled later)
|
||||||
|
</h4>
|
||||||
|
{Array.from(grouped.entries()).map(([groupName, fields]) => (
|
||||||
|
<div key={groupName} className="space-y-3">
|
||||||
|
{grouped.size > 1 && (
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">{groupName}</p>
|
||||||
|
)}
|
||||||
|
{fields.map(field => (
|
||||||
|
<div key={field.variable_name}>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-muted-foreground">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="ml-1 text-amber-400">*</span>}
|
||||||
|
</label>
|
||||||
|
{field.field_type === 'select' && field.options?.length ? (
|
||||||
|
<select
|
||||||
|
value={values[field.variable_name] || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.variable_name, e.target.value)}
|
||||||
|
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||||
|
>
|
||||||
|
<option value="">{field.placeholder || 'Select...'}</option>
|
||||||
|
{field.options.map(opt => (
|
||||||
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : field.field_type === 'textarea' ? (
|
||||||
|
<textarea
|
||||||
|
value={values[field.variable_name] || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.variable_name, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
rows={3}
|
||||||
|
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.field_type === 'number' ? 'number' : field.field_type === 'email' ? 'email' : 'text'}
|
||||||
|
value={values[field.variable_name] || ''}
|
||||||
|
onChange={(e) => handleFieldChange(field.variable_name, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="w-full rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-hidden focus:ring-1 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{field.help_text && (
|
||||||
|
<p className="mt-1 text-[0.625rem] text-muted-foreground">{field.help_text}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-end gap-3 border-t border-border px-5 py-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-[10px] px-4 py-2 text-sm text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={cn(
|
||||||
|
'rounded-[10px] bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114]',
|
||||||
|
'shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97]',
|
||||||
|
'disabled:opacity-40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Preparing...' : 'Prepare Session'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
87
frontend/src/components/procedural/ResolvedText.tsx
Normal file
87
frontend/src/components/procedural/ResolvedText.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import type { IntakeFormField } from '@/types'
|
||||||
|
import { InlineVariablePrompt } from './InlineVariablePrompt'
|
||||||
|
|
||||||
|
interface ResolvedTextProps {
|
||||||
|
text: string
|
||||||
|
variables: Record<string, string>
|
||||||
|
intakeFields?: IntakeFormField[]
|
||||||
|
onVariableSubmit?: (variableName: string, value: string) => void
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders text with resolved variables inline. Unresolved [VAR:x] tokens
|
||||||
|
* are replaced with interactive InlineVariablePrompt components.
|
||||||
|
*/
|
||||||
|
export function ResolvedText({
|
||||||
|
text,
|
||||||
|
variables,
|
||||||
|
intakeFields,
|
||||||
|
onVariableSubmit,
|
||||||
|
className,
|
||||||
|
}: ResolvedTextProps) {
|
||||||
|
if (!text) return null
|
||||||
|
|
||||||
|
// Split on variable tokens, keeping the tokens in the result
|
||||||
|
const tokenPattern = /(\[(?:VAR|USER_INPUT):([^\]]+)\])/g
|
||||||
|
const parts: Array<{ type: 'text'; value: string } | { type: 'var'; variableName: string; token: string }> = []
|
||||||
|
let lastIndex = 0
|
||||||
|
let match
|
||||||
|
|
||||||
|
// Remove [SAVE_AS:...] tokens first
|
||||||
|
const cleaned = text.replace(/\[SAVE_AS:[^\]]+\]/g, '')
|
||||||
|
|
||||||
|
while ((match = tokenPattern.exec(cleaned)) !== null) {
|
||||||
|
// Add text before the token
|
||||||
|
if (match.index > lastIndex) {
|
||||||
|
parts.push({ type: 'text', value: cleaned.slice(lastIndex, match.index) })
|
||||||
|
}
|
||||||
|
|
||||||
|
const variableName = match[2].trim()
|
||||||
|
const resolvedValue = variables[variableName]
|
||||||
|
|
||||||
|
if (resolvedValue && resolvedValue.trim()) {
|
||||||
|
// Variable is resolved — render as plain text
|
||||||
|
parts.push({ type: 'text', value: resolvedValue })
|
||||||
|
} else {
|
||||||
|
// Variable is unresolved — render as inline prompt
|
||||||
|
parts.push({ type: 'var', variableName, token: match[1] })
|
||||||
|
}
|
||||||
|
|
||||||
|
lastIndex = match.index + match[0].length
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add remaining text
|
||||||
|
if (lastIndex < cleaned.length) {
|
||||||
|
parts.push({ type: 'text', value: cleaned.slice(lastIndex) })
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no unresolved variables, just render plain text
|
||||||
|
const hasUnresolved = parts.some(p => p.type === 'var')
|
||||||
|
if (!hasUnresolved) {
|
||||||
|
const fullText = parts.map(p => p.type === 'text' ? p.value : '').join('')
|
||||||
|
return <span className={className}>{fullText}</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find field metadata helper
|
||||||
|
const getFieldMeta = (varName: string) =>
|
||||||
|
intakeFields?.find(f => f.variable_name === varName)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={className}>
|
||||||
|
{parts.map((part, i) => {
|
||||||
|
if (part.type === 'text') {
|
||||||
|
return <span key={i}>{part.value}</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<InlineVariablePrompt
|
||||||
|
key={`${part.variableName}-${i}`}
|
||||||
|
variableName={part.variableName}
|
||||||
|
fieldMeta={getFieldMeta(part.variableName)}
|
||||||
|
onSubmit={onVariableSubmit || (() => {})}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { AlertTriangle, CheckCircle2, Info, Zap, Copy, Check, ExternalLink } from 'lucide-react'
|
import { AlertTriangle, CheckCircle2, Info, Zap, Copy, Check, ExternalLink } from 'lucide-react'
|
||||||
import type { RuntimeStep, StepContentType, CommandBlock } from '@/types'
|
import type { RuntimeStep, StepContentType, CommandBlock, IntakeFormField } from '@/types'
|
||||||
import { resolveVariables } from '@/lib/variableResolver'
|
import { resolveVariables } from '@/lib/variableResolver'
|
||||||
|
import { ResolvedText } from './ResolvedText'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const contentTypeConfig: Record<StepContentType, { icon: typeof Zap; color: string; bg: string; label: string }> = {
|
const contentTypeConfig: Record<StepContentType, { icon: typeof Zap; color: string; bg: string; label: string }> = {
|
||||||
@@ -23,6 +24,8 @@ interface StepDetailProps {
|
|||||||
isCompleted: boolean
|
isCompleted: boolean
|
||||||
onMarkComplete: () => void
|
onMarkComplete: () => void
|
||||||
isLast: boolean
|
isLast: boolean
|
||||||
|
intakeFields?: IntakeFormField[]
|
||||||
|
onVariableSubmit?: (variableName: string, value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StepDetail({
|
export function StepDetail({
|
||||||
@@ -37,6 +40,8 @@ export function StepDetail({
|
|||||||
isCompleted,
|
isCompleted,
|
||||||
onMarkComplete,
|
onMarkComplete,
|
||||||
isLast,
|
isLast,
|
||||||
|
intakeFields,
|
||||||
|
onVariableSubmit,
|
||||||
}: StepDetailProps) {
|
}: StepDetailProps) {
|
||||||
const [copiedIndex, setCopiedIndex] = useState<number | null>(null)
|
const [copiedIndex, setCopiedIndex] = useState<number | null>(null)
|
||||||
const isCustom = 'isCustom' in step && step.isCustom
|
const isCustom = 'isCustom' in step && step.isCustom
|
||||||
@@ -116,14 +121,28 @@ export function StepDetail({
|
|||||||
{'warning_text' in step && step.warning_text && (
|
{'warning_text' in step && step.warning_text && (
|
||||||
<div className="flex items-start gap-2 rounded-lg border border-yellow-400/20 bg-yellow-400/5 px-3 py-2.5">
|
<div className="flex items-start gap-2 rounded-lg border border-yellow-400/20 bg-yellow-400/5 px-3 py-2.5">
|
||||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-yellow-400" />
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-yellow-400" />
|
||||||
<p className="text-sm text-yellow-200">{resolve(step.warning_text)}</p>
|
<p className="text-sm text-yellow-200">
|
||||||
|
<ResolvedText
|
||||||
|
text={step.warning_text}
|
||||||
|
variables={variables}
|
||||||
|
intakeFields={intakeFields}
|
||||||
|
onVariableSubmit={onVariableSubmit}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
{step.description && (
|
{step.description && (
|
||||||
<div className="prose prose-invert prose-sm max-w-none text-muted-foreground">
|
<div className="prose prose-invert prose-sm max-w-none text-muted-foreground">
|
||||||
<p className="whitespace-pre-wrap">{resolve(step.description)}</p>
|
<p className="whitespace-pre-wrap">
|
||||||
|
<ResolvedText
|
||||||
|
text={step.description}
|
||||||
|
variables={variables}
|
||||||
|
intakeFields={intakeFields}
|
||||||
|
onVariableSubmit={onVariableSubmit}
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -145,7 +164,12 @@ export function StepDetail({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<pre className="overflow-x-auto p-3 font-mono text-sm text-emerald-300">
|
<pre className="overflow-x-auto p-3 font-mono text-sm text-emerald-300">
|
||||||
{resolve(cmd.code)}
|
<ResolvedText
|
||||||
|
text={cmd.code}
|
||||||
|
variables={variables}
|
||||||
|
intakeFields={intakeFields}
|
||||||
|
onVariableSubmit={onVariableSubmit}
|
||||||
|
/>
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -5,6 +5,21 @@
|
|||||||
* - [USER_INPUT:prompt] → replaced with variables[prompt]
|
* - [USER_INPUT:prompt] → replaced with variables[prompt]
|
||||||
* - [SAVE_AS:name] → removed from display
|
* - [SAVE_AS:name] → removed from display
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export interface UnresolvedVariable {
|
||||||
|
variableName: string
|
||||||
|
token: string // e.g. "[VAR:server_name]"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolveResult {
|
||||||
|
text: string
|
||||||
|
unresolvedVariables: UnresolvedVariable[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve variables in text, replacing tokens with values.
|
||||||
|
* Missing/empty values are replaced with "N/A".
|
||||||
|
*/
|
||||||
export function resolveVariables(text: string, variables: Record<string, string>): string {
|
export function resolveVariables(text: string, variables: Record<string, string>): string {
|
||||||
// Replace [VAR:name] — empty/missing values show "N/A"
|
// Replace [VAR:name] — empty/missing values show "N/A"
|
||||||
let result = text.replace(/\[VAR:([^\]]+)\]/g, (_, name) => {
|
let result = text.replace(/\[VAR:([^\]]+)\]/g, (_, name) => {
|
||||||
@@ -26,6 +41,41 @@ export function resolveVariables(text: string, variables: Record<string, string>
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve variables but also return info about which variables are unresolved.
|
||||||
|
* Unresolved variables are left as their original tokens in the text (not replaced with N/A).
|
||||||
|
*/
|
||||||
|
export function resolveVariablesWithStatus(text: string, variables: Record<string, string>): ResolveResult {
|
||||||
|
const unresolvedVariables: UnresolvedVariable[] = []
|
||||||
|
|
||||||
|
// Replace [VAR:name] — resolved values get substituted, unresolved stay as tokens
|
||||||
|
let result = text.replace(/\[VAR:([^\]]+)\]/g, (match, name) => {
|
||||||
|
const key = name.trim()
|
||||||
|
const value = variables[key]
|
||||||
|
if (value && value.trim()) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
unresolvedVariables.push({ variableName: key, token: match })
|
||||||
|
return match // Keep original token for inline prompt rendering
|
||||||
|
})
|
||||||
|
|
||||||
|
// Replace [USER_INPUT:prompt]
|
||||||
|
result = result.replace(/\[USER_INPUT:([^\]]+)\]/g, (match, prompt) => {
|
||||||
|
const key = prompt.trim()
|
||||||
|
const value = variables[key]
|
||||||
|
if (value && value.trim()) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
unresolvedVariables.push({ variableName: key, token: match })
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
|
||||||
|
// Remove [SAVE_AS:name]
|
||||||
|
result = result.replace(/\[SAVE_AS:[^\]]+\]/g, '')
|
||||||
|
|
||||||
|
return { text: result, unresolvedVariables }
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract all [USER_INPUT:prompt] tokens from text.
|
* Extract all [USER_INPUT:prompt] tokens from text.
|
||||||
* Returns array of prompt strings.
|
* Returns array of prompt strings.
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export function MyTreesPage() {
|
|||||||
const lastUsedMap = new Map<string, string>()
|
const lastUsedMap = new Map<string, string>()
|
||||||
for (const session of recentSessions) {
|
for (const session of recentSessions) {
|
||||||
const existing = lastUsedMap.get(session.tree_id)
|
const existing = lastUsedMap.get(session.tree_id)
|
||||||
if (!existing || new Date(session.started_at) > new Date(existing)) {
|
if (session.started_at && (!existing || new Date(session.started_at) > new Date(existing))) {
|
||||||
lastUsedMap.set(session.tree_id, session.started_at)
|
lastUsedMap.set(session.tree_id, session.started_at)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X, Plus } from 'lucide-react'
|
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X, Plus, Check, AlertCircle } from 'lucide-react'
|
||||||
import { treesApi } from '@/api/trees'
|
import { treesApi } from '@/api/trees'
|
||||||
import { sessionsApi } from '@/api/sessions'
|
import { sessionsApi } from '@/api/sessions'
|
||||||
import { stepsApi } from '@/api/steps'
|
import { stepsApi } from '@/api/steps'
|
||||||
import type { Tree, Session, ProceduralStep, DecisionRecord, RuntimeStep, CustomProceduralStep } from '@/types'
|
import type { Tree, Session, ProceduralStep, DecisionRecord, RuntimeStep, CustomProceduralStep, IntakeFormField } from '@/types'
|
||||||
import type { CustomStep } from '@/types/session'
|
import type { CustomStep } from '@/types/session'
|
||||||
import type { Step } from '@/types/step'
|
import type { Step } from '@/types/step'
|
||||||
import { IntakeFormModal } from '@/components/procedural/IntakeFormModal'
|
|
||||||
import { StepChecklist } from '@/components/procedural/StepChecklist'
|
import { StepChecklist } from '@/components/procedural/StepChecklist'
|
||||||
import { StepDetail } from '@/components/procedural/StepDetail'
|
import { StepDetail } from '@/components/procedural/StepDetail'
|
||||||
import { ProgressBar } from '@/components/procedural/ProgressBar'
|
import { ProgressBar } from '@/components/procedural/ProgressBar'
|
||||||
@@ -64,7 +63,6 @@ export function ProceduralNavigationPage() {
|
|||||||
const [tree, setTree] = useState<Tree | null>(null)
|
const [tree, setTree] = useState<Tree | null>(null)
|
||||||
const [session, setSession] = useState<Session | null>(null)
|
const [session, setSession] = useState<Session | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [showIntakeForm, setShowIntakeForm] = useState(false)
|
|
||||||
const [sessionVariables, setSessionVariables] = useState<Record<string, string>>({})
|
const [sessionVariables, setSessionVariables] = useState<Record<string, string>>({})
|
||||||
const [currentStepIndex, setCurrentStepIndex] = useState(0)
|
const [currentStepIndex, setCurrentStepIndex] = useState(0)
|
||||||
const [stepStates, setStepStates] = useState<Map<string, StepState>>(new Map())
|
const [stepStates, setStepStates] = useState<Map<string, StepState>>(new Map())
|
||||||
@@ -88,6 +86,22 @@ export function ProceduralNavigationPage() {
|
|||||||
const [isSavingStep, setIsSavingStep] = useState(false)
|
const [isSavingStep, setIsSavingStep] = useState(false)
|
||||||
const [copilotOpen, setCopilotOpen] = useState(false)
|
const [copilotOpen, setCopilotOpen] = useState(false)
|
||||||
|
|
||||||
|
// Editable variables panel state
|
||||||
|
const [editingVarName, setEditingVarName] = useState<string | null>(null)
|
||||||
|
const [editingVarValue, setEditingVarValue] = useState('')
|
||||||
|
const [showUnfilledWarning, setShowUnfilledWarning] = useState(false)
|
||||||
|
|
||||||
|
// Get intake form fields from tree snapshot or tree
|
||||||
|
const intakeFields: IntakeFormField[] = (() => {
|
||||||
|
if (tree?.intake_form && tree.intake_form.length > 0) return tree.intake_form
|
||||||
|
// Fallback: check tree snapshot on session
|
||||||
|
const snapshot = session?.tree_snapshot as Record<string, unknown> | undefined
|
||||||
|
if (snapshot?.intake_form && Array.isArray(snapshot.intake_form)) {
|
||||||
|
return snapshot.intake_form as IntakeFormField[]
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
})()
|
||||||
|
|
||||||
// Get procedural steps from tree
|
// Get procedural steps from tree
|
||||||
const getSteps = (): ProceduralStep[] => {
|
const getSteps = (): ProceduralStep[] => {
|
||||||
if (!tree) return []
|
if (!tree) return []
|
||||||
@@ -127,9 +141,9 @@ export function ProceduralNavigationPage() {
|
|||||||
|
|
||||||
// Elapsed time timer
|
// Elapsed time timer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (session && !isComplete) {
|
if (session && session.started_at && !isComplete) {
|
||||||
const calcElapsed = () => {
|
const calcElapsed = () => {
|
||||||
const start = parseTimestamp(session.started_at).getTime()
|
const start = parseTimestamp(session.started_at!).getTime()
|
||||||
setElapsedMinutes(Math.max(0, Math.floor((Date.now() - start) / 60000)))
|
setElapsedMinutes(Math.max(0, Math.floor((Date.now() - start) / 60000)))
|
||||||
}
|
}
|
||||||
calcElapsed()
|
calcElapsed()
|
||||||
@@ -169,12 +183,9 @@ export function ProceduralNavigationPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if intake form exists
|
// Start session immediately — no intake form modal
|
||||||
if (treeData.intake_form && treeData.intake_form.length > 0) {
|
// Variables will be filled inline during execution
|
||||||
setShowIntakeForm(true)
|
await startSession(id, {})
|
||||||
} else {
|
|
||||||
await startSession(id, {})
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to load flow')
|
toast.error('Failed to load flow')
|
||||||
navigate('/trees')
|
navigate('/trees')
|
||||||
@@ -191,7 +202,6 @@ export function ProceduralNavigationPage() {
|
|||||||
})
|
})
|
||||||
setSession(newSession)
|
setSession(newSession)
|
||||||
setSessionVariables(variables)
|
setSessionVariables(variables)
|
||||||
setShowIntakeForm(false)
|
|
||||||
|
|
||||||
// Initialize step states
|
// Initialize step states
|
||||||
const initialStates = new Map<string, StepState>()
|
const initialStates = new Map<string, StepState>()
|
||||||
@@ -212,7 +222,6 @@ export function ProceduralNavigationPage() {
|
|||||||
const sessionData = await sessionsApi.get(sessionId)
|
const sessionData = await sessionsApi.get(sessionId)
|
||||||
setSession(sessionData)
|
setSession(sessionData)
|
||||||
setSessionVariables(sessionData.session_variables || {})
|
setSessionVariables(sessionData.session_variables || {})
|
||||||
setShowIntakeForm(false)
|
|
||||||
|
|
||||||
// Initialize step states from session decisions
|
// Initialize step states from session decisions
|
||||||
const allSteps = getStepsFromTree(treeData)
|
const allSteps = getStepsFromTree(treeData)
|
||||||
@@ -257,9 +266,21 @@ export function ProceduralNavigationPage() {
|
|||||||
return structure.steps || []
|
return structure.steps || []
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleIntakeSubmit = async (variables: Record<string, string>) => {
|
// Handle inline variable submission (from StepDetail or variables panel)
|
||||||
if (!treeId) return
|
const handleVariableSubmit = async (variableName: string, value: string) => {
|
||||||
await startSession(treeId, variables)
|
if (!session) return
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
const newVars = { ...sessionVariables, [variableName]: value }
|
||||||
|
setSessionVariables(newVars)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionsApi.updateVariables(session.id, { [variableName]: value })
|
||||||
|
} catch {
|
||||||
|
// Revert on failure
|
||||||
|
setSessionVariables(sessionVariables)
|
||||||
|
toast.error('Failed to save variable')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMarkComplete = async () => {
|
const handleMarkComplete = async () => {
|
||||||
@@ -310,7 +331,18 @@ export function ProceduralNavigationPage() {
|
|||||||
|
|
||||||
// Move to next step or complete
|
// Move to next step or complete
|
||||||
if (currentStepIndex >= procedureSteps.length - 1) {
|
if (currentStepIndex >= procedureSteps.length - 1) {
|
||||||
// Last step — complete the procedure
|
// Last step — check for unfilled required variables
|
||||||
|
const unfilledReqVars = intakeFields.filter(
|
||||||
|
f => f.required && !sessionVariables[f.variable_name]?.trim()
|
||||||
|
)
|
||||||
|
if (unfilledReqVars.length > 0 && !showUnfilledWarning) {
|
||||||
|
// Show warning but don't block
|
||||||
|
setShowUnfilledWarning(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setShowUnfilledWarning(false)
|
||||||
|
|
||||||
|
// Complete the procedure
|
||||||
const completedTime = new Date().toISOString()
|
const completedTime = new Date().toISOString()
|
||||||
await sessionsApi.complete(session.id, {
|
await sessionsApi.complete(session.id, {
|
||||||
outcome: 'resolved',
|
outcome: 'resolved',
|
||||||
@@ -461,6 +493,21 @@ export function ProceduralNavigationPage() {
|
|||||||
setPendingCustomStep(null)
|
setPendingCustomStep(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Variables panel: start editing
|
||||||
|
const startEditingVar = (varName: string) => {
|
||||||
|
setEditingVarName(varName)
|
||||||
|
setEditingVarValue(sessionVariables[varName] || '')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Variables panel: save edit
|
||||||
|
const saveEditingVar = () => {
|
||||||
|
if (editingVarName && editingVarValue.trim()) {
|
||||||
|
handleVariableSubmit(editingVarName, editingVarValue.trim())
|
||||||
|
}
|
||||||
|
setEditingVarName(null)
|
||||||
|
setEditingVarValue('')
|
||||||
|
}
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -470,19 +517,6 @@ export function ProceduralNavigationPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intake form modal
|
|
||||||
if (showIntakeForm && tree) {
|
|
||||||
return (
|
|
||||||
<IntakeFormModal
|
|
||||||
isOpen={true}
|
|
||||||
fields={tree.intake_form || []}
|
|
||||||
treeName={tree.name}
|
|
||||||
onSubmit={handleIntakeSubmit}
|
|
||||||
onCancel={() => navigate('/trees')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Completion summary
|
// Completion summary
|
||||||
if (isComplete && tree && session) {
|
if (isComplete && tree && session) {
|
||||||
return (
|
return (
|
||||||
@@ -501,7 +535,7 @@ export function ProceduralNavigationPage() {
|
|||||||
}])
|
}])
|
||||||
)}
|
)}
|
||||||
variables={sessionVariables}
|
variables={sessionVariables}
|
||||||
startedAt={session.started_at}
|
startedAt={session.started_at || ''}
|
||||||
completedAt={completedAt}
|
completedAt={completedAt}
|
||||||
onExport={() => navigate(`/sessions/${session.id}`)}
|
onExport={() => navigate(`/sessions/${session.id}`)}
|
||||||
onClose={() => navigate('/trees')}
|
onClose={() => navigate('/trees')}
|
||||||
@@ -516,6 +550,11 @@ export function ProceduralNavigationPage() {
|
|||||||
const currentStep = procedureSteps[currentStepIndex]
|
const currentStep = procedureSteps[currentStepIndex]
|
||||||
const currentStepState = currentStep ? stepStates.get(currentStep.id) : undefined
|
const currentStepState = currentStep ? stepStates.get(currentStep.id) : undefined
|
||||||
|
|
||||||
|
// Count unfilled required variables
|
||||||
|
const unfilledRequired = intakeFields.filter(
|
||||||
|
f => f.required && !sessionVariables[f.variable_name]?.trim()
|
||||||
|
).length
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
<div className="flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
@@ -583,15 +622,20 @@ export function ProceduralNavigationPage() {
|
|||||||
onStepClick={setCurrentStepIndex}
|
onStepClick={setCurrentStepIndex}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* View Parameters button */}
|
{/* Session Variables button */}
|
||||||
{Object.keys(sessionVariables).length > 0 && (
|
{intakeFields.length > 0 && (
|
||||||
<div className="mt-3 border-t border-border pt-3">
|
<div className="mt-3 border-t border-border pt-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setParamsOpen(true)}
|
onClick={() => setParamsOpen(true)}
|
||||||
className="flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-muted-foreground"
|
className="flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Settings2 className="h-3.5 w-3.5" />
|
<Settings2 className="h-3.5 w-3.5" />
|
||||||
View Parameters ({Object.keys(sessionVariables).length})
|
Session Variables
|
||||||
|
{unfilledRequired > 0 && (
|
||||||
|
<span className="ml-auto flex h-4 w-4 items-center justify-center rounded-full bg-amber-400/20 text-[0.6rem] font-bold text-amber-400">
|
||||||
|
{unfilledRequired}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -614,6 +658,8 @@ export function ProceduralNavigationPage() {
|
|||||||
isCompleted={completedStepIds.has(currentStep.id)}
|
isCompleted={completedStepIds.has(currentStep.id)}
|
||||||
onMarkComplete={handleMarkComplete}
|
onMarkComplete={handleMarkComplete}
|
||||||
isLast={currentStepIndex === procedureSteps.length - 1}
|
isLast={currentStepIndex === procedureSteps.length - 1}
|
||||||
|
intakeFields={intakeFields}
|
||||||
|
onVariableSubmit={handleVariableSubmit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -667,7 +713,16 @@ export function ProceduralNavigationPage() {
|
|||||||
confirmLabel="Exit"
|
confirmLabel="Exit"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Parameters popover */}
|
<ConfirmDialog
|
||||||
|
isOpen={showUnfilledWarning}
|
||||||
|
onClose={() => setShowUnfilledWarning(false)}
|
||||||
|
onConfirm={handleMarkComplete}
|
||||||
|
title="Unfilled Variables"
|
||||||
|
message={`${intakeFields.filter(f => f.required && !sessionVariables[f.variable_name]?.trim()).length} required variable(s) are still empty. You can fill them now via the Session Variables panel, or complete anyway. Unfilled variables will appear as blank in exports.`}
|
||||||
|
confirmLabel="Complete Anyway"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Session Variables Panel (editable) */}
|
||||||
{paramsOpen && (
|
{paramsOpen && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
<div
|
<div
|
||||||
@@ -676,7 +731,7 @@ export function ProceduralNavigationPage() {
|
|||||||
/>
|
/>
|
||||||
<div className="relative w-full max-w-md rounded-2xl border border-border bg-card shadow-2xl backdrop-blur-xs">
|
<div className="relative w-full max-w-md rounded-2xl border border-border bg-card shadow-2xl backdrop-blur-xs">
|
||||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||||
<h3 className="text-sm font-semibold text-foreground">Project Parameters</h3>
|
<h3 className="text-sm font-semibold text-foreground">Session Variables</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => setParamsOpen(false)}
|
onClick={() => setParamsOpen(false)}
|
||||||
className="rounded-lg p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
className="rounded-lg p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
@@ -686,12 +741,111 @@ export function ProceduralNavigationPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="max-h-[60vh] overflow-y-auto p-5">
|
<div className="max-h-[60vh] overflow-y-auto p-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{Object.entries(sessionVariables).map(([key, value]) => (
|
{intakeFields
|
||||||
<div key={key} className="flex items-baseline justify-between gap-4 rounded-lg bg-accent px-3 py-2">
|
.sort((a, b) => a.display_order - b.display_order)
|
||||||
<span className="text-xs font-medium text-muted-foreground">{key.replace(/_/g, ' ')}</span>
|
.map((field) => {
|
||||||
<span className="text-right text-sm text-muted-foreground">{value || 'N/A'}</span>
|
const value = sessionVariables[field.variable_name] || ''
|
||||||
</div>
|
const isFilled = !!value.trim()
|
||||||
))}
|
const isEditing = editingVarName === field.variable_name
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={field.variable_name}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border px-3 py-2.5',
|
||||||
|
isFilled ? 'border-border bg-accent' : 'border-cyan-500/20 bg-cyan-500/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{isFilled ? (
|
||||||
|
<Check className="h-3.5 w-3.5 shrink-0 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-cyan-400" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium text-muted-foreground truncate">
|
||||||
|
{field.label}
|
||||||
|
{field.required && <span className="ml-1 text-amber-400">*</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{!isEditing && (
|
||||||
|
<button
|
||||||
|
onClick={() => startEditingVar(field.variable_name)}
|
||||||
|
className="shrink-0 rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-card hover:text-foreground"
|
||||||
|
>
|
||||||
|
{isFilled ? 'Edit' : 'Fill'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
{field.field_type === 'select' && field.options?.length ? (
|
||||||
|
<select
|
||||||
|
value={editingVarValue}
|
||||||
|
onChange={(e) => setEditingVarValue(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
className="w-full rounded-md border border-cyan-500/30 bg-card px-2.5 py-1.5 text-sm text-foreground focus:border-cyan-400 focus:outline-hidden focus:ring-1 focus:ring-cyan-400/30"
|
||||||
|
>
|
||||||
|
<option value="">{field.placeholder || 'Select...'}</option>
|
||||||
|
{field.options.map((opt) => (
|
||||||
|
<option key={opt} value={opt}>{opt}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : field.field_type === 'textarea' ? (
|
||||||
|
<textarea
|
||||||
|
value={editingVarValue}
|
||||||
|
onChange={(e) => setEditingVarValue(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
rows={3}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="w-full rounded-md border border-cyan-500/30 bg-card px-2.5 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-cyan-400 focus:outline-hidden focus:ring-1 focus:ring-cyan-400/30"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type={field.field_type === 'number' ? 'number' : field.field_type === 'email' ? 'email' : 'text'}
|
||||||
|
value={editingVarValue}
|
||||||
|
onChange={(e) => setEditingVarValue(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') saveEditingVar() }}
|
||||||
|
autoFocus
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="w-full rounded-md border border-cyan-500/30 bg-card px-2.5 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-cyan-400 focus:outline-hidden focus:ring-1 focus:ring-cyan-400/30"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{field.help_text && (
|
||||||
|
<p className="mt-1 text-[0.625rem] text-muted-foreground">{field.help_text}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setEditingVarName(null); setEditingVarValue('') }}
|
||||||
|
className="rounded px-2.5 py-1 text-xs text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={saveEditingVar}
|
||||||
|
disabled={!editingVarValue.trim()}
|
||||||
|
className="rounded bg-gradient-brand px-2.5 py-1 text-xs font-medium text-[#101114] disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isFilled ? (
|
||||||
|
<p className="mt-1 text-sm text-foreground truncate">{value}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Show any extra variables not in intake form */}
|
||||||
|
{Object.entries(sessionVariables)
|
||||||
|
.filter(([key]) => !intakeFields.some(f => f.variable_name === key))
|
||||||
|
.map(([key, value]) => (
|
||||||
|
<div key={key} className="flex items-baseline justify-between gap-4 rounded-lg bg-accent px-3 py-2">
|
||||||
|
<span className="text-xs font-medium text-muted-foreground">{key.replace(/_/g, ' ')}</span>
|
||||||
|
<span className="text-right text-sm text-muted-foreground">{value || 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { WeeklyCalendar } from '@/components/dashboard/WeeklyCalendar'
|
|||||||
import { QuickActions } from '@/components/dashboard/QuickActions'
|
import { QuickActions } from '@/components/dashboard/QuickActions'
|
||||||
import { OpenSessions } from '@/components/dashboard/OpenSessions'
|
import { OpenSessions } from '@/components/dashboard/OpenSessions'
|
||||||
import { RecentActivity } from '@/components/dashboard/RecentActivity'
|
import { RecentActivity } from '@/components/dashboard/RecentActivity'
|
||||||
|
import { PreparedSessions } from '@/components/dashboard/PreparedSessions'
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
@@ -218,6 +219,7 @@ export function QuickStartPage() {
|
|||||||
// Stats
|
// Stats
|
||||||
const openSessions = activeSessions.length
|
const openSessions = activeSessions.length
|
||||||
const todaySessions = allSessions.filter(s => {
|
const todaySessions = allSessions.filter(s => {
|
||||||
|
if (!s.started_at) return false
|
||||||
const d = new Date(s.started_at)
|
const d = new Date(s.started_at)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
return d.toDateString() === now.toDateString()
|
return d.toDateString() === now.toDateString()
|
||||||
@@ -226,14 +228,15 @@ export function QuickStartPage() {
|
|||||||
|
|
||||||
// Open sessions for the new panel (3 oldest)
|
// Open sessions for the new panel (3 oldest)
|
||||||
const openSessionItems = activeSessions
|
const openSessionItems = activeSessions
|
||||||
.sort((a, b) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime())
|
.filter(s => s.started_at) // Exclude prepared sessions (started_at is null)
|
||||||
|
.sort((a, b) => new Date(a.started_at!).getTime() - new Date(b.started_at!).getTime())
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map(s => ({
|
.map(s => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
treeName: s.tree_snapshot?.name || 'Unknown',
|
treeName: s.tree_snapshot?.name || 'Unknown',
|
||||||
treeId: s.tree_id,
|
treeId: s.tree_id,
|
||||||
treeType: (s.tree_snapshot as unknown as Record<string, unknown>)?.tree_type as string | undefined,
|
treeType: (s.tree_snapshot as unknown as Record<string, unknown>)?.tree_type as string | undefined,
|
||||||
timeAgo: timeAgo(s.started_at),
|
timeAgo: timeAgo(s.started_at!),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// recentSessionItems removed — replaced by RecentActivity component
|
// recentSessionItems removed — replaced by RecentActivity component
|
||||||
@@ -335,7 +338,10 @@ export function QuickStartPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Row 3: Recent Activity */}
|
{/* Row 3: Prepared Sessions (only visible when sessions exist) */}
|
||||||
|
<PreparedSessions />
|
||||||
|
|
||||||
|
{/* Row 4: Recent Activity */}
|
||||||
<RecentActivity />
|
<RecentActivity />
|
||||||
|
|
||||||
{/* ── Existing content below ── */}
|
{/* ── Existing content below ── */}
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ export function SessionDetailPage() {
|
|||||||
|
|
||||||
const getTotalDuration = () => {
|
const getTotalDuration = () => {
|
||||||
if (!session?.completed_at) return 'In progress'
|
if (!session?.completed_at) return 'In progress'
|
||||||
const startedAtMs = new Date(session.started_at).getTime()
|
const startedAtMs = new Date(session.started_at || Date.now()).getTime()
|
||||||
const completedAtMs = new Date(session.completed_at).getTime()
|
const completedAtMs = new Date(session.completed_at).getTime()
|
||||||
if (Number.isNaN(startedAtMs) || Number.isNaN(completedAtMs)) return 'Unknown'
|
if (Number.isNaN(startedAtMs) || Number.isNaN(completedAtMs)) return 'Unknown'
|
||||||
const seconds = Math.max(0, Math.floor((completedAtMs - startedAtMs) / 1000))
|
const seconds = Math.max(0, Math.floor((completedAtMs - startedAtMs) / 1000))
|
||||||
@@ -358,7 +358,7 @@ export function SessionDetailPage() {
|
|||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{session.tree_snapshot?.name}
|
{session.tree_snapshot?.name}
|
||||||
{session.client_name && <> · Client: {session.client_name}</>}
|
{session.client_name && <> · Client: {session.client_name}</>}
|
||||||
{' · '}{new Date(session.started_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
{session.started_at && <>{' · '}{new Date(session.started_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}</>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ActionMenu
|
<ActionMenu
|
||||||
@@ -482,7 +482,7 @@ export function SessionDetailPage() {
|
|||||||
<SessionTimeline
|
<SessionTimeline
|
||||||
decisions={session.decisions}
|
decisions={session.decisions}
|
||||||
treeType={(session.tree_snapshot as unknown as Record<string, unknown>).tree_type as string}
|
treeType={(session.tree_snapshot as unknown as Record<string, unknown>).tree_type as string}
|
||||||
startedAt={session.started_at}
|
startedAt={session.started_at || ''}
|
||||||
completedAt={session.completed_at}
|
completedAt={session.completed_at}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ export function SessionHistoryPage() {
|
|||||||
const [hasMore, setHasMore] = useState(false)
|
const [hasMore, setHasMore] = useState(false)
|
||||||
const [trees, setTrees] = useState<TreeListItem[]>([])
|
const [trees, setTrees] = useState<TreeListItem[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [filter, setFilter] = useState<'all' | 'completed' | 'active'>('all')
|
const [filter, setFilter] = useState<'all' | 'completed' | 'active' | 'prepared'>('all')
|
||||||
|
|
||||||
// Initialize filters from URL params
|
// Initialize filters from URL params
|
||||||
const [filters, setFilters] = useState<SessionFilterState>(() => {
|
const [filters, setFilters] = useState<SessionFilterState>(() => {
|
||||||
@@ -67,8 +67,10 @@ export function SessionHistoryPage() {
|
|||||||
try {
|
try {
|
||||||
const params: Record<string, string | boolean> = {}
|
const params: Record<string, string | boolean> = {}
|
||||||
|
|
||||||
// Tab filter (all/active/completed)
|
// Tab filter (all/active/completed/prepared)
|
||||||
if (filter !== 'all') {
|
if (filter === 'prepared') {
|
||||||
|
params.status = 'prepared'
|
||||||
|
} else if (filter !== 'all') {
|
||||||
params.completed = filter === 'completed'
|
params.completed = filter === 'completed'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +175,7 @@ export function SessionHistoryPage() {
|
|||||||
|
|
||||||
{/* Filter Tabs */}
|
{/* Filter Tabs */}
|
||||||
<div className="mb-6 flex gap-2 border-b border-border">
|
<div className="mb-6 flex gap-2 border-b border-border">
|
||||||
{(['all', 'active', 'completed'] as const).map((tab) => (
|
{(['all', 'active', 'completed', 'prepared'] as const).map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
onClick={() => setFilter(tab)}
|
onClick={() => setFilter(tab)}
|
||||||
@@ -267,7 +269,7 @@ export function SessionHistoryPage() {
|
|||||||
|
|
||||||
{/* Timestamps */}
|
{/* Timestamps */}
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
Started: {formatDate(session.started_at)}
|
Started: {session.started_at ? formatDate(session.started_at) : 'Not started'}
|
||||||
{session.completed_at && (
|
{session.completed_at && (
|
||||||
<> · Completed: {formatDate(session.completed_at)}</>
|
<> · Completed: {formatDate(session.completed_at)}</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -436,7 +436,7 @@ export function TreeLibraryPage() {
|
|||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{s.client_name && `${s.client_name} · `}
|
{s.client_name && `${s.client_name} · `}
|
||||||
Started {formatTimeAgo(s.started_at)}
|
{s.started_at ? `Started ${formatTimeAgo(s.started_at)}` : 'Not started'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -177,10 +177,10 @@ export function TreeNavigationPage() {
|
|||||||
|
|
||||||
const deriveCurrentStepEnteredAt = (sessionData: Session): string => {
|
const deriveCurrentStepEnteredAt = (sessionData: Session): string => {
|
||||||
if (!sessionData.decisions || sessionData.decisions.length === 0) {
|
if (!sessionData.decisions || sessionData.decisions.length === 0) {
|
||||||
return sessionData.started_at
|
return sessionData.started_at || new Date().toISOString()
|
||||||
}
|
}
|
||||||
const lastDecision = sessionData.decisions[sessionData.decisions.length - 1]
|
const lastDecision = sessionData.decisions[sessionData.decisions.length - 1]
|
||||||
return lastDecision.exited_at || lastDecision.timestamp || sessionData.started_at
|
return lastDecision.exited_at || lastDecision.timestamp || sessionData.started_at || new Date().toISOString()
|
||||||
}
|
}
|
||||||
|
|
||||||
const openCompletionModal = (completionDecision: DecisionRecord, source: CompletionSource) => {
|
const openCompletionModal = (completionDecision: DecisionRecord, source: CompletionSource) => {
|
||||||
@@ -320,7 +320,7 @@ export function TreeNavigationPage() {
|
|||||||
client_name: clientName || undefined,
|
client_name: clientName || undefined,
|
||||||
})
|
})
|
||||||
setSession(newSession)
|
setSession(newSession)
|
||||||
setCurrentStepEnteredAt(newSession.started_at)
|
setCurrentStepEnteredAt(newSession.started_at || new Date().toISOString())
|
||||||
setShowMetadataForm(false)
|
setShowMetadataForm(false)
|
||||||
// Save for "Repeat Last Session"
|
// Save for "Repeat Last Session"
|
||||||
safeSetItem('last-session', JSON.stringify({
|
safeSetItem('last-session', JSON.stringify({
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export interface Session {
|
|||||||
path_taken: string[]
|
path_taken: string[]
|
||||||
decisions: DecisionRecord[]
|
decisions: DecisionRecord[]
|
||||||
custom_steps: CustomStep[]
|
custom_steps: CustomStep[]
|
||||||
started_at: string
|
started_at: string | null
|
||||||
completed_at: string | null
|
completed_at: string | null
|
||||||
outcome: SessionOutcome | null
|
outcome: SessionOutcome | null
|
||||||
outcome_notes: string | null
|
outcome_notes: string | null
|
||||||
@@ -60,6 +60,8 @@ export interface Session {
|
|||||||
scratchpad: string
|
scratchpad: string
|
||||||
next_steps: string
|
next_steps: string
|
||||||
session_variables: Record<string, string>
|
session_variables: Record<string, string>
|
||||||
|
prepared_by_id?: string | null
|
||||||
|
assigned_to_id?: string | null
|
||||||
batch_id?: string
|
batch_id?: string
|
||||||
target_label?: string
|
target_label?: string
|
||||||
}
|
}
|
||||||
@@ -71,6 +73,14 @@ export interface SessionCreate {
|
|||||||
session_variables?: Record<string, string>
|
session_variables?: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PrepareSessionRequest {
|
||||||
|
tree_id: string
|
||||||
|
session_variables?: Record<string, string>
|
||||||
|
assigned_to_id?: string
|
||||||
|
ticket_number?: string
|
||||||
|
client_name?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface SessionUpdate {
|
export interface SessionUpdate {
|
||||||
path_taken?: string[]
|
path_taken?: string[]
|
||||||
decisions?: DecisionRecord[]
|
decisions?: DecisionRecord[]
|
||||||
|
|||||||
Reference in New Issue
Block a user