feat: add ResolutionOutputPanel with three-tab view, edit, and push
Three tabs: PSA Notes (FileText), KB Article (BookOpen), Client Summary (MessageSquare). Content area shows generated or edited text in monospace. Edit mode swaps in a textarea. Action bar: Edit, Copy to clipboard (with checkmark feedback), Push to PSA (disabled when already pushed). Uses resolutionsApi directly without a custom hook. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
239
frontend/src/components/session/ResolutionOutputPanel.tsx
Normal file
239
frontend/src/components/session/ResolutionOutputPanel.tsx
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { FileText, BookOpen, MessageSquare, Pencil, Copy, Send, Check } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { toast } from '@/lib/toast'
|
||||||
|
import { resolutionsApi } from '@/api/resolutions'
|
||||||
|
import type {
|
||||||
|
ResolutionOutputResponse,
|
||||||
|
ResolutionOutputType,
|
||||||
|
} from '@/types/branching'
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
type: ResolutionOutputType
|
||||||
|
label: string
|
||||||
|
icon: React.ElementType
|
||||||
|
}
|
||||||
|
|
||||||
|
const TABS: Tab[] = [
|
||||||
|
{ type: 'psa_ticket_notes', label: 'PSA Notes', icon: FileText },
|
||||||
|
{ type: 'knowledge_base', label: 'KB Article', icon: BookOpen },
|
||||||
|
{ type: 'client_summary', label: 'Client Summary', icon: MessageSquare },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface ResolutionOutputPanelProps {
|
||||||
|
sessionId: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResolutionOutputPanel({ sessionId, className }: ResolutionOutputPanelProps) {
|
||||||
|
const [outputs, setOutputs] = useState<ResolutionOutputResponse[]>([])
|
||||||
|
const [activeType, setActiveType] = useState<ResolutionOutputType>('psa_ticket_notes')
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [isEditing, setIsEditing] = useState(false)
|
||||||
|
const [editValue, setEditValue] = useState('')
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [isPushing, setIsPushing] = useState(false)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
|
const loadOutputs = useCallback(async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const result = await resolutionsApi.getOutputs(sessionId)
|
||||||
|
setOutputs(result.outputs)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to load resolution outputs')
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [sessionId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadOutputs()
|
||||||
|
}, [loadOutputs])
|
||||||
|
|
||||||
|
const activeOutput = outputs.find(o => o.output_type === activeType) ?? null
|
||||||
|
const displayContent = activeOutput?.edited_content ?? activeOutput?.generated_content ?? ''
|
||||||
|
|
||||||
|
function handleTabChange(type: ResolutionOutputType) {
|
||||||
|
setActiveType(type)
|
||||||
|
setIsEditing(false)
|
||||||
|
setEditValue('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditToggle() {
|
||||||
|
if (!isEditing) {
|
||||||
|
setEditValue(displayContent)
|
||||||
|
setIsEditing(true)
|
||||||
|
} else {
|
||||||
|
setIsEditing(false)
|
||||||
|
setEditValue('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSaveEdit() {
|
||||||
|
if (!activeOutput) return
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
const updated = await resolutionsApi.editOutput(sessionId, activeOutput.id, {
|
||||||
|
edited_content: editValue,
|
||||||
|
})
|
||||||
|
setOutputs(prev => prev.map(o => (o.id === updated.id ? updated : o)))
|
||||||
|
setIsEditing(false)
|
||||||
|
setEditValue('')
|
||||||
|
toast.success('Output saved')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save edit')
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(displayContent)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to copy to clipboard')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePushToPsa() {
|
||||||
|
if (!activeOutput) return
|
||||||
|
setIsPushing(true)
|
||||||
|
try {
|
||||||
|
const updated = await resolutionsApi.pushOutput(sessionId, activeOutput.id, {
|
||||||
|
destination: 'psa',
|
||||||
|
})
|
||||||
|
setOutputs(prev => prev.map(o => (o.id === updated.id ? updated : o)))
|
||||||
|
toast.success('Pushed to PSA')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to push to PSA')
|
||||||
|
} finally {
|
||||||
|
setIsPushing(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col bg-card border border-default rounded-lg overflow-hidden', className)}>
|
||||||
|
{/* Tab bar */}
|
||||||
|
<div className="flex border-b border-default shrink-0">
|
||||||
|
{TABS.map(tab => {
|
||||||
|
const Icon = tab.icon
|
||||||
|
const isActive = tab.type === activeType
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleTabChange(tab.type)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors border-b-2 -mb-px',
|
||||||
|
isActive
|
||||||
|
? 'border-accent text-accent-text'
|
||||||
|
: 'border-transparent text-secondary hover:text-primary hover:border-hover'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon size={13} />
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-auto p-3 min-h-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center h-24">
|
||||||
|
<span className="text-xs text-muted">Loading…</span>
|
||||||
|
</div>
|
||||||
|
) : !activeOutput ? (
|
||||||
|
<div className="flex items-center justify-center h-24">
|
||||||
|
<span className="text-xs text-muted">No output generated yet.</span>
|
||||||
|
</div>
|
||||||
|
) : isEditing ? (
|
||||||
|
<textarea
|
||||||
|
value={editValue}
|
||||||
|
onChange={e => setEditValue(e.target.value)}
|
||||||
|
className={cn(
|
||||||
|
'w-full h-full min-h-[160px] resize-none rounded-[5px] border border-default bg-input',
|
||||||
|
'px-3 py-2 text-sm text-primary font-mono leading-relaxed',
|
||||||
|
'focus:outline-none focus:border-accent focus:shadow-[0_0_0_2px_var(--color-accent-dim)]',
|
||||||
|
'transition-colors'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<pre className="text-sm text-primary font-mono whitespace-pre-wrap leading-relaxed">
|
||||||
|
{displayContent}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action bar */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5 border-t border-default shrink-0">
|
||||||
|
{isEditing ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveEdit}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="rounded-[5px] bg-accent px-3 py-1.5 text-xs font-medium text-white hover:bg-[#ea580c] transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSaving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleEditToggle}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="rounded-[5px] border border-default px-3 py-1.5 text-xs text-secondary hover:bg-elevated hover:text-primary transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleEditToggle}
|
||||||
|
disabled={!activeOutput}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-[5px] border border-default px-3 py-1.5 text-xs text-secondary',
|
||||||
|
'hover:bg-elevated hover:text-primary transition-colors disabled:opacity-40'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Pencil size={12} />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopy}
|
||||||
|
disabled={!activeOutput}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-[5px] border border-default px-3 py-1.5 text-xs transition-colors disabled:opacity-40',
|
||||||
|
copied
|
||||||
|
? 'border-[#34d399] text-[#34d399]'
|
||||||
|
: 'text-secondary hover:bg-elevated hover:text-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{copied ? <Check size={12} /> : <Copy size={12} />}
|
||||||
|
{copied ? 'Copied' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handlePushToPsa}
|
||||||
|
disabled={!activeOutput || isPushing || activeOutput.status === 'pushed'}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-[5px] border border-default px-3 py-1.5 text-xs transition-colors disabled:opacity-40',
|
||||||
|
activeOutput?.status === 'pushed'
|
||||||
|
? 'border-[#34d399] text-[#34d399]'
|
||||||
|
: 'text-secondary hover:bg-elevated hover:text-primary'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Send size={12} />
|
||||||
|
{isPushing ? 'Pushing…' : activeOutput?.status === 'pushed' ? 'Pushed' : 'Push to PSA'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user