feat: session sharing frontend #76
@@ -139,7 +139,7 @@ pytest --override-ini="addopts="
|
|||||||
# First time only: create test database
|
# First time only: create test database
|
||||||
docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"
|
docker exec -it patherly_postgres psql -U postgres -c "CREATE DATABASE patherly_test;"
|
||||||
|
|
||||||
# Frontend build
|
# Frontend build (IMPORTANT: stricter than tsc --noEmit — always use as final check)
|
||||||
cd frontend && npm run build
|
cd frontend && npm run build
|
||||||
|
|
||||||
# Database migrations
|
# Database migrations
|
||||||
@@ -222,6 +222,10 @@ markSaved() // Clear isDirty BEFORE navigate()
|
|||||||
navigate(`/trees/${newTree.id}/edit`)
|
navigate(`/trees/${newTree.id}/edit`)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**12. TreeStructure vs Tree types:** `TreeStructure` is for node structure only — it does NOT have `tree_type`, `name`, etc. Those are on `Tree`. JSONB tree snapshots need `TreeStructure & Record<string, unknown>` for extra fields.
|
||||||
|
|
||||||
|
**13. Login redirect state format:** `navigate('/login', { state: { from: { pathname: '/path' } } })` — LoginPage expects `state.from.pathname` (object), NOT a plain string.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## RBAC & Permissions
|
## RBAC & Permissions
|
||||||
@@ -260,6 +264,7 @@ navigate(`/trees/${newTree.id}/edit`)
|
|||||||
|
|
||||||
- **New endpoint:** Create in `endpoints/` → add to `router.py` → schema in `schemas/` → tests → frontend API client
|
- **New endpoint:** Create in `endpoints/` → add to `router.py` → schema in `schemas/` → tests → frontend API client
|
||||||
- **New page:** Create in `pages/` → add route in `router.tsx` → nav link in `AppLayout.tsx`
|
- **New page:** Create in `pages/` → add route in `router.tsx` → nav link in `AppLayout.tsx`
|
||||||
|
- **New public route (no auth):** Add at top level in `router.tsx` alongside `/login`, `/register` — NOT inside the `ProtectedRoute`/`AppLayout` children.
|
||||||
- **Schema change:** Update model → `alembic revision --autogenerate -m "desc"` → review → `alembic upgrade head`
|
- **Schema change:** Update model → `alembic revision --autogenerate -m "desc"` → review → `alembic upgrade head`
|
||||||
- **New frontend API module:** Types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`
|
- **New frontend API module:** Types in `types/` → export from `types/index.ts` → client in `api/` → export from `api/index.ts`
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ def _validate_children(children: list[dict[str, Any]], path: str, errors: list[d
|
|||||||
|
|
||||||
# --- Procedural Tree Validation ---
|
# --- Procedural Tree Validation ---
|
||||||
|
|
||||||
VALID_STEP_TYPES = {"procedure_step", "procedure_end"}
|
VALID_STEP_TYPES = {"procedure_step", "procedure_end", "section_header"}
|
||||||
VALID_CONTENT_TYPES = {"action", "informational", "verification", "warning"}
|
VALID_CONTENT_TYPES = {"action", "informational", "verification", "warning"}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ TreeType = Literal['troubleshooting', 'procedural']
|
|||||||
# --- Intake Form Schemas ---
|
# --- Intake Form Schemas ---
|
||||||
|
|
||||||
FIELD_TYPES = Literal[
|
FIELD_TYPES = Literal[
|
||||||
'text', 'textarea', 'number', 'ip_address', 'email',
|
'text', 'textarea', 'number', 'ip_address', 'email', 'url',
|
||||||
'select', 'multi_select', 'checkbox', 'password'
|
'select', 'multi_select', 'checkbox', 'password'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
72
frontend/src/api/sessions.test.ts
Normal file
72
frontend/src/api/sessions.test.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { sessionsApi } from './sessions'
|
||||||
|
import apiClient from './client'
|
||||||
|
|
||||||
|
vi.mock('./client', () => ({
|
||||||
|
default: {
|
||||||
|
get: vi.fn(),
|
||||||
|
post: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mockClient = apiClient as unknown as {
|
||||||
|
get: ReturnType<typeof vi.fn>
|
||||||
|
post: ReturnType<typeof vi.fn>
|
||||||
|
delete: ReturnType<typeof vi.fn>
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('sessionsApi sharing methods', () => {
|
||||||
|
it('createShare hits POST /sessions/{id}/shares with correct payload', async () => {
|
||||||
|
const mockShare = { id: 'share-1', share_token: 'tok-123', visibility: 'public' }
|
||||||
|
mockClient.post.mockResolvedValue({ data: mockShare })
|
||||||
|
|
||||||
|
const payload = { visibility: 'public' as const, share_name: 'My Share' }
|
||||||
|
const result = await sessionsApi.createShare('session-42', payload)
|
||||||
|
|
||||||
|
expect(mockClient.post).toHaveBeenCalledWith('/sessions/session-42/shares', payload)
|
||||||
|
expect(result).toEqual(mockShare)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('listMyShares hits GET /shares/my-shares', async () => {
|
||||||
|
const mockShares = [
|
||||||
|
{ id: 'share-1', share_token: 'tok-1' },
|
||||||
|
{ id: 'share-2', share_token: 'tok-2' },
|
||||||
|
]
|
||||||
|
mockClient.get.mockResolvedValue({ data: mockShares })
|
||||||
|
|
||||||
|
const result = await sessionsApi.listMyShares()
|
||||||
|
|
||||||
|
expect(mockClient.get).toHaveBeenCalledWith('/shares/my-shares')
|
||||||
|
expect(result).toEqual(mockShares)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('revokeShare hits DELETE /shares/{id}', async () => {
|
||||||
|
mockClient.delete.mockResolvedValue({})
|
||||||
|
|
||||||
|
await sessionsApi.revokeShare('share-99')
|
||||||
|
|
||||||
|
expect(mockClient.delete).toHaveBeenCalledWith('/shares/share-99')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('getSharedSession hits GET /share/{token}', async () => {
|
||||||
|
const mockView = {
|
||||||
|
session_id: 'sess-1',
|
||||||
|
tree_name: 'DNS Troubleshooting',
|
||||||
|
path_taken: ['root', 'node-1'],
|
||||||
|
decisions: [],
|
||||||
|
}
|
||||||
|
mockClient.get.mockResolvedValue({ data: mockView })
|
||||||
|
|
||||||
|
const result = await sessionsApi.getSharedSession('tok-abc')
|
||||||
|
|
||||||
|
expect(mockClient.get).toHaveBeenCalledWith('/share/tok-abc')
|
||||||
|
expect(result).toEqual(mockView)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client'
|
||||||
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete, RedactionSummary } from '@/types'
|
import type { Session, SessionCreate, SessionUpdate, SessionExport, SaveAsTreeRequest, SaveAsTreeResponse, SessionComplete, RedactionSummary, SessionShareCreate, SessionShare, SharedSessionView } from '@/types'
|
||||||
|
|
||||||
export interface SessionListParams {
|
export interface SessionListParams {
|
||||||
page?: number
|
page?: number
|
||||||
@@ -85,6 +85,26 @@ export const sessionsApi = {
|
|||||||
const response = await apiClient.post<SaveAsTreeResponse>(`/sessions/${id}/save-as-tree`, data)
|
const response = await apiClient.post<SaveAsTreeResponse>(`/sessions/${id}/save-as-tree`, data)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Session Sharing
|
||||||
|
async createShare(sessionId: string, data: SessionShareCreate): Promise<SessionShare> {
|
||||||
|
const response = await apiClient.post<SessionShare>(`/sessions/${sessionId}/shares`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async listMyShares(): Promise<SessionShare[]> {
|
||||||
|
const response = await apiClient.get<SessionShare[]>('/shares/my-shares')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async revokeShare(shareId: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/shares/${shareId}`)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getSharedSession(shareToken: string): Promise<SharedSessionView> {
|
||||||
|
const response = await apiClient.get<SharedSessionView>(`/share/${shareToken}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export default sessionsApi
|
export default sessionsApi
|
||||||
|
|||||||
100
frontend/src/components/common/ActionMenu.tsx
Normal file
100
frontend/src/components/common/ActionMenu.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import { MoreVertical } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface MenuAction {
|
||||||
|
label: string
|
||||||
|
icon?: React.ComponentType<{ className?: string }>
|
||||||
|
onClick: () => void
|
||||||
|
disabled?: boolean
|
||||||
|
variant?: 'default' | 'destructive'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActionMenuProps {
|
||||||
|
actions: MenuAction[]
|
||||||
|
align?: 'left' | 'right' // default 'right'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionMenu({ actions, align = 'right' }: ActionMenuProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const menuRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside)
|
||||||
|
document.addEventListener('keydown', handleEscape)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside)
|
||||||
|
document.removeEventListener('keydown', handleEscape)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const handleItemClick = (action: MenuAction) => {
|
||||||
|
if (action.disabled) return
|
||||||
|
action.onClick()
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={menuRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-white/10 p-2 text-white/60',
|
||||||
|
'hover:bg-white/10 hover:text-white'
|
||||||
|
)}
|
||||||
|
aria-label="Actions"
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute z-50 mt-1 min-w-[180px] glass-card rounded-lg p-1',
|
||||||
|
align === 'right' ? 'right-0' : 'left-0'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{actions.map((action, index) => {
|
||||||
|
const Icon = action.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
onClick={() => handleItemClick(action)}
|
||||||
|
disabled={action.disabled}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm',
|
||||||
|
action.disabled
|
||||||
|
? 'cursor-not-allowed opacity-40'
|
||||||
|
: action.variant === 'destructive'
|
||||||
|
? 'text-red-400 hover:bg-white/10 hover:text-red-300'
|
||||||
|
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="h-4 w-4" />}
|
||||||
|
{action.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { MenuAction, ActionMenuProps }
|
||||||
|
|
||||||
|
export default ActionMenu
|
||||||
@@ -106,10 +106,14 @@ export function TagInput({
|
|||||||
} else if (e.key === 'Escape') {
|
} else if (e.key === 'Escape') {
|
||||||
setShowSuggestions(false)
|
setShowSuggestions(false)
|
||||||
setSelectedIndex(-1)
|
setSelectedIndex(-1)
|
||||||
} else if (e.key === ',' || e.key === 'Tab') {
|
} else if (e.key === ',' || e.key === ';' || e.key === 'Tab') {
|
||||||
if (inputValue.trim()) {
|
if (inputValue.trim()) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
addTag(inputValue)
|
// Support multiple tags separated by commas or semicolons
|
||||||
|
const parts = inputValue.split(/[,;]/).map(s => s.trim()).filter(Boolean)
|
||||||
|
for (const part of parts) {
|
||||||
|
addTag(part)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,7 +161,20 @@ export function TagInput({
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
value={inputValue}
|
value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
const val = e.target.value
|
||||||
|
// If pasted text contains delimiters, split into tags immediately
|
||||||
|
if (val.includes(',') || val.includes(';')) {
|
||||||
|
const parts = val.split(/[,;]/).map(s => s.trim()).filter(Boolean)
|
||||||
|
const last = parts.pop()
|
||||||
|
for (const part of parts) {
|
||||||
|
addTag(part)
|
||||||
|
}
|
||||||
|
setInputValue(last || '')
|
||||||
|
} else {
|
||||||
|
setInputValue(val)
|
||||||
|
}
|
||||||
|
}}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => {
|
onFocus={() => {
|
||||||
if (inputValue.length >= 1 && suggestions.length > 0) {
|
if (inputValue.length >= 1 && suggestions.length > 0) {
|
||||||
@@ -221,7 +238,7 @@ export function TagInput({
|
|||||||
|
|
||||||
{/* Helper text */}
|
{/* Helper text */}
|
||||||
<p className="mt-1 text-xs text-white/40">
|
<p className="mt-1 text-xs text-white/40">
|
||||||
{tags.length}/{maxTags} tags. Press Enter or comma to add.
|
{tags.length}/{maxTags} tags. Press Enter, Tab, comma, or semicolon to add.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ export function AppLayout() {
|
|||||||
},
|
},
|
||||||
{ path: '/my-trees', label: 'My Flows' },
|
{ path: '/my-trees', label: 'My Flows' },
|
||||||
{ path: '/sessions', label: 'Sessions' },
|
{ path: '/sessions', label: 'Sessions' },
|
||||||
|
{ path: '/shares', label: 'My Shares' },
|
||||||
{ path: '/account', label: 'Account' },
|
{ path: '/account', label: 'Account' },
|
||||||
...(isSuperAdmin ? [{ path: '/admin', label: 'Admin Panel' }] : []),
|
...(isSuperAdmin ? [{ path: '/admin', label: 'Admin Panel' }] : []),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const FIELD_TYPE_OPTIONS: { value: IntakeFieldType; label: string }[] = [
|
|||||||
{ value: 'number', label: 'Number' },
|
{ value: 'number', label: 'Number' },
|
||||||
{ value: 'ip_address', label: 'IP Address' },
|
{ value: 'ip_address', label: 'IP Address' },
|
||||||
{ value: 'email', label: 'Email' },
|
{ value: 'email', label: 'Email' },
|
||||||
|
{ value: 'url', label: 'URL' },
|
||||||
{ value: 'select', label: 'Select (Dropdown)' },
|
{ value: 'select', label: 'Select (Dropdown)' },
|
||||||
{ value: 'multi_select', label: 'Multi-Select' },
|
{ value: 'multi_select', label: 'Multi-Select' },
|
||||||
{ value: 'checkbox', label: 'Checkbox' },
|
{ value: 'checkbox', label: 'Checkbox' },
|
||||||
|
|||||||
@@ -36,10 +36,10 @@ export function IntakeFormBuilder() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{intakeForm.map((field, index) => (
|
{intakeForm.map((field, index) => (
|
||||||
<IntakeFieldEditor
|
<IntakeFieldEditor
|
||||||
key={field.variable_name + '-' + index}
|
key={`field-${index}-${field.display_order}`}
|
||||||
field={field}
|
field={field}
|
||||||
onUpdate={(updates) => updateField(field.variable_name, updates)}
|
onUpdate={(updates) => updateField(index, updates)}
|
||||||
onRemove={() => removeField(field.variable_name)}
|
onRemove={() => removeField(index)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ChevronUp, AlertTriangle, Clock, ExternalLink, CheckSquare, Terminal, Type } from 'lucide-react'
|
import { useState } from 'react'
|
||||||
|
import { ChevronUp, ChevronDown, AlertTriangle, Clock, ExternalLink, CheckSquare, Terminal, Settings2 } from 'lucide-react'
|
||||||
import type { ProceduralStep, StepContentType, IntakeFormField } from '@/types'
|
import type { ProceduralStep, StepContentType, IntakeFormField } from '@/types'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
@@ -18,6 +19,35 @@ interface StepEditorProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVariables }: StepEditorProps) {
|
export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVariables }: StepEditorProps) {
|
||||||
|
const [showMore, setShowMore] = useState(false)
|
||||||
|
|
||||||
|
// Section header steps get a minimal editor
|
||||||
|
if (step.type === 'section_header') {
|
||||||
|
return (
|
||||||
|
<div className="glass-card rounded-xl border border-white/10 p-4">
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium text-white/50">Edit Section Header</span>
|
||||||
|
<button
|
||||||
|
onClick={onCollapse}
|
||||||
|
className="rounded p-1 text-white/40 hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-white/50">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={step.title}
|
||||||
|
onChange={(e) => onUpdate({ title: e.target.value })}
|
||||||
|
placeholder="Section title"
|
||||||
|
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card rounded-xl border border-white/10 p-4">
|
<div className="glass-card rounded-xl border border-white/10 p-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -48,55 +78,18 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content type + Section header row */}
|
{/* Est. Minutes */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="w-40">
|
||||||
<div>
|
|
||||||
<label className="mb-1 block text-xs font-medium text-white/50">Content Type</label>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{CONTENT_TYPE_OPTIONS.map((opt) => (
|
|
||||||
<button
|
|
||||||
key={opt.value}
|
|
||||||
onClick={() => onUpdate({ content_type: opt.value })}
|
|
||||||
className={cn(
|
|
||||||
'rounded px-2 py-1 text-xs font-medium transition-colors',
|
|
||||||
step.content_type === opt.value
|
|
||||||
? 'bg-white/15 ' + opt.color
|
|
||||||
: 'text-white/40 hover:bg-white/10 hover:text-white/60'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
|
||||||
<Clock className="h-3 w-3" />
|
|
||||||
Est. Minutes
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={step.estimated_minutes || ''}
|
|
||||||
onChange={(e) => onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })}
|
|
||||||
placeholder="—"
|
|
||||||
min={1}
|
|
||||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section Header */}
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||||
<Type className="h-3 w-3" />
|
<Clock className="h-3 w-3" />
|
||||||
Section Header (optional)
|
Est. Minutes
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="number"
|
||||||
value={step.section_header || ''}
|
value={step.estimated_minutes || ''}
|
||||||
onChange={(e) => onUpdate({ section_header: e.target.value || undefined })}
|
onChange={(e) => onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })}
|
||||||
placeholder="e.g. Phase 2: AD Configuration"
|
placeholder="—"
|
||||||
|
min={1}
|
||||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,23 +120,6 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Warning text */}
|
|
||||||
{(step.content_type === 'warning' || step.warning_text) && (
|
|
||||||
<div>
|
|
||||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-yellow-400/70">
|
|
||||||
<AlertTriangle className="h-3 w-3" />
|
|
||||||
Warning Text
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={step.warning_text || ''}
|
|
||||||
onChange={(e) => onUpdate({ warning_text: e.target.value || undefined })}
|
|
||||||
placeholder="Caution: This will restart the service..."
|
|
||||||
rows={2}
|
|
||||||
className="w-full rounded border border-yellow-400/20 bg-yellow-400/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-yellow-400/30 focus:outline-none focus:ring-1 focus:ring-yellow-400/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Commands */}
|
{/* Commands */}
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||||
@@ -159,74 +135,127 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expected Outcome */}
|
{/* More Options toggle */}
|
||||||
<div>
|
<button
|
||||||
<label className="mb-1 block text-xs font-medium text-white/50">Expected Outcome (optional)</label>
|
type="button"
|
||||||
<input
|
onClick={() => setShowMore(!showMore)}
|
||||||
type="text"
|
className="flex items-center gap-1.5 text-xs text-white/40 hover:text-white/60"
|
||||||
value={step.expected_outcome || ''}
|
>
|
||||||
onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })}
|
<Settings2 className="h-3 w-3" />
|
||||||
placeholder="Server should respond with..."
|
More Options
|
||||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
{showMore ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
|
||||||
/>
|
</button>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Verification */}
|
{showMore && (
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="space-y-4 border-t border-white/[0.06] pt-4">
|
||||||
<div>
|
{/* Content Type */}
|
||||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
<div>
|
||||||
<CheckSquare className="h-3 w-3" />
|
<label className="mb-1 block text-xs font-medium text-white/50">Content Type</label>
|
||||||
Verification Prompt (optional)
|
<div className="flex gap-1">
|
||||||
</label>
|
{CONTENT_TYPE_OPTIONS.map((opt) => (
|
||||||
<input
|
<button
|
||||||
type="text"
|
key={opt.value}
|
||||||
value={step.verification_prompt || ''}
|
onClick={() => onUpdate({ content_type: opt.value })}
|
||||||
onChange={(e) => onUpdate({ verification_prompt: e.target.value || undefined })}
|
className={cn(
|
||||||
placeholder="Confirm the role was installed"
|
'rounded px-2 py-1 text-xs font-medium transition-colors',
|
||||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
step.content_type === opt.value
|
||||||
/>
|
? 'bg-white/15 ' + opt.color
|
||||||
</div>
|
: 'text-white/40 hover:bg-white/10 hover:text-white/60'
|
||||||
<div>
|
)}
|
||||||
<label className="mb-1 block text-xs font-medium text-white/50">Verification Type</label>
|
>
|
||||||
<select
|
{opt.label}
|
||||||
value={step.verification_type || ''}
|
</button>
|
||||||
onChange={(e) => onUpdate({ verification_type: e.target.value as 'checkbox' | 'text_input' || undefined })}
|
))}
|
||||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
</div>
|
||||||
>
|
</div>
|
||||||
<option value="">None</option>
|
|
||||||
<option value="checkbox">Checkbox (confirm done)</option>
|
|
||||||
<option value="text_input">Text input (enter value)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Reference URL + Notes toggle */}
|
{/* Warning text */}
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{(step.content_type === 'warning' || step.warning_text) && (
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-yellow-400/70">
|
||||||
<ExternalLink className="h-3 w-3" />
|
<AlertTriangle className="h-3 w-3" />
|
||||||
Reference URL (optional)
|
Warning Text
|
||||||
</label>
|
</label>
|
||||||
<input
|
<textarea
|
||||||
type="url"
|
value={step.warning_text || ''}
|
||||||
value={step.reference_url || ''}
|
onChange={(e) => onUpdate({ warning_text: e.target.value || undefined })}
|
||||||
onChange={(e) => onUpdate({ reference_url: e.target.value || undefined })}
|
placeholder="Caution: This will restart the service..."
|
||||||
placeholder="https://learn.microsoft.com/..."
|
rows={2}
|
||||||
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
className="w-full rounded border border-yellow-400/20 bg-yellow-400/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-yellow-400/30 focus:outline-none focus:ring-1 focus:ring-yellow-400/20"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end pb-1">
|
)}
|
||||||
<label className="flex items-center gap-2 text-sm text-white/60">
|
|
||||||
|
{/* Expected Outcome */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-white/50">Expected Outcome (optional)</label>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="text"
|
||||||
checked={step.notes_enabled !== false}
|
value={step.expected_outcome || ''}
|
||||||
onChange={(e) => onUpdate({ notes_enabled: e.target.checked })}
|
onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })}
|
||||||
className="rounded border-white/20"
|
placeholder="Server should respond with..."
|
||||||
|
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||||
/>
|
/>
|
||||||
Allow tech notes
|
</div>
|
||||||
</label>
|
|
||||||
|
{/* Verification */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||||
|
<CheckSquare className="h-3 w-3" />
|
||||||
|
Verification Prompt (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={step.verification_prompt || ''}
|
||||||
|
onChange={(e) => onUpdate({ verification_prompt: e.target.value || undefined })}
|
||||||
|
placeholder="Confirm the role was installed"
|
||||||
|
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-white/50">Verification Type</label>
|
||||||
|
<select
|
||||||
|
value={step.verification_type || ''}
|
||||||
|
onChange={(e) => onUpdate({ verification_type: e.target.value as 'checkbox' | 'text_input' || undefined })}
|
||||||
|
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||||
|
>
|
||||||
|
<option value="">None</option>
|
||||||
|
<option value="checkbox">Checkbox (confirm done)</option>
|
||||||
|
<option value="text_input">Text input (enter value)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reference URL + Notes toggle */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
|
||||||
|
<ExternalLink className="h-3 w-3" />
|
||||||
|
Reference URL (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={step.reference_url || ''}
|
||||||
|
onChange={(e) => onUpdate({ reference_url: e.target.value || undefined })}
|
||||||
|
placeholder="https://learn.microsoft.com/..."
|
||||||
|
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end pb-1">
|
||||||
|
<label className="flex items-center gap-2 text-sm text-white/60">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={step.notes_enabled !== false}
|
||||||
|
onChange={(e) => onUpdate({ notes_enabled: e.target.checked })}
|
||||||
|
className="rounded border-white/20"
|
||||||
|
/>
|
||||||
|
Allow tech notes
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield } from 'lucide-react'
|
import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield, SeparatorHorizontal } from 'lucide-react'
|
||||||
import type { StepContentType } from '@/types'
|
import type { StepContentType } from '@/types'
|
||||||
import { StepEditor } from './StepEditor'
|
import { StepEditor } from './StepEditor'
|
||||||
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
|
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
|
||||||
@@ -18,11 +18,13 @@ export function StepList() {
|
|||||||
expandedStepId,
|
expandedStepId,
|
||||||
setExpandedStepId,
|
setExpandedStepId,
|
||||||
addStep,
|
addStep,
|
||||||
|
addSectionHeader,
|
||||||
removeStep,
|
removeStep,
|
||||||
updateStep,
|
updateStep,
|
||||||
} = useProceduralEditorStore()
|
} = useProceduralEditorStore()
|
||||||
|
|
||||||
const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
|
const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
|
||||||
|
let stepCounter = 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="glass-card rounded-2xl p-4 sm:p-6">
|
<div className="glass-card rounded-2xl p-4 sm:p-6">
|
||||||
@@ -34,19 +36,27 @@ export function StepList() {
|
|||||||
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''})
|
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => addStep()}
|
<button
|
||||||
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
onClick={() => addSectionHeader()}
|
||||||
>
|
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||||
<Plus className="h-3.5 w-3.5" />
|
>
|
||||||
Add Step
|
<SeparatorHorizontal className="h-3.5 w-3.5" />
|
||||||
</button>
|
Add Section
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => addStep()}
|
||||||
|
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
Add Step
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{steps.map((step, index) => {
|
{steps.map((step) => {
|
||||||
if (step.type === 'procedure_end') {
|
if (step.type === 'procedure_end') {
|
||||||
// Render end step as a simple footer
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={step.id}
|
key={step.id}
|
||||||
@@ -65,20 +75,57 @@ export function StepList() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Section header rendering
|
||||||
|
if (step.type === 'section_header') {
|
||||||
|
const isExpanded = expandedStepId === step.id
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
return (
|
||||||
|
<div key={step.id}>
|
||||||
|
<StepEditor
|
||||||
|
step={step}
|
||||||
|
stepNumber={0}
|
||||||
|
onUpdate={(updates) => updateStep(step.id, updates)}
|
||||||
|
onCollapse={() => setExpandedStepId(null)}
|
||||||
|
availableVariables={intakeForm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
className="group flex items-center gap-2 border-b border-white/[0.06] pb-1 pt-3"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-white/20 group-hover:text-white/40" />
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 cursor-pointer text-xs font-semibold uppercase tracking-wider text-white/40 hover:text-white/60"
|
||||||
|
onClick={() => setExpandedStepId(step.id)}
|
||||||
|
>
|
||||||
|
{step.title || 'Untitled Section'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => removeStep(step.id)}
|
||||||
|
className="shrink-0 rounded p-1 text-white/30 opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular procedure step
|
||||||
|
stepCounter++
|
||||||
|
const stepNumber = stepCounter
|
||||||
const isExpanded = expandedStepId === step.id
|
const isExpanded = expandedStepId === step.id
|
||||||
const contentType = step.content_type || 'action'
|
const contentType = step.content_type || 'action'
|
||||||
const config = contentTypeConfig[contentType]
|
const config = contentTypeConfig[contentType]
|
||||||
const Icon = config.icon
|
const Icon = config.icon
|
||||||
const stepNumber = index + 1
|
|
||||||
|
|
||||||
if (isExpanded) {
|
if (isExpanded) {
|
||||||
return (
|
return (
|
||||||
<div key={step.id}>
|
<div key={step.id}>
|
||||||
{step.section_header && (
|
|
||||||
<div className="mb-2 mt-4 border-b border-white/[0.06] pb-1 text-xs font-semibold uppercase tracking-wider text-white/40">
|
|
||||||
{step.section_header}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<StepEditor
|
<StepEditor
|
||||||
step={step}
|
step={step}
|
||||||
stepNumber={stepNumber}
|
stepNumber={stepNumber}
|
||||||
@@ -92,11 +139,6 @@ export function StepList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={step.id}>
|
<div key={step.id}>
|
||||||
{step.section_header && (
|
|
||||||
<div className="mb-2 mt-4 border-b border-white/[0.06] pb-1 text-xs font-semibold uppercase tracking-wider text-white/40">
|
|
||||||
{step.section_header}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex items-center gap-2 rounded-xl border border-white/[0.06] px-3 py-2.5 transition-colors',
|
'group flex items-center gap-2 rounded-xl border border-white/[0.06] px-3 py-2.5 transition-colors',
|
||||||
|
|||||||
@@ -171,6 +171,18 @@ export function IntakeFormModal({ isOpen, fields, treeName, onSubmit, onCancel }
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 'url':
|
||||||
|
input = (
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(field.variable_name, e.target.value)}
|
||||||
|
placeholder={field.placeholder || 'https://...'}
|
||||||
|
className={baseInputClass}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
default: // text, ip_address, email
|
default: // text, ip_address, email
|
||||||
input = (
|
input = (
|
||||||
<input
|
<input
|
||||||
|
|||||||
207
frontend/src/components/session/SessionTimeline.tsx
Normal file
207
frontend/src/components/session/SessionTimeline.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Copy, Check } from 'lucide-react'
|
||||||
|
import type { DecisionRecord } from '@/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface SessionTimelineProps {
|
||||||
|
decisions: DecisionRecord[]
|
||||||
|
treeType?: string // 'procedural' or 'troubleshooting' (default)
|
||||||
|
startedAt: string
|
||||||
|
completedAt: string | null
|
||||||
|
showCopyButtons?: boolean // default true
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string) {
|
||||||
|
return new Date(dateString).toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(durationSeconds: number | null | undefined) {
|
||||||
|
if (durationSeconds == null || durationSeconds < 0) return null
|
||||||
|
if (durationSeconds < 60) return `${durationSeconds}s`
|
||||||
|
const hours = Math.floor(durationSeconds / 3600)
|
||||||
|
const minutes = Math.floor((durationSeconds % 3600) / 60)
|
||||||
|
const seconds = durationSeconds % 60
|
||||||
|
if (hours > 0) return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m`
|
||||||
|
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionTimeline({
|
||||||
|
decisions,
|
||||||
|
treeType,
|
||||||
|
startedAt,
|
||||||
|
completedAt,
|
||||||
|
showCopyButtons = true,
|
||||||
|
}: SessionTimelineProps) {
|
||||||
|
const [copiedStepIndex, setCopiedStepIndex] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const handleCopyStep = async (decision: DecisionRecord, index: number) => {
|
||||||
|
const lines: string[] = []
|
||||||
|
if (decision.question) lines.push(`Question: ${decision.question}`)
|
||||||
|
if (decision.answer) lines.push(`Answer: ${decision.answer}`)
|
||||||
|
if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`)
|
||||||
|
if (decision.notes) lines.push(`Notes: ${decision.notes}`)
|
||||||
|
if (decision.command_output) lines.push(`Output:\n${decision.command_output}`)
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(lines.join('\n'))
|
||||||
|
setCopiedStepIndex(index)
|
||||||
|
setTimeout(() => setCopiedStepIndex(null), 2000)
|
||||||
|
} catch {
|
||||||
|
// Clipboard access denied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (treeType === 'procedural') {
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-white">Procedure Steps</h2>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{decisions.map((decision, index) => {
|
||||||
|
const isCompleted = decision.answer === 'completed'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
'glass-card rounded-xl p-4',
|
||||||
|
isCompleted && 'border-l-2 border-emerald-400/50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<span className={cn(
|
||||||
|
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium',
|
||||||
|
isCompleted ? 'bg-emerald-400/10 text-emerald-400' : 'bg-white/10 text-white/50'
|
||||||
|
)}>
|
||||||
|
{isCompleted ? '\u2713' : index + 1}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-white">{decision.question || 'Step'}</p>
|
||||||
|
{decision.notes && (
|
||||||
|
<p className="mt-1.5 rounded bg-white/5 p-2 text-sm text-white/40">
|
||||||
|
Notes: {decision.notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{decision.command_output && (
|
||||||
|
<p className="mt-1 text-sm text-white/40">
|
||||||
|
Verification: {decision.command_output}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{decision.duration_seconds != null && (
|
||||||
|
<p className="mt-1 text-xs text-white/30">
|
||||||
|
Duration: {formatDuration(decision.duration_seconds)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showCopyButtons && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyStep(decision, index)}
|
||||||
|
title="Copy step to clipboard"
|
||||||
|
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
{copiedStepIndex === index ? (
|
||||||
|
<Check className="h-4 w-4 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{completedAt && (
|
||||||
|
<div className="flex items-center gap-3 pl-2 pt-2 text-sm">
|
||||||
|
<span className="h-3 w-3 rounded-full bg-emerald-500" />
|
||||||
|
<span className="text-emerald-400">
|
||||||
|
Procedure completed: {formatDate(completedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: troubleshooting decision timeline
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-white">Decision Timeline</h2>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="h-3 w-3 rounded-full bg-white" />
|
||||||
|
<span className="text-white/40">
|
||||||
|
Session started: {formatDate(startedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{decisions.map((decision, index) => (
|
||||||
|
<div key={index} className="ml-1 border-l-2 border-white/[0.06] pl-6">
|
||||||
|
<div className="relative">
|
||||||
|
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-white/20" />
|
||||||
|
<div className="glass-card rounded-xl p-4">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
{decision.question && (
|
||||||
|
<p className="font-medium text-white">{decision.question}</p>
|
||||||
|
)}
|
||||||
|
{decision.answer && (
|
||||||
|
<p className="mt-1 text-sm text-white">Answer: {decision.answer}</p>
|
||||||
|
)}
|
||||||
|
{decision.action_performed && (
|
||||||
|
<p className="mt-1 text-sm text-white/40">
|
||||||
|
Action: {decision.action_performed}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{decision.notes && (
|
||||||
|
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-white/40">
|
||||||
|
Notes: {decision.notes}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{decision.command_output && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="mb-1 text-xs font-medium text-white/50">Command Output</p>
|
||||||
|
<pre className="overflow-x-auto rounded bg-white/5 p-2 text-xs font-mono text-white/60 whitespace-pre-wrap">
|
||||||
|
{decision.command_output}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{decision.duration_seconds != null && (
|
||||||
|
<p className="mt-2 text-xs text-white/50">
|
||||||
|
Duration: {formatDuration(decision.duration_seconds)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 text-xs text-white/40">
|
||||||
|
{formatDate(decision.timestamp)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{showCopyButtons && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyStep(decision, index)}
|
||||||
|
title="Copy step to clipboard"
|
||||||
|
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
{copiedStepIndex === index ? (
|
||||||
|
<Check className="h-4 w-4 text-emerald-400" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{completedAt && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="h-3 w-3 rounded-full bg-green-500" />
|
||||||
|
<span className="text-emerald-400">
|
||||||
|
Session completed: {formatDate(completedAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SessionTimeline
|
||||||
431
frontend/src/components/session/ShareSessionModal.tsx
Normal file
431
frontend/src/components/session/ShareSessionModal.tsx
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { X, Copy, Check, Globe, Users, Clock, Trash2, Link2 } from 'lucide-react'
|
||||||
|
import type { SessionShare, SessionShareVisibility } from '@/types'
|
||||||
|
import { sessionsApi } from '@/api/sessions'
|
||||||
|
import { buildSessionShareUrl, filterSharesForSession } from '@/lib/sessionShare'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
|
||||||
|
interface ShareSessionModalProps {
|
||||||
|
sessionId: string
|
||||||
|
sessionLabel: string // e.g. ticket number or "Session Details"
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExpirationPreset = 'never' | '1day' | '7days' | '30days' | 'custom'
|
||||||
|
|
||||||
|
function getRelativeTime(dateString: string): string {
|
||||||
|
const now = Date.now()
|
||||||
|
const date = new Date(dateString).getTime()
|
||||||
|
const diffMs = now - date
|
||||||
|
const diffSeconds = Math.floor(diffMs / 1000)
|
||||||
|
const diffMinutes = Math.floor(diffSeconds / 60)
|
||||||
|
const diffHours = Math.floor(diffMinutes / 60)
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
|
||||||
|
if (diffSeconds < 60) return 'just now'
|
||||||
|
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`
|
||||||
|
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`
|
||||||
|
if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`
|
||||||
|
const diffMonths = Math.floor(diffDays / 30)
|
||||||
|
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExpirationLabel(expiresAt: string | null): { text: string; isExpired: boolean } {
|
||||||
|
if (!expiresAt) return { text: 'No expiration', isExpired: false }
|
||||||
|
const now = Date.now()
|
||||||
|
const expiry = new Date(expiresAt).getTime()
|
||||||
|
if (expiry <= now) return { text: 'Expired', isExpired: true }
|
||||||
|
|
||||||
|
const diffMs = expiry - now
|
||||||
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
|
||||||
|
const diffDays = Math.floor(diffHours / 24)
|
||||||
|
|
||||||
|
if (diffDays > 0) return { text: `Expires in ${diffDays} day${diffDays === 1 ? '' : 's'}`, isExpired: false }
|
||||||
|
if (diffHours > 0) return { text: `Expires in ${diffHours} hour${diffHours === 1 ? '' : 's'}`, isExpired: false }
|
||||||
|
return { text: 'Expires soon', isExpired: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeExpiresAt(preset: ExpirationPreset, customDatetime: string): string | undefined {
|
||||||
|
if (preset === 'never') return undefined
|
||||||
|
if (preset === 'custom') {
|
||||||
|
if (!customDatetime) return undefined
|
||||||
|
return new Date(customDatetime).toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
switch (preset) {
|
||||||
|
case '1day':
|
||||||
|
now.setDate(now.getDate() + 1)
|
||||||
|
break
|
||||||
|
case '7days':
|
||||||
|
now.setDate(now.getDate() + 7)
|
||||||
|
break
|
||||||
|
case '30days':
|
||||||
|
now.setDate(now.getDate() + 30)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return now.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ShareSessionModal({ sessionId, sessionLabel, isOpen, onClose }: ShareSessionModalProps) {
|
||||||
|
const [shares, setShares] = useState<SessionShare[]>([])
|
||||||
|
const [isLoadingShares, setIsLoadingShares] = useState(false)
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false)
|
||||||
|
const [copiedShareId, setCopiedShareId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [visibility, setVisibility] = useState<SessionShareVisibility>('account')
|
||||||
|
const [shareName, setShareName] = useState('')
|
||||||
|
const [expirationPreset, setExpirationPreset] = useState<ExpirationPreset>('never')
|
||||||
|
const [customDatetime, setCustomDatetime] = useState('')
|
||||||
|
const [visibilityError, setVisibilityError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const loadShares = useCallback(async () => {
|
||||||
|
setIsLoadingShares(true)
|
||||||
|
try {
|
||||||
|
const allShares = await sessionsApi.listMyShares()
|
||||||
|
const sessionShares = filterSharesForSession(allShares, sessionId)
|
||||||
|
// Sort newest first
|
||||||
|
sessionShares.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||||
|
setShares(sessionShares)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load shares:', err)
|
||||||
|
} finally {
|
||||||
|
setIsLoadingShares(false)
|
||||||
|
}
|
||||||
|
}, [sessionId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadShares()
|
||||||
|
// Reset form state
|
||||||
|
setVisibility('account')
|
||||||
|
setShareName('')
|
||||||
|
setExpirationPreset('never')
|
||||||
|
setCustomDatetime('')
|
||||||
|
setVisibilityError(null)
|
||||||
|
setCopiedShareId(null)
|
||||||
|
}
|
||||||
|
}, [isOpen, sessionId, loadShares])
|
||||||
|
|
||||||
|
const handleGenerateLink = async () => {
|
||||||
|
setIsGenerating(true)
|
||||||
|
setVisibilityError(null)
|
||||||
|
try {
|
||||||
|
const expires_at = computeExpiresAt(expirationPreset, customDatetime)
|
||||||
|
const newShare = await sessionsApi.createShare(sessionId, {
|
||||||
|
visibility,
|
||||||
|
share_name: shareName.trim() || undefined,
|
||||||
|
expires_at,
|
||||||
|
})
|
||||||
|
setShares([newShare, ...shares])
|
||||||
|
toast.success('Share link generated')
|
||||||
|
// Reset form
|
||||||
|
setShareName('')
|
||||||
|
setExpirationPreset('never')
|
||||||
|
setCustomDatetime('')
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = err as { response?: { status?: number; data?: { detail?: string } } }
|
||||||
|
if (
|
||||||
|
error.response?.status === 403 &&
|
||||||
|
error.response?.data?.detail?.toLowerCase().includes('public session sharing')
|
||||||
|
) {
|
||||||
|
setVisibilityError(error.response.data.detail ?? 'Organization does not allow public session sharing')
|
||||||
|
} else {
|
||||||
|
console.error('Failed to generate share link:', err)
|
||||||
|
toast.error('Failed to generate share link')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyUrl = async (share: SessionShare) => {
|
||||||
|
try {
|
||||||
|
const url = buildSessionShareUrl(share)
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
setCopiedShareId(share.id)
|
||||||
|
toast.success('Link copied to clipboard')
|
||||||
|
setTimeout(() => setCopiedShareId(null), 2000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy link:', err)
|
||||||
|
toast.error('Failed to copy link')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRevoke = async (shareId: string) => {
|
||||||
|
try {
|
||||||
|
await sessionsApi.revokeShare(shareId)
|
||||||
|
setShares(shares.filter((s) => s.id !== shareId))
|
||||||
|
toast.success('Share link revoked')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to revoke share:', err)
|
||||||
|
toast.error('Failed to revoke share')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const presetButtons: { value: ExpirationPreset; label: string }[] = [
|
||||||
|
{ value: 'never', label: 'Never' },
|
||||||
|
{ value: '1day', label: '1 day' },
|
||||||
|
{ value: '7days', label: '7 days' },
|
||||||
|
{ value: '30days', label: '30 days' },
|
||||||
|
{ value: 'custom', label: 'Custom' },
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
<div className="relative w-full max-w-lg glass-card rounded-2xl shadow-lg">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-white/[0.06] px-6 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-white">Share Session</h2>
|
||||||
|
<p className="text-sm text-white/40">{sessionLabel}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded-md p-1 text-white/40 hover:bg-white/[0.06] hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="max-h-[60vh] overflow-y-auto px-6 py-4 space-y-6">
|
||||||
|
{/* Create Share Form */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Visibility */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-white">
|
||||||
|
Visibility
|
||||||
|
</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setVisibility('account'); setVisibilityError(null) }}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors',
|
||||||
|
visibility === 'account'
|
||||||
|
? 'border-white/20 bg-white/10 text-white'
|
||||||
|
: 'border-white/[0.06] bg-transparent text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Users className="h-4 w-4" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium">Account Only</div>
|
||||||
|
<div className="text-xs text-white/40">Visible to your team</div>
|
||||||
|
</div>
|
||||||
|
{visibility === 'account' && (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-white" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setVisibility('public'); setVisibilityError(null) }}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center gap-3 rounded-md border px-4 py-3 text-left transition-colors',
|
||||||
|
visibility === 'public'
|
||||||
|
? 'border-white/20 bg-white/10 text-white'
|
||||||
|
: 'border-white/[0.06] bg-transparent text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Globe className="h-4 w-4" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium">Public</div>
|
||||||
|
<div className="text-xs text-white/40">Anyone with the link</div>
|
||||||
|
</div>
|
||||||
|
{visibility === 'public' && (
|
||||||
|
<div className="h-2 w-2 rounded-full bg-white" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{visibilityError && (
|
||||||
|
<p className="mt-2 text-sm text-red-400">{visibilityError}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Share Name */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-white">
|
||||||
|
Share Name <span className="text-white/40">(optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={shareName}
|
||||||
|
onChange={(e) => setShareName(e.target.value.slice(0, 100))}
|
||||||
|
placeholder="e.g. Training link, Customer escalation"
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder-white/30',
|
||||||
|
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20'
|
||||||
|
)}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expiration */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-white">
|
||||||
|
Expiration
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{presetButtons.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.value}
|
||||||
|
onClick={() => setExpirationPreset(preset.value)}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border px-3 py-1.5 text-sm transition-colors',
|
||||||
|
expirationPreset === preset.value
|
||||||
|
? 'border-white/20 bg-white/10 text-white'
|
||||||
|
: 'border-white/10 text-white/50 hover:border-white/20 hover:bg-white/[0.06]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{preset.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{expirationPreset === 'custom' && (
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={customDatetime}
|
||||||
|
onChange={(e) => setCustomDatetime(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'mt-2 w-full rounded-md border border-white/10 bg-black/50 px-3 py-2 text-sm text-white',
|
||||||
|
'focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20',
|
||||||
|
'[color-scheme:dark]'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Generate Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleGenerateLink}
|
||||||
|
disabled={isGenerating || (expirationPreset === 'custom' && !customDatetime)}
|
||||||
|
className={cn(
|
||||||
|
'flex w-full items-center justify-center gap-2 rounded-md bg-white px-4 py-2 text-sm font-medium text-black',
|
||||||
|
'hover:bg-white/90 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link2 className="h-4 w-4" />
|
||||||
|
{isGenerating ? 'Generating...' : 'Generate Link'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Existing Shares */}
|
||||||
|
{shares.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-3 text-sm font-medium text-white">
|
||||||
|
Active Shares ({shares.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{shares.map((share) => {
|
||||||
|
const expiration = getExpirationLabel(share.expires_at)
|
||||||
|
const isCopied = copiedShareId === share.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={share.id}
|
||||||
|
className="glass-card rounded-xl p-4 space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn(
|
||||||
|
'inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs',
|
||||||
|
share.visibility === 'public'
|
||||||
|
? 'bg-white/10 text-white/70'
|
||||||
|
: 'bg-white/10 text-white/70'
|
||||||
|
)}>
|
||||||
|
{share.visibility === 'public' ? (
|
||||||
|
<Globe className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{share.visibility === 'public' ? 'Public' : 'Account'}
|
||||||
|
</span>
|
||||||
|
<span className="truncate text-sm font-medium text-white">
|
||||||
|
{share.share_name || 'Untitled share'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/40">
|
||||||
|
<span>{getRelativeTime(share.created_at)}</span>
|
||||||
|
<span>
|
||||||
|
{share.view_count > 0
|
||||||
|
? `${share.view_count} view${share.view_count === 1 ? '' : 's'}`
|
||||||
|
: 'Not viewed yet'}
|
||||||
|
</span>
|
||||||
|
<span className={cn(
|
||||||
|
'flex items-center gap-1',
|
||||||
|
expiration.isExpired && 'text-red-400'
|
||||||
|
)}>
|
||||||
|
<Clock className="h-3 w-3" />
|
||||||
|
{expiration.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyUrl(share)}
|
||||||
|
title="Copy share URL"
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-white/10 p-1.5 text-sm transition-colors',
|
||||||
|
isCopied
|
||||||
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
|
||||||
|
: 'text-white/50 hover:bg-white/10 hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRevoke(share.id)}
|
||||||
|
title="Revoke share"
|
||||||
|
className="rounded-md border border-white/10 p-1.5 text-white/50 hover:bg-red-500/10 hover:border-red-500/30 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading state */}
|
||||||
|
{isLoadingShares && shares.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-white" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex justify-end gap-3 border-t border-white/[0.06] px-6 py-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-white/10 px-4 py-2 text-sm font-medium text-white/60',
|
||||||
|
'hover:bg-white/10 hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ShareSessionModal
|
||||||
89
frontend/src/components/session/SharedSessionTreePreview.tsx
Normal file
89
frontend/src/components/session/SharedSessionTreePreview.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { TreeStructure } from '@/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
interface SharedSessionTreePreviewProps {
|
||||||
|
treeStructure: TreeStructure
|
||||||
|
pathTaken: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeTypeColors: Record<string, string> = {
|
||||||
|
root: 'bg-white',
|
||||||
|
decision: 'bg-blue-400',
|
||||||
|
action: 'bg-yellow-400',
|
||||||
|
solution: 'bg-emerald-400',
|
||||||
|
information: 'bg-white/50',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeTitle(node: Record<string, unknown>): string {
|
||||||
|
return (
|
||||||
|
(node.question as string) ||
|
||||||
|
(node.title as string) ||
|
||||||
|
(node.node_type as string) ||
|
||||||
|
'Untitled'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TreeNode({
|
||||||
|
node,
|
||||||
|
depth,
|
||||||
|
pathTaken,
|
||||||
|
}: {
|
||||||
|
node: Record<string, unknown>
|
||||||
|
depth: number
|
||||||
|
pathTaken: string[]
|
||||||
|
}) {
|
||||||
|
const nodeId = (node.id as string) || ''
|
||||||
|
const nodeType = (node.node_type as string) || 'decision'
|
||||||
|
const isInPath = pathTaken.includes(nodeId)
|
||||||
|
const children = (node.children as Record<string, unknown>[]) || []
|
||||||
|
const colorClass = nodeTypeColors[nodeType] || 'bg-white/50'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-1.5 text-sm',
|
||||||
|
isInPath
|
||||||
|
? 'rounded-md border-l-2 border-white/40 bg-white/10 font-medium text-white'
|
||||||
|
: 'text-white/30'
|
||||||
|
)}
|
||||||
|
style={{ paddingLeft: `${depth * 16 + 12}px` }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn('h-2 w-2 shrink-0 rounded-full', colorClass)}
|
||||||
|
/>
|
||||||
|
<span className="truncate">{getNodeTitle(node)}</span>
|
||||||
|
</div>
|
||||||
|
{children.map((child, index) => (
|
||||||
|
<TreeNode
|
||||||
|
key={(child.id as string) || index}
|
||||||
|
node={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
pathTaken={pathTaken}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SharedSessionTreePreview({
|
||||||
|
treeStructure,
|
||||||
|
pathTaken,
|
||||||
|
}: SharedSessionTreePreviewProps) {
|
||||||
|
if (!treeStructure) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass-card rounded-2xl">
|
||||||
|
<div className="sticky top-0 z-10 rounded-t-2xl border-b border-white/[0.06] bg-black/80 px-6 py-4 backdrop-blur">
|
||||||
|
<h3 className="text-sm font-semibold text-white">Tree Structure</h3>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[600px] overflow-y-auto py-2">
|
||||||
|
<TreeNode node={treeStructure as unknown as Record<string, unknown>} depth={0} pathTaken={pathTaken} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SharedSessionTreePreview
|
||||||
31
frontend/src/lib/routing.ts
Normal file
31
frontend/src/lib/routing.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Shared routing helpers for tree/session navigation.
|
||||||
|
* Centralizes the logic for determining the correct navigation path
|
||||||
|
* based on tree type (troubleshooting vs procedural).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the navigation path for starting or resuming a tree/session.
|
||||||
|
*/
|
||||||
|
export function getTreeNavigatePath(
|
||||||
|
treeId: string,
|
||||||
|
treeType?: string
|
||||||
|
): string {
|
||||||
|
if (treeType === 'procedural') {
|
||||||
|
return `/flows/${treeId}/navigate`
|
||||||
|
}
|
||||||
|
return `/trees/${treeId}/navigate`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the editor path for a tree.
|
||||||
|
*/
|
||||||
|
export function getTreeEditorPath(
|
||||||
|
treeId: string,
|
||||||
|
treeType?: string
|
||||||
|
): string {
|
||||||
|
if (treeType === 'procedural') {
|
||||||
|
return `/flows/${treeId}/edit`
|
||||||
|
}
|
||||||
|
return `/trees/${treeId}/edit`
|
||||||
|
}
|
||||||
85
frontend/src/lib/sessionShare.test.ts
Normal file
85
frontend/src/lib/sessionShare.test.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest'
|
||||||
|
import type { SessionShare } from '@/types'
|
||||||
|
import { buildSessionShareUrl, filterSharesForSession, getLatestActiveShareForSession } from './sessionShare'
|
||||||
|
|
||||||
|
function makeMockShare(overrides: Partial<SessionShare> = {}): SessionShare {
|
||||||
|
return {
|
||||||
|
id: 'share-1',
|
||||||
|
session_id: 'session-1',
|
||||||
|
account_id: 'account-1',
|
||||||
|
share_token: 'abc123',
|
||||||
|
share_name: null,
|
||||||
|
visibility: 'public',
|
||||||
|
created_by: 'user-1',
|
||||||
|
created_at: '2026-02-14T10:00:00Z',
|
||||||
|
updated_at: '2026-02-14T10:00:00Z',
|
||||||
|
expires_at: null,
|
||||||
|
view_count: 0,
|
||||||
|
last_viewed_at: null,
|
||||||
|
is_active: true,
|
||||||
|
share_url: null,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('buildSessionShareUrl', () => {
|
||||||
|
it('returns share_url when present', () => {
|
||||||
|
const share = makeMockShare({ share_url: 'https://resolutionflow.com/share/abc123' })
|
||||||
|
expect(buildSessionShareUrl(share)).toBe('https://resolutionflow.com/share/abc123')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('constructs URL from token when share_url is null', () => {
|
||||||
|
vi.stubGlobal('location', { origin: 'http://localhost:5173' })
|
||||||
|
const share = makeMockShare({ share_token: 'tok-xyz', share_url: null })
|
||||||
|
expect(buildSessionShareUrl(share)).toBe('http://localhost:5173/share/tok-xyz')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('filterSharesForSession', () => {
|
||||||
|
it('filters to matching session_id and active shares', () => {
|
||||||
|
const shares = [
|
||||||
|
makeMockShare({ id: 's1', session_id: 'sess-A', is_active: true }),
|
||||||
|
makeMockShare({ id: 's2', session_id: 'sess-B', is_active: true }),
|
||||||
|
makeMockShare({ id: 's3', session_id: 'sess-A', is_active: true }),
|
||||||
|
]
|
||||||
|
const result = filterSharesForSession(shares, 'sess-A')
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result.map(s => s.id)).toEqual(['s1', 's3'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('excludes inactive shares', () => {
|
||||||
|
const shares = [
|
||||||
|
makeMockShare({ id: 's1', session_id: 'sess-A', is_active: true }),
|
||||||
|
makeMockShare({ id: 's2', session_id: 'sess-A', is_active: false }),
|
||||||
|
]
|
||||||
|
const result = filterSharesForSession(shares, 'sess-A')
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe('s1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getLatestActiveShareForSession', () => {
|
||||||
|
it('returns the most recently created share', () => {
|
||||||
|
const shares = [
|
||||||
|
makeMockShare({ id: 'old', session_id: 'sess-A', created_at: '2026-02-10T10:00:00Z', is_active: true }),
|
||||||
|
makeMockShare({ id: 'newest', session_id: 'sess-A', created_at: '2026-02-14T12:00:00Z', is_active: true }),
|
||||||
|
makeMockShare({ id: 'mid', session_id: 'sess-A', created_at: '2026-02-12T10:00:00Z', is_active: true }),
|
||||||
|
]
|
||||||
|
const result = getLatestActiveShareForSession(shares, 'sess-A')
|
||||||
|
expect(result).not.toBeNull()
|
||||||
|
expect(result!.id).toBe('newest')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when no shares match', () => {
|
||||||
|
const shares = [
|
||||||
|
makeMockShare({ session_id: 'sess-B', is_active: true }),
|
||||||
|
makeMockShare({ session_id: 'sess-A', is_active: false }),
|
||||||
|
]
|
||||||
|
const result = getLatestActiveShareForSession(shares, 'sess-A')
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
27
frontend/src/lib/sessionShare.ts
Normal file
27
frontend/src/lib/sessionShare.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import type { SessionShare } from '@/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the full share URL from a SessionShare object.
|
||||||
|
* Uses share.share_url if present, otherwise constructs from token.
|
||||||
|
*/
|
||||||
|
export function buildSessionShareUrl(share: SessionShare): string {
|
||||||
|
if (share.share_url) return share.share_url
|
||||||
|
return `${window.location.origin}/share/${share.share_token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter shares to only those belonging to a specific session.
|
||||||
|
*/
|
||||||
|
export function filterSharesForSession(shares: SessionShare[], sessionId: string): SessionShare[] {
|
||||||
|
return shares.filter(s => s.session_id === sessionId && s.is_active)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the most recent active share for a given session.
|
||||||
|
* Returns null if no active shares exist.
|
||||||
|
*/
|
||||||
|
export function getLatestActiveShareForSession(shares: SessionShare[], sessionId: string): SessionShare | null {
|
||||||
|
const sessionShares = filterSharesForSession(shares, sessionId)
|
||||||
|
if (sessionShares.length === 0) return null
|
||||||
|
return sessionShares.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
|
||||||
|
}
|
||||||
240
frontend/src/pages/MySharesPage.tsx
Normal file
240
frontend/src/pages/MySharesPage.tsx
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { Globe, Users, Copy, Check, Link2, ExternalLink, Trash2, ArrowLeft } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
import { sessionsApi } from '@/api/sessions'
|
||||||
|
import { buildSessionShareUrl } from '@/lib/sessionShare'
|
||||||
|
import type { SessionShare } from '@/types'
|
||||||
|
|
||||||
|
function formatRelativeTime(dateString: string): string {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = now.getTime() - date.getTime()
|
||||||
|
const diffMinutes = Math.floor(diffMs / 60000)
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000)
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000)
|
||||||
|
|
||||||
|
if (diffMinutes < 1) return 'just now'
|
||||||
|
if (diffHours < 1) return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`
|
||||||
|
if (diffDays < 1) return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`
|
||||||
|
if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`
|
||||||
|
const diffMonths = Math.floor(diffDays / 30)
|
||||||
|
return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatExpiration(expiresAt: string | null): { text: string; isExpired: boolean } {
|
||||||
|
if (!expiresAt) return { text: 'No expiration', isExpired: false }
|
||||||
|
|
||||||
|
const expiry = new Date(expiresAt)
|
||||||
|
const now = new Date()
|
||||||
|
const diffMs = expiry.getTime() - now.getTime()
|
||||||
|
|
||||||
|
if (diffMs <= 0) return { text: 'Expired', isExpired: true }
|
||||||
|
|
||||||
|
const diffMinutes = Math.floor(diffMs / 60000)
|
||||||
|
const diffHours = Math.floor(diffMs / 3600000)
|
||||||
|
const diffDays = Math.floor(diffMs / 86400000)
|
||||||
|
|
||||||
|
if (diffHours < 1) return { text: `Expires in ${diffMinutes} minute${diffMinutes === 1 ? '' : 's'}`, isExpired: false }
|
||||||
|
if (diffDays < 1) return { text: `Expires in ${diffHours} hour${diffHours === 1 ? '' : 's'}`, isExpired: false }
|
||||||
|
return { text: `Expires in ${diffDays} day${diffDays === 1 ? '' : 's'}`, isExpired: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MySharesPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [shares, setShares] = useState<SessionShare[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const fetchShares = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
const data = await sessionsApi.listMyShares()
|
||||||
|
setShares(data)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Failed to load shares')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchShares()
|
||||||
|
}, [fetchShares])
|
||||||
|
|
||||||
|
const handleCopyLink = async (share: SessionShare) => {
|
||||||
|
try {
|
||||||
|
const url = buildSessionShareUrl(share)
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
setCopiedId(share.id)
|
||||||
|
toast.success('Link copied')
|
||||||
|
setTimeout(() => setCopiedId(null), 2000)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy link')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRevoke = async (share: SessionShare) => {
|
||||||
|
const confirmed = window.confirm(
|
||||||
|
'Revoke this share link? Anyone with the link will no longer be able to access the session.'
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionsApi.revokeShare(share.id)
|
||||||
|
setShares((prev) => prev.filter((s) => s.id !== share.id))
|
||||||
|
toast.success('Share link revoked')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to revoke share link')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-32">
|
||||||
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-white/20 border-t-white" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||||
|
<div className="glass-card rounded-xl p-6 border border-red-400/20">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-red-400 text-sm mb-4">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchShares}
|
||||||
|
className="bg-white text-black hover:bg-white/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||||
|
{/* Back link */}
|
||||||
|
<Link
|
||||||
|
to="/sessions"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm text-white/40 hover:text-white/70 transition-colors mb-6"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="h-4 w-4" />
|
||||||
|
Back to sessions
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white">My Shared Sessions</h1>
|
||||||
|
<p className="text-white/40 mt-1">Manage your session share links</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{shares.length === 0 ? (
|
||||||
|
<div className="glass-card rounded-xl p-12 text-center">
|
||||||
|
<Link2 className="h-12 w-12 text-white/20 mx-auto mb-4" />
|
||||||
|
<h2 className="text-lg font-semibold text-white mb-2">No shared sessions</h2>
|
||||||
|
<p className="text-white/40 text-sm mb-6">
|
||||||
|
Share a session from the session detail page to create a link
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/sessions')}
|
||||||
|
className="bg-white text-black hover:bg-white/90 rounded-md px-4 py-2 text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Go to Sessions
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{shares.map((share) => {
|
||||||
|
const expiration = formatExpiration(share.expires_at)
|
||||||
|
const isCopied = copiedId === share.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={share.id} className="glass-card rounded-xl p-5">
|
||||||
|
{/* Top row: badge + name */}
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs rounded-full px-2 py-0.5 bg-white/10 text-white/60">
|
||||||
|
{share.visibility === 'public' ? (
|
||||||
|
<Globe className="h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<Users className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{share.visibility === 'public' ? 'Public' : 'Account Only'}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-white">
|
||||||
|
{share.share_name || 'Untitled share'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Session info */}
|
||||||
|
<p className="text-sm text-white/50 mb-2">
|
||||||
|
Session ID: {share.session_id.slice(0, 8)}...
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Meta row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-white/40 mb-4">
|
||||||
|
<span>Created {formatRelativeTime(share.created_at)}</span>
|
||||||
|
<span className="hidden sm:inline">·</span>
|
||||||
|
<span>
|
||||||
|
{share.view_count > 0
|
||||||
|
? `${share.view_count} view${share.view_count === 1 ? '' : 's'}`
|
||||||
|
: 'Not viewed yet'}
|
||||||
|
</span>
|
||||||
|
<span className="hidden sm:inline">·</span>
|
||||||
|
<span className={cn(expiration.isExpired && 'text-red-400')}>
|
||||||
|
{expiration.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopyLink(share)}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors',
|
||||||
|
isCopied
|
||||||
|
? 'bg-emerald-400/10 text-emerald-400'
|
||||||
|
: 'bg-white text-black hover:bg-white/90'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isCopied ? (
|
||||||
|
<Check className="h-3.5 w-3.5" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
{isCopied ? 'Copied' : 'Copy Link'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
to={`/sessions/${share.session_id}`}
|
||||||
|
className="inline-flex items-center gap-1.5 border border-white/10 text-white/60 hover:bg-white/10 rounded-md px-3 py-1.5 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
View Session
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => handleRevoke(share)}
|
||||||
|
className="inline-flex items-center gap-1.5 text-red-400 hover:text-red-300 hover:bg-red-400/10 rounded-md px-3 py-1.5 text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { Save, ArrowLeft, ListOrdered } from 'lucide-react'
|
import { Save, ArrowLeft, ListOrdered } from 'lucide-react'
|
||||||
import { treesApi } from '@/api/trees'
|
import { treesApi } from '@/api/trees'
|
||||||
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
|
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
|
||||||
import { IntakeFormBuilder } from '@/components/procedural-editor/IntakeFormBuilder'
|
import { IntakeFormBuilder } from '@/components/procedural-editor/IntakeFormBuilder'
|
||||||
import { StepList } from '@/components/procedural-editor/StepList'
|
import { StepList } from '@/components/procedural-editor/StepList'
|
||||||
|
import { TagInput } from '@/components/common/TagInput'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
|
|
||||||
export function ProceduralEditorPage() {
|
export function ProceduralEditorPage() {
|
||||||
@@ -33,8 +34,6 @@ export function ProceduralEditorPage() {
|
|||||||
getTreeForSave,
|
getTreeForSave,
|
||||||
} = useProceduralEditorStore()
|
} = useProceduralEditorStore()
|
||||||
|
|
||||||
const [tagInput, setTagInput] = useState('')
|
|
||||||
|
|
||||||
// Load tree or init new
|
// Load tree or init new
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isEditMode && id) {
|
if (isEditMode && id) {
|
||||||
@@ -95,18 +94,6 @@ export function ProceduralEditorPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleAddTag = () => {
|
|
||||||
const tag = tagInput.trim()
|
|
||||||
if (tag && !tags.includes(tag)) {
|
|
||||||
setTags([...tags, tag])
|
|
||||||
setTagInput('')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveTag = (tag: string) => {
|
|
||||||
setTags(tags.filter((t) => t !== tag))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[50vh] items-center justify-center">
|
<div className="flex min-h-[50vh] items-center justify-center">
|
||||||
@@ -187,34 +174,7 @@ export function ProceduralEditorPage() {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium text-white/60">Tags</label>
|
<label className="mb-1 block text-sm font-medium text-white/60">Tags</label>
|
||||||
<div className="flex items-center gap-2">
|
<TagInput tags={tags} onChange={setTags} />
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={tagInput}
|
|
||||||
onChange={(e) => setTagInput(e.target.value)}
|
|
||||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddTag() } }}
|
|
||||||
placeholder="Add tag..."
|
|
||||||
className="flex-1 rounded-lg border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{tags.length > 0 && (
|
|
||||||
<div className="mt-2 flex flex-wrap gap-1">
|
|
||||||
{tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveTag(tag)}
|
|
||||||
className="text-white/40 hover:text-white"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-end pb-1">
|
<div className="flex items-end pb-1">
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X } from 'lucide-react'
|
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X } from 'lucide-react'
|
||||||
import { treesApi } from '@/api/trees'
|
import { treesApi } from '@/api/trees'
|
||||||
import { sessionsApi } from '@/api/sessions'
|
import { sessionsApi } from '@/api/sessions'
|
||||||
@@ -21,6 +21,8 @@ interface StepState {
|
|||||||
export function ProceduralNavigationPage() {
|
export function ProceduralNavigationPage() {
|
||||||
const { id: treeId } = useParams<{ id: string }>()
|
const { id: treeId } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const locationState = location.state as { sessionId?: string } | undefined
|
||||||
|
|
||||||
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)
|
||||||
@@ -98,6 +100,12 @@ export function ProceduralNavigationPage() {
|
|||||||
}
|
}
|
||||||
setTree(treeData)
|
setTree(treeData)
|
||||||
|
|
||||||
|
// If resuming an existing session
|
||||||
|
if (locationState?.sessionId) {
|
||||||
|
await resumeSession(treeData, locationState.sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check if intake form exists
|
// Check if intake form exists
|
||||||
if (treeData.intake_form && treeData.intake_form.length > 0) {
|
if (treeData.intake_form && treeData.intake_form.length > 0) {
|
||||||
setShowIntakeForm(true)
|
setShowIntakeForm(true)
|
||||||
@@ -134,6 +142,42 @@ export function ProceduralNavigationPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resumeSession = async (treeData: Tree, sessionId: string) => {
|
||||||
|
try {
|
||||||
|
const sessionData = await sessionsApi.get(sessionId)
|
||||||
|
setSession(sessionData)
|
||||||
|
setSessionVariables(sessionData.session_variables || {})
|
||||||
|
setShowIntakeForm(false)
|
||||||
|
|
||||||
|
// Initialize step states from session decisions
|
||||||
|
const allSteps = getStepsFromTree(treeData)
|
||||||
|
const initialStates = new Map<string, StepState>()
|
||||||
|
for (const step of allSteps) {
|
||||||
|
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hydrate completed steps from decisions
|
||||||
|
for (const decision of sessionData.decisions || []) {
|
||||||
|
if (decision.answer === 'completed' && initialStates.has(decision.node_id)) {
|
||||||
|
initialStates.set(decision.node_id, {
|
||||||
|
notes: decision.notes || '',
|
||||||
|
verificationValue: decision.command_output || '',
|
||||||
|
completedAt: decision.exited_at || decision.timestamp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStepStates(initialStates)
|
||||||
|
|
||||||
|
// Set current step to first incomplete step
|
||||||
|
const pSteps = allSteps.filter((s) => s.type === 'procedure_step')
|
||||||
|
const firstIncomplete = pSteps.findIndex((s) => !initialStates.get(s.id)?.completedAt)
|
||||||
|
setCurrentStepIndex(firstIncomplete >= 0 ? firstIncomplete : pSteps.length - 1)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to resume session')
|
||||||
|
navigate('/my-trees')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getStepsFromTree = (t: Tree): ProceduralStep[] => {
|
const getStepsFromTree = (t: Tree): ProceduralStep[] => {
|
||||||
const structure = t.tree_structure as unknown as { steps?: ProceduralStep[] }
|
const structure = t.tree_structure as unknown as { steps?: ProceduralStep[] }
|
||||||
return structure.steps || []
|
return structure.steps || []
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { treesApi } from '@/api/trees'
|
|||||||
import { sessionsApi } from '@/api/sessions'
|
import { sessionsApi } from '@/api/sessions'
|
||||||
import type { TreeListItem } from '@/types'
|
import type { TreeListItem } from '@/types'
|
||||||
import type { Session } from '@/types/session'
|
import type { Session } from '@/types/session'
|
||||||
|
import { getTreeNavigatePath } from '@/lib/routing'
|
||||||
|
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
@@ -27,7 +28,7 @@ export function QuickStartPage() {
|
|||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const [showResults, setShowResults] = useState(false)
|
const [showResults, setShowResults] = useState(false)
|
||||||
const [activeSessions, setActiveSessions] = useState<Session[]>([])
|
const [activeSessions, setActiveSessions] = useState<Session[]>([])
|
||||||
const [recentTrees, setRecentTrees] = useState<{ tree_id: string; name: string; lastUsed: string }[]>([])
|
const [recentTrees, setRecentTrees] = useState<{ tree_id: string; name: string; lastUsed: string; tree_type?: string }[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const searchRef = useRef<HTMLDivElement>(null)
|
const searchRef = useRef<HTMLDivElement>(null)
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
@@ -44,7 +45,7 @@ export function QuickStartPage() {
|
|||||||
|
|
||||||
// Deduplicate recent sessions by tree_id, max 5
|
// Deduplicate recent sessions by tree_id, max 5
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const deduped: { tree_id: string; name: string; lastUsed: string }[] = []
|
const deduped: { tree_id: string; name: string; lastUsed: string; tree_type?: string }[] = []
|
||||||
for (const s of recent) {
|
for (const s of recent) {
|
||||||
if (!seen.has(s.tree_id) && deduped.length < 5) {
|
if (!seen.has(s.tree_id) && deduped.length < 5) {
|
||||||
seen.add(s.tree_id)
|
seen.add(s.tree_id)
|
||||||
@@ -52,6 +53,7 @@ export function QuickStartPage() {
|
|||||||
tree_id: s.tree_id,
|
tree_id: s.tree_id,
|
||||||
name: s.tree_snapshot?.name || 'Unnamed Tree',
|
name: s.tree_snapshot?.name || 'Unnamed Tree',
|
||||||
lastUsed: s.started_at,
|
lastUsed: s.started_at,
|
||||||
|
tree_type: s.tree_snapshot?.tree_type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,7 +166,7 @@ export function QuickStartPage() {
|
|||||||
{searchResults.map((tree) => (
|
{searchResults.map((tree) => (
|
||||||
<li key={tree.id}>
|
<li key={tree.id}>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/trees/${tree.id}/navigate`)}
|
onClick={() => navigate(getTreeNavigatePath(tree.id, tree.tree_type))}
|
||||||
className="w-full px-5 py-3.5 text-left transition-all hover:bg-white/[0.06]"
|
className="w-full px-5 py-3.5 text-left transition-all hover:bg-white/[0.06]"
|
||||||
>
|
>
|
||||||
<div className="text-sm font-medium text-white">
|
<div className="text-sm font-medium text-white">
|
||||||
@@ -210,7 +212,7 @@ export function QuickStartPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/trees/${activeSessions[0].tree_id}/navigate`, {
|
navigate(getTreeNavigatePath(activeSessions[0].tree_id, activeSessions[0].tree_snapshot?.tree_type), {
|
||||||
state: { sessionId: activeSessions[0].id },
|
state: { sessionId: activeSessions[0].id },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -234,7 +236,7 @@ export function QuickStartPage() {
|
|||||||
<button
|
<button
|
||||||
key={session.id}
|
key={session.id}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/trees/${session.tree_id}/navigate`, {
|
navigate(getTreeNavigatePath(session.tree_id, session.tree_snapshot?.tree_type), {
|
||||||
state: { sessionId: session.id },
|
state: { sessionId: session.id },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -282,7 +284,7 @@ export function QuickStartPage() {
|
|||||||
{recentTrees.map((tree) => (
|
{recentTrees.map((tree) => (
|
||||||
<button
|
<button
|
||||||
key={tree.tree_id}
|
key={tree.tree_id}
|
||||||
onClick={() => navigate(`/trees/${tree.tree_id}/navigate`)}
|
onClick={() => navigate(getTreeNavigatePath(tree.tree_id, tree.tree_type))}
|
||||||
className="glass-card hover:glass-card-hover rounded-2xl p-5 text-left transition-all hover:scale-[1.02] cursor-pointer"
|
className="glass-card hover:glass-card-hover rounded-2xl p-5 text-left transition-all hover:scale-[1.02] cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { Copy, Check, Eye, Save } from 'lucide-react'
|
import { Copy, Check, Eye, Save, Share2 } from 'lucide-react'
|
||||||
import { sessionsApi } from '@/api/sessions'
|
import { sessionsApi } from '@/api/sessions'
|
||||||
import { stepsApi } from '@/api/steps'
|
import { stepsApi } from '@/api/steps'
|
||||||
import { ExportPreviewModal } from '@/components/session/ExportPreviewModal'
|
import { ExportPreviewModal } from '@/components/session/ExportPreviewModal'
|
||||||
import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal'
|
import { SaveSessionAsTreeModal } from '@/components/session/SaveSessionAsTreeModal'
|
||||||
|
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
|
||||||
|
import { SessionTimeline } from '@/components/session/SessionTimeline'
|
||||||
import { StepRatingModal } from '@/components/session/StepRatingModal'
|
import { StepRatingModal } from '@/components/session/StepRatingModal'
|
||||||
|
import { ActionMenu } from '@/components/common/ActionMenu'
|
||||||
|
import type { MenuAction } from '@/components/common/ActionMenu'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types'
|
import type { Session, SessionExport, SaveAsTreeRequest, Step, RedactionSummary } from '@/types'
|
||||||
import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings'
|
import { hasRatedSession, markSessionRated } from '@/lib/sessionRatings'
|
||||||
@@ -30,7 +34,7 @@ export function SessionDetailPage() {
|
|||||||
const [showRatingModal, setShowRatingModal] = useState(false)
|
const [showRatingModal, setShowRatingModal] = useState(false)
|
||||||
const [isSavingRatings, setIsSavingRatings] = useState(false)
|
const [isSavingRatings, setIsSavingRatings] = useState(false)
|
||||||
const [librarySteps, setLibrarySteps] = useState<Step[]>([])
|
const [librarySteps, setLibrarySteps] = useState<Step[]>([])
|
||||||
const [copiedStepIndex, setCopiedStepIndex] = useState<number | null>(null)
|
const [showShareModal, setShowShareModal] = useState(false)
|
||||||
const [maxStepIndex, setMaxStepIndex] = useState<number | null>(null)
|
const [maxStepIndex, setMaxStepIndex] = useState<number | null>(null)
|
||||||
const [detailLevel, setDetailLevel] = useState<'standard' | 'full'>('standard')
|
const [detailLevel, setDetailLevel] = useState<'standard' | 'full'>('standard')
|
||||||
const [includeSummary, setIncludeSummary] = useState(false)
|
const [includeSummary, setIncludeSummary] = useState(false)
|
||||||
@@ -257,26 +261,6 @@ export function SessionDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopyStep = async (decision: Session['decisions'][number], index: number) => {
|
|
||||||
const lines: string[] = []
|
|
||||||
if (decision.question) lines.push(`Question: ${decision.question}`)
|
|
||||||
if (decision.answer) lines.push(`Answer: ${decision.answer}`)
|
|
||||||
if (decision.action_performed) lines.push(`Action: ${decision.action_performed}`)
|
|
||||||
if (decision.notes) lines.push(`Notes: ${decision.notes}`)
|
|
||||||
if (decision.command_output) lines.push(`Output:\n${decision.command_output}`)
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(lines.join('\n'))
|
|
||||||
setCopiedStepIndex(index)
|
|
||||||
setTimeout(() => setCopiedStepIndex(null), 2000)
|
|
||||||
} catch {
|
|
||||||
// Clipboard access denied
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateString: string) => {
|
|
||||||
return new Date(dateString).toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDuration = (durationSeconds: number | null | undefined) => {
|
const formatDuration = (durationSeconds: number | null | undefined) => {
|
||||||
if (durationSeconds == null || durationSeconds < 0) return null
|
if (durationSeconds == null || durationSeconds < 0) return null
|
||||||
if (durationSeconds < 60) return `${durationSeconds}s`
|
if (durationSeconds < 60) return `${durationSeconds}s`
|
||||||
@@ -381,20 +365,20 @@ export function SessionDetailPage() {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
{/* Save as Tree - Only for completed sessions */}
|
<ActionMenu
|
||||||
{session.completed_at && (
|
actions={[
|
||||||
<button
|
{
|
||||||
onClick={() => setShowSaveAsTreeModal(true)}
|
label: 'Share',
|
||||||
disabled={isSavingTree}
|
icon: Share2,
|
||||||
className={cn(
|
onClick: () => setShowShareModal(true),
|
||||||
'flex items-center gap-2 rounded-md border border-white/10 bg-transparent px-4 py-2 text-sm font-medium text-white/60',
|
},
|
||||||
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
...(session.completed_at ? [{
|
||||||
)}
|
label: 'Save as Tree',
|
||||||
>
|
icon: Save,
|
||||||
<Save className="h-4 w-4" />
|
onClick: () => setShowSaveAsTreeModal(true),
|
||||||
Save as Tree
|
}] as MenuAction[] : []),
|
||||||
</button>
|
]}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
{/* Copy for Ticket */}
|
{/* Copy for Ticket */}
|
||||||
<button
|
<button
|
||||||
@@ -482,151 +466,12 @@ export function SessionDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Timeline / Step Checklist */}
|
{/* Timeline / Step Checklist */}
|
||||||
<div className="mb-8">
|
<SessionTimeline
|
||||||
{(session.tree_snapshot as unknown as Record<string, unknown>).tree_type === 'procedural' ? (
|
decisions={session.decisions}
|
||||||
<>
|
treeType={(session.tree_snapshot as unknown as Record<string, unknown>).tree_type as string}
|
||||||
<h2 className="mb-4 text-lg font-semibold text-white">Procedure Steps</h2>
|
startedAt={session.started_at}
|
||||||
<div className="space-y-2">
|
completedAt={session.completed_at}
|
||||||
{session.decisions.map((decision, index) => {
|
/>
|
||||||
const isCompleted = decision.answer === 'completed'
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className={cn(
|
|
||||||
'glass-card rounded-xl p-4',
|
|
||||||
isCompleted && 'border-l-2 border-emerald-400/50'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<span className={cn(
|
|
||||||
'mt-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-medium',
|
|
||||||
isCompleted ? 'bg-emerald-400/10 text-emerald-400' : 'bg-white/10 text-white/50'
|
|
||||||
)}>
|
|
||||||
{isCompleted ? '\u2713' : index + 1}
|
|
||||||
</span>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="font-medium text-white">{decision.question || 'Step'}</p>
|
|
||||||
{decision.notes && (
|
|
||||||
<p className="mt-1.5 rounded bg-white/5 p-2 text-sm text-white/40">
|
|
||||||
Notes: {decision.notes}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{decision.command_output && (
|
|
||||||
<p className="mt-1 text-sm text-white/40">
|
|
||||||
Verification: {decision.command_output}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{decision.duration_seconds != null && (
|
|
||||||
<p className="mt-1 text-xs text-white/30">
|
|
||||||
Duration: {formatDuration(decision.duration_seconds)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopyStep(decision, index)}
|
|
||||||
title="Copy step to clipboard"
|
|
||||||
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
|
||||||
>
|
|
||||||
{copiedStepIndex === index ? (
|
|
||||||
<Check className="h-4 w-4 text-emerald-400" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{session.completed_at && (
|
|
||||||
<div className="flex items-center gap-3 pl-2 pt-2 text-sm">
|
|
||||||
<span className="h-3 w-3 rounded-full bg-emerald-500" />
|
|
||||||
<span className="text-emerald-400">
|
|
||||||
Procedure completed: {formatDate(session.completed_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<h2 className="mb-4 text-lg font-semibold text-white">Decision Timeline</h2>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3 text-sm">
|
|
||||||
<span className="h-3 w-3 rounded-full bg-white" />
|
|
||||||
<span className="text-white/40">
|
|
||||||
Session started: {formatDate(session.started_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{session.decisions.map((decision, index) => (
|
|
||||||
<div key={index} className="ml-1 border-l-2 border-white/[0.06] pl-6">
|
|
||||||
<div className="relative">
|
|
||||||
<span className="absolute -left-[1.625rem] top-1 h-2 w-2 rounded-full bg-white/20" />
|
|
||||||
<div className="glass-card rounded-xl p-4">
|
|
||||||
<div className="flex items-start justify-between gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
{decision.question && (
|
|
||||||
<p className="font-medium text-white">{decision.question}</p>
|
|
||||||
)}
|
|
||||||
{decision.answer && (
|
|
||||||
<p className="mt-1 text-sm text-white">Answer: {decision.answer}</p>
|
|
||||||
)}
|
|
||||||
{decision.action_performed && (
|
|
||||||
<p className="mt-1 text-sm text-white/40">
|
|
||||||
Action: {decision.action_performed}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{decision.notes && (
|
|
||||||
<p className="mt-2 rounded bg-white/5 p-2 text-sm text-white/40">
|
|
||||||
Notes: {decision.notes}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{decision.command_output && (
|
|
||||||
<div className="mt-2">
|
|
||||||
<p className="mb-1 text-xs font-medium text-white/50">Command Output</p>
|
|
||||||
<pre className="overflow-x-auto rounded bg-white/5 p-2 text-xs font-mono text-white/60 whitespace-pre-wrap">
|
|
||||||
{decision.command_output}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{decision.duration_seconds != null && (
|
|
||||||
<p className="mt-2 text-xs text-white/50">
|
|
||||||
Duration: {formatDuration(decision.duration_seconds)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="mt-2 text-xs text-white/40">
|
|
||||||
{formatDate(decision.timestamp)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopyStep(decision, index)}
|
|
||||||
title="Copy step to clipboard"
|
|
||||||
className="rounded p-1 text-white/30 hover:bg-white/10 hover:text-white"
|
|
||||||
>
|
|
||||||
{copiedStepIndex === index ? (
|
|
||||||
<Check className="h-4 w-4 text-emerald-400" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{session.completed_at && (
|
|
||||||
<div className="flex items-center gap-3 text-sm">
|
|
||||||
<span className="h-3 w-3 rounded-full bg-green-500" />
|
|
||||||
<span className="text-emerald-400">
|
|
||||||
Session completed: {formatDate(session.completed_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Export Preview Modal */}
|
{/* Export Preview Modal */}
|
||||||
<ExportPreviewModal
|
<ExportPreviewModal
|
||||||
@@ -660,6 +505,14 @@ export function SessionDetailPage() {
|
|||||||
librarySteps={librarySteps}
|
librarySteps={librarySteps}
|
||||||
isSaving={isSavingRatings}
|
isSaving={isSavingRatings}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Share Session Modal */}
|
||||||
|
<ShareSessionModal
|
||||||
|
sessionId={session.id}
|
||||||
|
sessionLabel={session.ticket_number || 'Session Details'}
|
||||||
|
isOpen={showShareModal}
|
||||||
|
onClose={() => setShowShareModal(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { SessionFilters } from '@/components/session/SessionFilters'
|
|||||||
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
|
import { getTreeNavigatePath } from '@/lib/routing'
|
||||||
|
|
||||||
export function SessionHistoryPage() {
|
export function SessionHistoryPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -284,7 +285,7 @@ export function SessionHistoryPage() {
|
|||||||
</button>
|
</button>
|
||||||
{!session.completed_at && (
|
{!session.completed_at && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/trees/${session.tree_id}/navigate`, { state: { sessionId: session.id } })}
|
onClick={() => navigate(getTreeNavigatePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md bg-white px-3 py-2 text-sm font-medium text-black',
|
'rounded-md bg-white px-3 py-2 text-sm font-medium text-black',
|
||||||
'hover:bg-white/90'
|
'hover:bg-white/90'
|
||||||
|
|||||||
263
frontend/src/pages/SharedSessionPage.tsx
Normal file
263
frontend/src/pages/SharedSessionPage.tsx
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom'
|
||||||
|
import { Globe, Users, ShieldAlert, FileX, Clock, Loader2 } from 'lucide-react'
|
||||||
|
import { isAxiosError } from 'axios'
|
||||||
|
import { sessionsApi } from '@/api/sessions'
|
||||||
|
import { BrandLogo } from '@/components/common/BrandLogo'
|
||||||
|
import { SessionTimeline } from '@/components/session/SessionTimeline'
|
||||||
|
import { SharedSessionTreePreview } from '@/components/session/SharedSessionTreePreview'
|
||||||
|
import type { SharedSessionView } from '@/types'
|
||||||
|
|
||||||
|
function formatDate(dateString: string) {
|
||||||
|
return new Date(dateString).toLocaleDateString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(startedAt: string, completedAt: string): string {
|
||||||
|
const start = new Date(startedAt).getTime()
|
||||||
|
const end = new Date(completedAt).getTime()
|
||||||
|
const totalSeconds = Math.floor((end - start) / 1000)
|
||||||
|
if (totalSeconds < 0) return '0s'
|
||||||
|
if (totalSeconds < 60) return `${totalSeconds}s`
|
||||||
|
const hours = Math.floor(totalSeconds / 3600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
if (hours > 0) {
|
||||||
|
return seconds > 0 ? `${hours}h ${minutes}m ${seconds}s` : `${hours}h ${minutes}m`
|
||||||
|
}
|
||||||
|
return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ErrorState = {
|
||||||
|
type: 'access_denied' | 'not_found' | 'expired' | 'generic'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function ErrorCard({ error }: { error: ErrorState }) {
|
||||||
|
const iconMap = {
|
||||||
|
access_denied: ShieldAlert,
|
||||||
|
not_found: FileX,
|
||||||
|
expired: Clock,
|
||||||
|
generic: FileX,
|
||||||
|
}
|
||||||
|
const titleMap = {
|
||||||
|
access_denied: 'Access Denied',
|
||||||
|
not_found: 'Not Found',
|
||||||
|
expired: 'Link Expired',
|
||||||
|
generic: 'Error',
|
||||||
|
}
|
||||||
|
|
||||||
|
const Icon = iconMap[error.type]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-black px-4">
|
||||||
|
<div className="glass-card w-full max-w-md rounded-2xl p-8 text-center">
|
||||||
|
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-white/5">
|
||||||
|
<Icon className="h-8 w-8 text-white/40" />
|
||||||
|
</div>
|
||||||
|
<h1 className="mb-2 text-xl font-semibold text-white">{titleMap[error.type]}</h1>
|
||||||
|
<p className="mb-6 text-sm text-white/50">{error.message}</p>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-block rounded-lg bg-white px-6 py-2.5 text-sm font-medium text-black hover:bg-white/90"
|
||||||
|
>
|
||||||
|
Go to ResolutionFlow
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SharedSessionPage() {
|
||||||
|
const { shareToken } = useParams<{ shareToken: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [data, setData] = useState<SharedSessionView | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<ErrorState | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shareToken) return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function fetchSharedSession() {
|
||||||
|
try {
|
||||||
|
const result = await sessionsApi.getSharedSession(shareToken!)
|
||||||
|
if (!cancelled) {
|
||||||
|
setData(result)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
if (isAxiosError(err)) {
|
||||||
|
const status = err.response?.status
|
||||||
|
if (status === 401) {
|
||||||
|
navigate('/login', {
|
||||||
|
state: { from: { pathname: `/share/${shareToken}` } },
|
||||||
|
replace: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (status === 403) {
|
||||||
|
setError({
|
||||||
|
type: 'access_denied',
|
||||||
|
message:
|
||||||
|
'This session is private to the account. You need to be a member of the account to view it.',
|
||||||
|
})
|
||||||
|
} else if (status === 404) {
|
||||||
|
setError({
|
||||||
|
type: 'not_found',
|
||||||
|
message: 'This share link was not found or has been revoked.',
|
||||||
|
})
|
||||||
|
} else if (status === 410) {
|
||||||
|
setError({
|
||||||
|
type: 'expired',
|
||||||
|
message: 'This share link has expired.',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setError({
|
||||||
|
type: 'generic',
|
||||||
|
message: 'Failed to load shared session. Please try again.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError({
|
||||||
|
type: 'generic',
|
||||||
|
message: 'Failed to load shared session. Please try again.',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchSharedSession()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [shareToken, navigate])
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-black">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-white/40" />
|
||||||
|
<p className="text-sm text-white/40">Loading shared session...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <ErrorCard error={error} />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-black">
|
||||||
|
{/* Minimal header */}
|
||||||
|
<header className="border-b border-white/[0.06] px-6 py-4">
|
||||||
|
<div className="mx-auto flex max-w-7xl items-center justify-between">
|
||||||
|
<Link to="/" className="flex items-center gap-2">
|
||||||
|
<BrandLogo size="sm" />
|
||||||
|
<span className="text-lg font-semibold text-white">ResolutionFlow</span>
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/60 hover:bg-white/10 hover:text-white"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<main className="container mx-auto max-w-7xl px-4 py-8">
|
||||||
|
{/* Metadata section */}
|
||||||
|
<div className="mb-8">
|
||||||
|
{data.share_name && (
|
||||||
|
<h1 className="mb-2 text-2xl font-bold text-white">{data.share_name}</h1>
|
||||||
|
)}
|
||||||
|
<p className="text-lg text-white/70">
|
||||||
|
<span className="text-white/40">Tree:</span> {data.tree_name}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{(data.ticket_number || data.client_name) && (
|
||||||
|
<p className="mt-1 text-sm text-white/50">
|
||||||
|
{data.ticket_number && (
|
||||||
|
<span>Ticket: #{data.ticket_number}</span>
|
||||||
|
)}
|
||||||
|
{data.ticket_number && data.client_name && (
|
||||||
|
<span className="mx-1.5">·</span>
|
||||||
|
)}
|
||||||
|
{data.client_name && (
|
||||||
|
<span>Client: {data.client_name}</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-white/40">
|
||||||
|
<span>Started: {formatDate(data.started_at)}</span>
|
||||||
|
{data.completed_at && (
|
||||||
|
<>
|
||||||
|
<span>Completed: {formatDate(data.completed_at)}</span>
|
||||||
|
<span>Duration: {formatDuration(data.started_at, data.completed_at)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{data.visibility === 'public' ? (
|
||||||
|
<>
|
||||||
|
<Globe className="h-3.5 w-3.5" />
|
||||||
|
Public
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Users className="h-3.5 w-3.5" />
|
||||||
|
Account
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Two-column layout */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
{/* Decision Timeline (2 cols) */}
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
<SessionTimeline
|
||||||
|
decisions={data.decisions}
|
||||||
|
treeType={data.tree_structure?.tree_type as string | undefined}
|
||||||
|
startedAt={data.started_at}
|
||||||
|
completedAt={data.completed_at}
|
||||||
|
showCopyButtons={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tree Preview (1 col) */}
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
<SharedSessionTreePreview
|
||||||
|
treeStructure={data.tree_structure}
|
||||||
|
pathTaken={data.path_taken}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="py-8 text-center text-sm text-white/30">
|
||||||
|
Powered by{' '}
|
||||||
|
<Link to="/" className="underline hover:text-white/50">
|
||||||
|
ResolutionFlow
|
||||||
|
</Link>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SharedSessionPage
|
||||||
@@ -15,6 +15,7 @@ import { TreeTableView } from '@/components/library/TreeTableView'
|
|||||||
import { ViewToggle } from '@/components/library/ViewToggle'
|
import { ViewToggle } from '@/components/library/ViewToggle'
|
||||||
import { SortDropdown } from '@/components/library/SortDropdown'
|
import { SortDropdown } from '@/components/library/SortDropdown'
|
||||||
import { cn, safeGetItem } from '@/lib/utils'
|
import { cn, safeGetItem } from '@/lib/utils'
|
||||||
|
import { getTreeNavigatePath } from '@/lib/routing'
|
||||||
import { usePermissions } from '@/hooks/usePermissions'
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
@@ -450,7 +451,7 @@ export function TreeLibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/trees/${s.tree_id}/navigate`, { state: { sessionId: s.id } })}
|
onClick={() => navigate(getTreeNavigatePath(s.tree_id, s.tree_snapshot?.tree_type), { state: { sessionId: s.id } })}
|
||||||
className="flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-sm font-medium text-black hover:bg-white/90"
|
className="flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-sm font-medium text-black hover:bg-white/90"
|
||||||
>
|
>
|
||||||
<Play className="h-3.5 w-3.5" />
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { treesApi } from '@/api/trees'
|
import { treesApi } from '@/api/trees'
|
||||||
import { sessionsApi } from '@/api/sessions'
|
import { sessionsApi } from '@/api/sessions'
|
||||||
@@ -10,9 +10,11 @@ import { cn, safeGetItem, safeSetItem } from '@/lib/utils'
|
|||||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||||
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
||||||
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session'
|
import { PostStepActionModal, ContinuationModal, ForkTreeModal, ScratchpadSidebar, SessionOutcomeModal } from '@/components/session'
|
||||||
import { Plus, CheckCircle, ArrowRight, Clock, Terminal, Clipboard, Check, Copy, HelpCircle } from 'lucide-react'
|
import { Plus, CheckCircle, ArrowRight, Clock, Terminal, Clipboard, Check, Copy, HelpCircle, Link2, ChevronDown, Settings } from 'lucide-react'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
import { Modal } from '@/components/common/Modal'
|
import { Modal } from '@/components/common/Modal'
|
||||||
|
import { ShareSessionModal } from '@/components/session/ShareSessionModal'
|
||||||
|
import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sessionShare'
|
||||||
|
|
||||||
interface LocationState {
|
interface LocationState {
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
@@ -48,6 +50,11 @@ export function TreeNavigationPage() {
|
|||||||
const [selectingOption, setSelectingOption] = useState<string | null>(null)
|
const [selectingOption, setSelectingOption] = useState<string | null>(null)
|
||||||
const [copiedForTicket, setCopiedForTicket] = useState(false)
|
const [copiedForTicket, setCopiedForTicket] = useState(false)
|
||||||
const [isCopyingForTicket, setIsCopyingForTicket] = useState(false)
|
const [isCopyingForTicket, setIsCopyingForTicket] = useState(false)
|
||||||
|
const [showSharePopover, setShowSharePopover] = useState(false)
|
||||||
|
const [showShareModal, setShowShareModal] = useState(false)
|
||||||
|
const [copiedShareLink, setCopiedShareLink] = useState(false)
|
||||||
|
const [isCopyingShareLink, setIsCopyingShareLink] = useState(false)
|
||||||
|
const sharePopoverRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const handleCopyCommand = (text: string) => {
|
const handleCopyCommand = (text: string) => {
|
||||||
navigator.clipboard.writeText(text)
|
navigator.clipboard.writeText(text)
|
||||||
@@ -78,6 +85,55 @@ export function TreeNavigationPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleCopyShareLink = async () => {
|
||||||
|
if (!session || isCopyingShareLink) return
|
||||||
|
setIsCopyingShareLink(true)
|
||||||
|
try {
|
||||||
|
const allShares = await sessionsApi.listMyShares()
|
||||||
|
const existingShare = getLatestActiveShareForSession(allShares, session.id)
|
||||||
|
let shareUrl: string
|
||||||
|
if (existingShare) {
|
||||||
|
shareUrl = buildSessionShareUrl(existingShare)
|
||||||
|
} else {
|
||||||
|
const newShare = await sessionsApi.createShare(session.id, { visibility: 'account' })
|
||||||
|
shareUrl = buildSessionShareUrl(newShare)
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(shareUrl)
|
||||||
|
setCopiedShareLink(true)
|
||||||
|
toast.success('Share link copied to clipboard')
|
||||||
|
setTimeout(() => setCopiedShareLink(false), 2000)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Copy share link failed:', err)
|
||||||
|
toast.error('Failed to copy share link')
|
||||||
|
} finally {
|
||||||
|
setIsCopyingShareLink(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close share popover on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSharePopover) return
|
||||||
|
const handleMouseDown = (e: MouseEvent) => {
|
||||||
|
if (sharePopoverRef.current && !sharePopoverRef.current.contains(e.target as Node)) {
|
||||||
|
setShowSharePopover(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleMouseDown)
|
||||||
|
return () => document.removeEventListener('mousedown', handleMouseDown)
|
||||||
|
}, [showSharePopover])
|
||||||
|
|
||||||
|
// Close share popover on Escape key
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSharePopover) return
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setShowSharePopover(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [showSharePopover])
|
||||||
|
|
||||||
// Session metadata (prefill from Repeat Last Session)
|
// Session metadata (prefill from Repeat Last Session)
|
||||||
const [ticketNumber, setTicketNumber] = useState<string>(locationState?.prefillTicketNumber || '')
|
const [ticketNumber, setTicketNumber] = useState<string>(locationState?.prefillTicketNumber || '')
|
||||||
const [clientName, setClientName] = useState<string>(locationState?.prefillClientName || '')
|
const [clientName, setClientName] = useState<string>(locationState?.prefillClientName || '')
|
||||||
@@ -205,6 +261,16 @@ export function TreeNavigationPage() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const treeData = await treesApi.get(treeId!)
|
const treeData = await treesApi.get(treeId!)
|
||||||
|
|
||||||
|
// Safety redirect: procedural trees should use the procedural navigator
|
||||||
|
if (treeData.tree_type === 'procedural') {
|
||||||
|
navigate(`/flows/${treeId}/navigate`, {
|
||||||
|
replace: true,
|
||||||
|
state: locationState,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setTree(treeData)
|
setTree(treeData)
|
||||||
|
|
||||||
// If resuming a session
|
// If resuming a session
|
||||||
@@ -576,18 +642,70 @@ export function TreeNavigationPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{/* Share Progress Popover */}
|
||||||
onClick={handleCopyForTicket}
|
<div className="relative" ref={sharePopoverRef}>
|
||||||
disabled={isCopyingForTicket}
|
<button
|
||||||
title="Copy progress notes for ticket"
|
onClick={() => setShowSharePopover(!showSharePopover)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60',
|
'flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-xs font-medium text-white/60',
|
||||||
'hover:bg-white/10 hover:text-white transition-colors disabled:opacity-50'
|
'hover:bg-white/10 hover:text-white transition-colors'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
Share Progress
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
{showSharePopover && (
|
||||||
|
<div className="absolute right-0 mt-1 z-50 glass-card rounded-lg p-1 min-w-[220px]">
|
||||||
|
{/* Copy Progress Summary */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleCopyForTicket()
|
||||||
|
setShowSharePopover(false)
|
||||||
|
}}
|
||||||
|
disabled={isCopyingForTicket}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-white/70 w-full text-left cursor-pointer transition-colors',
|
||||||
|
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{copiedForTicket ? <Check className="h-4 w-4 text-emerald-400" /> : <Clipboard className="h-4 w-4" />}
|
||||||
|
{copiedForTicket ? 'Copied!' : 'Copy Progress Summary'}
|
||||||
|
</button>
|
||||||
|
{/* Copy Share Link */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
handleCopyShareLink()
|
||||||
|
setShowSharePopover(false)
|
||||||
|
}}
|
||||||
|
disabled={isCopyingShareLink}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-white/70 w-full text-left cursor-pointer transition-colors',
|
||||||
|
'hover:bg-white/10 hover:text-white disabled:opacity-50'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{copiedShareLink ? <Check className="h-4 w-4 text-emerald-400" /> : <Link2 className="h-4 w-4" />}
|
||||||
|
{isCopyingShareLink ? 'Loading...' : copiedShareLink ? 'Copied!' : 'Copy Share Link'}
|
||||||
|
</button>
|
||||||
|
{/* Divider */}
|
||||||
|
<div className="border-t border-white/[0.06] my-1" />
|
||||||
|
{/* Manage Share Links */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowSharePopover(false)
|
||||||
|
setShowShareModal(true)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-md px-3 py-2 text-sm text-white/70 w-full text-left cursor-pointer transition-colors',
|
||||||
|
'hover:bg-white/10 hover:text-white'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Settings className="h-4 w-4" />
|
||||||
|
Manage Share Links...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
{copiedForTicket ? <Check className="h-3.5 w-3.5 text-emerald-400" /> : <Copy className="h-3.5 w-3.5" />}
|
|
||||||
{copiedForTicket ? 'Copied!' : 'Copy for Ticket'}
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/sessions')}
|
onClick={() => navigate('/sessions')}
|
||||||
className="rounded-md px-3 py-1.5 text-sm text-white/50 hover:bg-white/[0.06] hover:text-white"
|
className="rounded-md px-3 py-1.5 text-sm text-white/50 hover:bg-white/[0.06] hover:text-white"
|
||||||
@@ -1092,6 +1210,16 @@ export function TreeNavigationPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Share Session Modal */}
|
||||||
|
{session && (
|
||||||
|
<ShareSessionModal
|
||||||
|
sessionId={session.id}
|
||||||
|
sessionLabel={ticketNumber || tree?.name || 'Session'}
|
||||||
|
isOpen={showShareModal}
|
||||||
|
onClose={() => setShowShareModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import {
|
|||||||
RegisterPage,
|
RegisterPage,
|
||||||
} from '@/pages'
|
} from '@/pages'
|
||||||
|
|
||||||
|
// Public pages
|
||||||
|
const SharedSessionPage = lazy(() => import('@/pages/SharedSessionPage'))
|
||||||
|
|
||||||
// Standalone auth pages
|
// Standalone auth pages
|
||||||
const ChangePasswordPage = lazy(() => import('@/pages/ChangePasswordPage'))
|
const ChangePasswordPage = lazy(() => import('@/pages/ChangePasswordPage'))
|
||||||
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'))
|
const ForgotPasswordPage = lazy(() => import('@/pages/ForgotPasswordPage'))
|
||||||
@@ -23,6 +26,7 @@ const ProceduralEditorPage = lazy(() => import('@/pages/ProceduralEditorPage'))
|
|||||||
const ProceduralNavigationPage = lazy(() => import('@/pages/ProceduralNavigationPage'))
|
const ProceduralNavigationPage = lazy(() => import('@/pages/ProceduralNavigationPage'))
|
||||||
const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
|
const SessionHistoryPage = lazy(() => import('@/pages/SessionHistoryPage'))
|
||||||
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
|
const SessionDetailPage = lazy(() => import('@/pages/SessionDetailPage'))
|
||||||
|
const MySharesPage = lazy(() => import('@/pages/MySharesPage'))
|
||||||
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
|
const AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
|
||||||
// Admin pages
|
// Admin pages
|
||||||
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
|
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
|
||||||
@@ -69,6 +73,15 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
errorElement: <RouteError />,
|
errorElement: <RouteError />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/share/:shareToken',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<SharedSessionPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
errorElement: <RouteError />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/change-password',
|
path: '/change-password',
|
||||||
element: (
|
element: (
|
||||||
@@ -177,6 +190,14 @@ export const router = createBrowserRouter([
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'shares',
|
||||||
|
element: (
|
||||||
|
<Suspense fallback={<PageLoader />}>
|
||||||
|
<MySharesPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
// Admin routes
|
// Admin routes
|
||||||
{
|
{
|
||||||
path: 'admin',
|
path: 'admin',
|
||||||
|
|||||||
@@ -1,10 +1,29 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { temporal } from 'zundo'
|
import { temporal } from 'zundo'
|
||||||
import { immer } from 'zustand/middleware/immer'
|
import { immer } from 'zustand/middleware/immer'
|
||||||
import type { Tree, IntakeFormField, ProceduralStep, ProceduralTreeStructure, TreeType } from '@/types'
|
import type { Tree, IntakeFormField, IntakeFieldType, ProceduralStep, ProceduralTreeStructure, TreeType } from '@/types'
|
||||||
|
|
||||||
const generateId = () => crypto.randomUUID()
|
const generateId = () => crypto.randomUUID()
|
||||||
|
|
||||||
|
const FIELD_TYPE_PREFIX: Record<IntakeFieldType, string> = {
|
||||||
|
text: 'text',
|
||||||
|
textarea: 'textarea',
|
||||||
|
number: 'number',
|
||||||
|
ip_address: 'ip',
|
||||||
|
email: 'email',
|
||||||
|
url: 'url',
|
||||||
|
select: 'select',
|
||||||
|
multi_select: 'multiselect',
|
||||||
|
checkbox: 'checkbox',
|
||||||
|
password: 'password',
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateVariableName(fieldType: IntakeFieldType, existingFields: IntakeFormField[]): string {
|
||||||
|
const prefix = FIELD_TYPE_PREFIX[fieldType] || 'field'
|
||||||
|
const count = existingFields.filter((f) => f.field_type === fieldType).length
|
||||||
|
return `${prefix}_${count + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
function createDefaultStep(index: number): ProceduralStep {
|
function createDefaultStep(index: number): ProceduralStep {
|
||||||
return {
|
return {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
@@ -24,9 +43,17 @@ function createEndStep(): ProceduralStep {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultField(index: number): IntakeFormField {
|
function createSectionHeader(title: string): ProceduralStep {
|
||||||
return {
|
return {
|
||||||
variable_name: `field_${index + 1}`,
|
id: generateId(),
|
||||||
|
type: 'section_header',
|
||||||
|
title,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDefaultField(index: number, existingFields: IntakeFormField[]): IntakeFormField {
|
||||||
|
return {
|
||||||
|
variable_name: generateVariableName('text', existingFields),
|
||||||
label: `Field ${index + 1}`,
|
label: `Field ${index + 1}`,
|
||||||
field_type: 'text',
|
field_type: 'text',
|
||||||
required: true,
|
required: true,
|
||||||
@@ -70,6 +97,7 @@ interface ProceduralEditorState {
|
|||||||
|
|
||||||
// Actions - Steps
|
// Actions - Steps
|
||||||
addStep: (afterIndex?: number) => void
|
addStep: (afterIndex?: number) => void
|
||||||
|
addSectionHeader: (afterIndex?: number) => void
|
||||||
removeStep: (stepId: string) => void
|
removeStep: (stepId: string) => void
|
||||||
updateStep: (stepId: string, updates: Partial<ProceduralStep>) => void
|
updateStep: (stepId: string, updates: Partial<ProceduralStep>) => void
|
||||||
moveStep: (fromIndex: number, toIndex: number) => void
|
moveStep: (fromIndex: number, toIndex: number) => void
|
||||||
@@ -78,8 +106,8 @@ interface ProceduralEditorState {
|
|||||||
|
|
||||||
// Actions - Intake Form
|
// Actions - Intake Form
|
||||||
addField: () => void
|
addField: () => void
|
||||||
removeField: (variableName: string) => void
|
removeField: (index: number) => void
|
||||||
updateField: (variableName: string, updates: Partial<IntakeFormField>) => void
|
updateField: (index: number, updates: Partial<IntakeFormField>) => void
|
||||||
moveField: (fromIndex: number, toIndex: number) => void
|
moveField: (fromIndex: number, toIndex: number) => void
|
||||||
|
|
||||||
// Actions - Save
|
// Actions - Save
|
||||||
@@ -200,15 +228,31 @@ export const useProceduralEditorStore = create<ProceduralEditorState>()(
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addSectionHeader: (afterIndex) => {
|
||||||
|
set((state) => {
|
||||||
|
const endIndex = state.steps.findIndex((s) => s.type === 'procedure_end')
|
||||||
|
const insertAt = afterIndex !== undefined
|
||||||
|
? Math.min(afterIndex + 1, endIndex >= 0 ? endIndex : state.steps.length)
|
||||||
|
: (endIndex >= 0 ? endIndex : state.steps.length)
|
||||||
|
|
||||||
|
const newHeader = createSectionHeader('New Section')
|
||||||
|
state.steps.splice(insertAt, 0, newHeader)
|
||||||
|
state.expandedStepId = newHeader.id
|
||||||
|
state.isDirty = true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
removeStep: (stepId) => {
|
removeStep: (stepId) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const index = state.steps.findIndex((s) => s.id === stepId)
|
const index = state.steps.findIndex((s) => s.id === stepId)
|
||||||
if (index === -1) return
|
if (index === -1) return
|
||||||
// Don't remove the end step
|
// Don't remove the end step
|
||||||
if (state.steps[index].type === 'procedure_end') return
|
if (state.steps[index].type === 'procedure_end') return
|
||||||
// Don't remove if it's the only procedure_step
|
// Don't remove if it's the only procedure_step (section headers can always be removed)
|
||||||
const stepCount = state.steps.filter((s) => s.type === 'procedure_step').length
|
if (state.steps[index].type === 'procedure_step') {
|
||||||
if (stepCount <= 1) return
|
const stepCount = state.steps.filter((s) => s.type === 'procedure_step').length
|
||||||
|
if (stepCount <= 1) return
|
||||||
|
}
|
||||||
|
|
||||||
state.steps.splice(index, 1)
|
state.steps.splice(index, 1)
|
||||||
if (state.selectedStepId === stepId) state.selectedStepId = null
|
if (state.selectedStepId === stepId) state.selectedStepId = null
|
||||||
@@ -249,28 +293,31 @@ export const useProceduralEditorStore = create<ProceduralEditorState>()(
|
|||||||
// Intake Form
|
// Intake Form
|
||||||
addField: () => {
|
addField: () => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const newField = createDefaultField(state.intakeForm.length)
|
const newField = createDefaultField(state.intakeForm.length, state.intakeForm)
|
||||||
state.intakeForm.push(newField)
|
state.intakeForm.push(newField)
|
||||||
state.isDirty = true
|
state.isDirty = true
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
removeField: (variableName) => {
|
removeField: (index) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const index = state.intakeForm.findIndex((f) => f.variable_name === variableName)
|
if (index < 0 || index >= state.intakeForm.length) return
|
||||||
if (index !== -1) {
|
state.intakeForm.splice(index, 1)
|
||||||
state.intakeForm.splice(index, 1)
|
// Reorder display_order
|
||||||
// Reorder display_order
|
state.intakeForm.forEach((f, i) => { f.display_order = i + 1 })
|
||||||
state.intakeForm.forEach((f, i) => { f.display_order = i + 1 })
|
state.isDirty = true
|
||||||
state.isDirty = true
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
updateField: (variableName, updates) => {
|
updateField: (index, updates) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const field = state.intakeForm.find((f) => f.variable_name === variableName)
|
const field = state.intakeForm[index]
|
||||||
if (field) {
|
if (field) {
|
||||||
|
// If field_type changed, auto-generate a new variable name
|
||||||
|
if (updates.field_type && updates.field_type !== field.field_type) {
|
||||||
|
const otherFields = state.intakeForm.filter((_, i) => i !== index)
|
||||||
|
updates.variable_name = generateVariableName(updates.field_type, otherFields)
|
||||||
|
}
|
||||||
Object.assign(field, updates)
|
Object.assign(field, updates)
|
||||||
state.isDirty = true
|
state.isDirty = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface TreeSnapshot extends TreeStructure {
|
|||||||
description?: string
|
description?: string
|
||||||
category?: string
|
category?: string
|
||||||
version?: number
|
version?: number
|
||||||
|
tree_type?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
@@ -58,6 +59,7 @@ export interface Session {
|
|||||||
exported: boolean
|
exported: boolean
|
||||||
scratchpad: string
|
scratchpad: string
|
||||||
next_steps: string
|
next_steps: string
|
||||||
|
session_variables: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionCreate {
|
export interface SessionCreate {
|
||||||
@@ -125,3 +127,45 @@ export interface SaveAsTreeResponse {
|
|||||||
tree_name: string
|
tree_name: string
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Session Sharing
|
||||||
|
export type SessionShareVisibility = 'public' | 'account'
|
||||||
|
|
||||||
|
export interface SessionShareCreate {
|
||||||
|
visibility: SessionShareVisibility
|
||||||
|
share_name?: string
|
||||||
|
expires_at?: string // ISO datetime string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionShare {
|
||||||
|
id: string
|
||||||
|
session_id: string
|
||||||
|
account_id: string
|
||||||
|
share_token: string
|
||||||
|
share_name: string | null
|
||||||
|
visibility: SessionShareVisibility
|
||||||
|
created_by: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
expires_at: string | null
|
||||||
|
view_count: number
|
||||||
|
last_viewed_at: string | null
|
||||||
|
is_active: boolean
|
||||||
|
share_url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharedSessionView {
|
||||||
|
session_id: string
|
||||||
|
tree_name: string
|
||||||
|
tree_description: string | null
|
||||||
|
tree_structure: TreeStructure & Record<string, unknown>
|
||||||
|
path_taken: string[]
|
||||||
|
decisions: DecisionRecord[]
|
||||||
|
custom_steps: CustomStep[]
|
||||||
|
started_at: string
|
||||||
|
completed_at: string | null
|
||||||
|
ticket_number: string | null
|
||||||
|
client_name: string | null
|
||||||
|
share_name: string | null
|
||||||
|
visibility: SessionShareVisibility
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export interface TreeStructure {
|
|||||||
export type TreeType = 'troubleshooting' | 'procedural'
|
export type TreeType = 'troubleshooting' | 'procedural'
|
||||||
|
|
||||||
export type IntakeFieldType =
|
export type IntakeFieldType =
|
||||||
| 'text' | 'textarea' | 'number' | 'ip_address' | 'email'
|
| 'text' | 'textarea' | 'number' | 'ip_address' | 'email' | 'url'
|
||||||
| 'select' | 'multi_select' | 'checkbox' | 'password'
|
| 'select' | 'multi_select' | 'checkbox' | 'password'
|
||||||
|
|
||||||
export type StepContentType = 'action' | 'informational' | 'verification' | 'warning'
|
export type StepContentType = 'action' | 'informational' | 'verification' | 'warning'
|
||||||
@@ -105,7 +105,7 @@ export interface StepVerification {
|
|||||||
|
|
||||||
export interface ProceduralStep {
|
export interface ProceduralStep {
|
||||||
id: string
|
id: string
|
||||||
type: 'procedure_step' | 'procedure_end'
|
type: 'procedure_step' | 'procedure_end' | 'section_header'
|
||||||
title: string
|
title: string
|
||||||
description?: string
|
description?: string
|
||||||
content_type?: StepContentType
|
content_type?: StepContentType
|
||||||
|
|||||||
Reference in New Issue
Block a user