diff --git a/backend/alembic/versions/053_add_prepared_session_fields.py b/backend/alembic/versions/053_add_prepared_session_fields.py new file mode 100644 index 00000000..2512f7a7 --- /dev/null +++ b/backend/alembic/versions/053_add_prepared_session_fields.py @@ -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") diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 5c9dfb04..951489a7 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -21,6 +21,8 @@ from app.schemas.session import ( SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete, + SessionVariablesUpdate, + PrepareSessionRequest, ) from app.api.deps import get_current_active_user 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_id: Optional[UUID] = Query(None, description="Filter by tree ID"), 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_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"), @@ -49,7 +53,11 @@ async def list_sessions( limit: int = Query(50, ge=1, le=100) ): """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 if completed is not None: @@ -78,6 +86,18 @@ async def list_sessions( if 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 if started_after: query = query.where(Session.started_at >= started_after) @@ -93,7 +113,7 @@ async def list_sessions( if page is not None: 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) result = await db.execute(query) @@ -117,7 +137,7 @@ async def get_session( 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( status_code=status.HTTP_403_FORBIDDEN, 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" ) - # 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 {} - 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) tree_snapshot = { @@ -217,7 +228,7 @@ async def update_session( 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( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this session" @@ -229,6 +240,13 @@ async def update_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 update_data = session_data.model_dump(exclude_unset=True, mode='json') @@ -257,7 +275,7 @@ async def complete_session( 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( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this session" @@ -295,7 +313,7 @@ async def update_scratchpad( 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( status_code=status.HTTP_403_FORBIDDEN, detail="You don't have access to this session" @@ -307,6 +325,45 @@ async def update_scratchpad( 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") async def export_session( session_id: UUID, @@ -324,7 +381,7 @@ async def export_session( 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( status_code=status.HTTP_403_FORBIDDEN, 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) ────────────────────────────────────── diff --git a/backend/app/models/session.py b/backend/app/models/session.py index f5085c81..1e41c534 100644 --- a/backend/app/models/session.py +++ b/backend/app/models/session.py @@ -35,8 +35,9 @@ class Session(Base): 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) 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), + nullable=True, default=lambda: datetime.now(timezone.utc), index=True ) @@ -60,9 +61,25 @@ class Session(Base): 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 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") shares: Mapped[list["SessionShare"]] = relationship("SessionShare", back_populates="session", cascade="all, delete-orphan") diff --git a/backend/app/models/user.py b/backend/app/models/user.py index d385fcfb..f0c3f3f6 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -99,7 +99,7 @@ class User(Base): 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") 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") @property diff --git a/backend/app/schemas/session.py b/backend/app/schemas/session.py index 3aefa3b7..f2222559 100644 --- a/backend/app/schemas/session.py +++ b/backend/app/schemas/session.py @@ -55,6 +55,15 @@ class SessionUpdate(BaseModel): 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): id: UUID tree_id: UUID @@ -63,7 +72,7 @@ class SessionResponse(BaseModel): path_taken: list[str] decisions: list[dict[str, Any]] custom_steps: list[dict[str, Any]] = Field(default_factory=list) - started_at: datetime + started_at: Optional[datetime] = None completed_at: Optional[datetime] = None outcome: Optional[SessionOutcome] = None outcome_notes: Optional[str] = None @@ -74,6 +83,10 @@ class SessionResponse(BaseModel): scratchpad: str = "" 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) def normalize_text_fields(cls, v): return v or "" @@ -106,6 +119,11 @@ class SessionComplete(BaseModel): 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): scratchpad: str diff --git a/backend/tests/test_procedural_flows.py b/backend/tests/test_procedural_flows.py index ce4625d4..550709ff 100644 --- a/backend/tests/test_procedural_flows.py +++ b/backend/tests/test_procedural_flows.py @@ -607,8 +607,8 @@ class TestProceduralFlowsAPI: assert session_data["session_variables"]["server_name"] == "DC01" assert session_data["tree_snapshot"]["tree_type"] == "procedural" - async def test_start_session_procedural_missing_required_field(self, client, auth_headers): - """Starting a procedural session without required intake fields should fail.""" + async def test_start_session_procedural_deferred_variables(self, client, auth_headers): + """Starting a procedural session without required intake fields should succeed (deferred variables).""" # Create published procedural tree with required intake form create_resp = await client.post( "/api/v1/trees", @@ -623,17 +623,27 @@ class TestProceduralFlowsAPI: ) 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( "/api/v1/sessions", json={ "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, ) - assert session_resp.status_code == 422 - assert "Missing required" in session_resp.json()["detail"] + assert session_resp.status_code == 201 + 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): """Starting a session with only required fields (optional missing) should work.""" diff --git a/frontend/src/api/sessions.ts b/frontend/src/api/sessions.ts index 69f6605f..1895d562 100644 --- a/frontend/src/api/sessions.ts +++ b/frontend/src/api/sessions.ts @@ -1,5 +1,5 @@ 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 { page?: number @@ -7,6 +7,8 @@ export interface SessionListParams { tree_id?: string batch_id?: string completed?: boolean + assigned_to_id?: string + status?: 'prepared' | 'active' | 'completed' ticket_number?: string client_name?: string tree_name?: string @@ -69,6 +71,16 @@ export const sessionsApi = { return { content: response.data, redactionMode, redactionSummary } }, + async prepare(data: PrepareSessionRequest): Promise { + const response = await apiClient.post('/sessions/prepare', data) + return response.data + }, + + async updateVariables(id: string, variables: Record): Promise { + const response = await apiClient.patch(`/sessions/${id}/variables`, { variables }) + return response.data + }, + async updateScratchpad(id: string, content: string): Promise { const response = await apiClient.patch(`/sessions/${id}/scratchpad`, { scratchpad: content }) return response.data diff --git a/frontend/src/components/dashboard/PreparedSessions.tsx b/frontend/src/components/dashboard/PreparedSessions.tsx new file mode 100644 index 00000000..891b5413 --- /dev/null +++ b/frontend/src/components/dashboard/PreparedSessions.tsx @@ -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([]) + 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)?.tree_type as string | undefined + navigate(getTreeNavigatePath(session.tree_id, treeType), { + state: { sessionId: session.id }, + }) + } + + return ( +
+
+
+ +

Prepared for You

+ + {sessions.length} + +
+
+ +
+ {sessions.map(session => { + const snapshot = session.tree_snapshot as unknown as Record + 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 ( + + ) + })} +
+
+ ) +} diff --git a/frontend/src/components/layout/NotificationsPanel.tsx b/frontend/src/components/layout/NotificationsPanel.tsx index df6c352a..38718943 100644 --- a/frontend/src/components/layout/NotificationsPanel.tsx +++ b/frontend/src/components/layout/NotificationsPanel.tsx @@ -24,7 +24,7 @@ export function NotificationsPanel() { setSessions(data) // Mark as "new" if any session was updated in the last hour 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(() => {}) }, []) @@ -90,7 +90,7 @@ export function NotificationsPanel() {

{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}`}

diff --git a/frontend/src/components/library/TreeGridView.tsx b/frontend/src/components/library/TreeGridView.tsx index 2792c905..1973b6ee 100644 --- a/frontend/src/components/library/TreeGridView.tsx +++ b/frontend/src/components/library/TreeGridView.tsx @@ -1,5 +1,5 @@ import { Link } from 'react-router-dom' -import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star, Download } from 'lucide-react' +import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star, Download, ClipboardList } from 'lucide-react' import type { TreeListItem } from '@/types' import { TagBadges } from '@/components/common/TagBadges' import { StaggerList } from '@/components/common/StaggerList' @@ -10,6 +10,7 @@ import { getTreeEditorPath } from '@/lib/routing' interface TreeGridViewProps { trees: TreeListItem[] onStartSession: (treeId: string, treeType?: string) => void + onPrepareSession?: (tree: TreeListItem) => void onTagClick: (tag: string) => void onFolderCreated: (parentId?: string | null) => void onDeleteTree: (tree: TreeListItem) => void @@ -23,6 +24,7 @@ interface TreeGridViewProps { export function TreeGridView({ trees, onStartSession, + onPrepareSession, onTagClick, onDeleteTree, onForkTree, @@ -169,6 +171,20 @@ export function TreeGridView({ )} + {onPrepareSession && tree.tree_type !== 'troubleshooting' && ( + + )} )} + {onPrepareSession && tree.tree_type !== 'troubleshooting' && ( + + )} )} + {onPrepareSession && tree.tree_type !== 'troubleshooting' && ( + + )} + ) + } + + // Select field type + if (fieldType === 'select' && options.length > 0) { + return ( + + + + ) + } + + // Text/other input types + const inputType = fieldType === 'email' ? 'email' + : fieldType === 'number' ? 'number' + : fieldType === 'url' ? 'url' + : fieldType === 'ip_address' ? 'text' + : 'text' + + return ( + + + } + 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 && ( + + {helpText} + + )} + + + ) +} diff --git a/frontend/src/components/procedural/PrepareSessionModal.tsx b/frontend/src/components/procedural/PrepareSessionModal.tsx new file mode 100644 index 00000000..a679b445 --- /dev/null +++ b/frontend/src/components/procedural/PrepareSessionModal.tsx @@ -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>({}) + 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 = {} + 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() + 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 ( +
+
+
+ {/* Header */} +
+
+ +

Prepare Session

+
+ +
+ + {/* Body */} +
+ {/* Flow name */} +
+

Flow

+

{treeName}

+
+ + {/* Context fields */} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + {/* Assignee */} + {teamMembers.length > 0 && ( +
+ + +
+ )} + + {/* Intake form fields */} + {intakeFields.length > 0 && ( +
+

+ Variables (optional — can be filled later) +

+ {Array.from(grouped.entries()).map(([groupName, fields]) => ( +
+ {grouped.size > 1 && ( +

{groupName}

+ )} + {fields.map(field => ( +
+ + {field.field_type === 'select' && field.options?.length ? ( + + ) : field.field_type === 'textarea' ? ( +