Merge main into feat/flowpilot-migration
Brings in PR #141 (PSA ticket management) so FlowPilot can ship on top of a unified main. Two manual conflict resolutions: 1. CLAUDE.md — kept the FlowPilot ai-handoff rewrite (`.ai/`-driven protocol). The pre-rewrite reference content (CW integration notes, lessons archive, env vars table) lives in `docs/connectwise/`, `docs/LESSONS-ARCHIVE.md`, and DEV-ENV.md by design. 2. frontend/src/pages/AssistantChatPage.tsx — both conflict regions were purely additive. Concatenated FlowPilot's Phase 2-9 state hooks (facts, activeFix, preview*, scriptPanelOpen, templatizeQueue) with PSA's spin-off ticket state (linkedTicket, showNewTicket, spinOffHint). Both modal mounts (TemplatizePrompt, ShortcutsHelpOverlay, NewTicketModal) kept. All setters wired by either branch are intact. Verification: - `tsc -b` clean across the merged tree. - Browser smoke-test (Session B fixture): Phase 9 ProposalBanner ("Run AI-drafted PowerShell to recover SSL VPN") renders alongside PSA's new Tickets sidebar icon. Console clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause } from 'lucide-react'
|
||||
import { Sparkles, Send, Loader2, MessageSquare, Paperclip, Terminal, X, RotateCcw, ImagePlus, ListChecks, FileText, CheckCircle2, ArrowUpRight, MoreHorizontal, Pause, Plus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { uploadsApi } from '@/api/uploads'
|
||||
import type { PendingUpload } from '@/types/upload'
|
||||
import type { ForkMetadata, ActionItem, QuestionItem } from '@/types/ai-session'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { aiSessionsApi } from '@/api/aiSessions'
|
||||
import { integrationsApi } from '@/api/integrations'
|
||||
import { useBranching } from '@/hooks/useBranching'
|
||||
import { analytics } from '@/lib/analytics'
|
||||
import { toast } from '@/lib/toast'
|
||||
@@ -43,8 +44,10 @@ import {
|
||||
} from '@/api/sessionSuggestedFixes'
|
||||
import { ConcludeSessionModal } from '@/components/assistant/ConcludeSessionModal'
|
||||
import { StatusUpdateModal } from '@/components/flowpilot/StatusUpdateModal'
|
||||
import { NewTicketModal } from '@/components/tickets/NewTicketModal'
|
||||
import type { ChatListItem, ConclusionOutcome } from '@/types/assistant-chat'
|
||||
import type { SuggestedFlow } from '@/types/copilot'
|
||||
import type { PSATicketInfo } from '@/types/integrations'
|
||||
|
||||
interface MessageWithMeta {
|
||||
role: 'user' | 'assistant'
|
||||
@@ -129,6 +132,11 @@ export default function AssistantChatPage() {
|
||||
// time; the user accepts, rejects, or toggles "don't ask again", and we
|
||||
// advance to the next pending draft.
|
||||
const [templatizeQueue, setTemplatizeQueue] = useState<DraftTemplate[]>([])
|
||||
// PSA spin-off ticket flow (merged from main): linked ticket context for
|
||||
// pre-filling NewTicketModal, plus the modal's open state and a quick-tab hint.
|
||||
const [linkedTicket, setLinkedTicket] = useState<PSATicketInfo | null>(null)
|
||||
const [showNewTicket, setShowNewTicket] = useState(false)
|
||||
const [spinOffHint, setSpinOffHint] = useState<string | undefined>(undefined)
|
||||
const [showOverflow, setShowOverflow] = useState(false)
|
||||
// Phase 7: keyboard-shortcut help overlay.
|
||||
const [shortcutsHelpOpen, setShortcutsHelpOpen] = useState(false)
|
||||
@@ -790,6 +798,16 @@ export default function AssistantChatPage() {
|
||||
if (currentChatRef.current !== chatId) return
|
||||
setActiveSessionStatus(detail.status)
|
||||
setActivePsaTicketId(detail.psa_ticket_id)
|
||||
if (detail.psa_ticket_id) {
|
||||
integrationsApi.getTicket(detail.psa_ticket_id)
|
||||
.then(ticket => {
|
||||
if (currentChatRef.current !== chatId) return
|
||||
setLinkedTicket(ticket)
|
||||
})
|
||||
.catch(() => {})
|
||||
} else {
|
||||
setLinkedTicket(null)
|
||||
}
|
||||
setMessages(
|
||||
(detail.conversation_messages || []).map(m => ({
|
||||
role: m.role as 'user' | 'assistant',
|
||||
@@ -945,9 +963,17 @@ export default function AssistantChatPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string }>) => {
|
||||
const handleTaskSubmit = async (responses: Array<{ type: string; state: string; value: string; text?: string; label?: string; command?: string | null }>) => {
|
||||
if (!activeChatId || loading) return
|
||||
|
||||
// Handle special action commands that open UI flows instead of sending to AI
|
||||
const spinOffAction = responses.find(r => r.type === 'action' && r.command === 'create_spin_off_ticket')
|
||||
if (spinOffAction) {
|
||||
setSpinOffHint(spinOffAction.label || spinOffAction.text)
|
||||
setShowNewTicket(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Format task responses into a structured message for the AI.
|
||||
// Pending tasks are included so the AI knows they weren't completed yet.
|
||||
const parts: string[] = []
|
||||
@@ -1300,6 +1326,14 @@ export default function AssistantChatPage() {
|
||||
|
||||
{/* Desktop actions — shown when session is active and has messages */}
|
||||
<div className="hidden sm:flex items-center gap-1.5">
|
||||
{activePsaTicketId && (
|
||||
<button
|
||||
onClick={() => { setSpinOffHint(undefined); setShowNewTicket(true) }}
|
||||
className="flex items-center gap-1 px-2 py-1 text-xs text-muted-foreground border border-default rounded-[5px] hover:border-hover hover:text-primary transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> New Ticket
|
||||
</button>
|
||||
)}
|
||||
{isActive && (
|
||||
<>
|
||||
<button
|
||||
@@ -1905,6 +1939,24 @@ export default function AssistantChatPage() {
|
||||
open={shortcutsHelpOpen}
|
||||
onClose={() => setShortcutsHelpOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Spin-off Ticket Modal (merged from main) */}
|
||||
{showNewTicket && (
|
||||
<NewTicketModal
|
||||
defaultTab={spinOffHint ? 'quick' : 'manual'}
|
||||
summaryHint={spinOffHint}
|
||||
initialValues={linkedTicket ? {
|
||||
company_id: linkedTicket.company_id,
|
||||
board_id: linkedTicket.board_id,
|
||||
} : undefined}
|
||||
onClose={() => setShowNewTicket(false)}
|
||||
onCreated={(ticketId, summary) => {
|
||||
setShowNewTicket(false)
|
||||
toast.success(`Ticket #${ticketId} created: ${summary}`)
|
||||
setActiveActions(prev => prev.filter(a => a.command !== 'create_spin_off_ticket'))
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
259
frontend/src/pages/TicketsPage.tsx
Normal file
259
frontend/src/pages/TicketsPage.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { Plus, Ticket, AlertTriangle } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
import { TicketFilterBar } from '@/components/tickets/TicketFilterBar'
|
||||
import { TicketListRow } from '@/components/tickets/TicketListRow'
|
||||
import { TicketDetailPanel } from '@/components/tickets/TicketDetailPanel'
|
||||
import { NewTicketModal } from '@/components/tickets/NewTicketModal'
|
||||
import { integrationsApi } from '@/api/integrations'
|
||||
import { ticketsApi } from '@/api/tickets'
|
||||
import type { PSATicketSearchResult, PSABoard, PSATicketStatusItem } from '@/types/integrations'
|
||||
import type { TicketFilters, PSAPriority } from '@/types/tickets'
|
||||
import { DEFAULT_TICKET_FILTERS } from '@/types/tickets'
|
||||
|
||||
const PAGE_SIZE = 25
|
||||
|
||||
function filtersFromParams(params: URLSearchParams): TicketFilters & { page: number } {
|
||||
const assigned = params.get('assigned') ?? 'all'
|
||||
return {
|
||||
...DEFAULT_TICKET_FILTERS,
|
||||
search: params.get('search') ?? '',
|
||||
board_id: params.get('board') ? Number(params.get('board')) : null,
|
||||
status_id: params.get('status') ? Number(params.get('status')) : null,
|
||||
priority: params.get('priority') ?? null,
|
||||
company_id: params.get('company') ? Number(params.get('company')) : null,
|
||||
assigned: (assigned === 'me' || assigned === 'unassigned' || assigned === 'all')
|
||||
? assigned
|
||||
: Number(assigned),
|
||||
include_closed: params.get('closed') === 'true',
|
||||
page: params.get('page') ? Number(params.get('page')) : 1,
|
||||
}
|
||||
}
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const { page, ...filters } = filtersFromParams(searchParams)
|
||||
|
||||
const [tickets, setTickets] = useState<PSATicketSearchResult[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [psaError, setPsaError] = useState<string | null>(null)
|
||||
const [boards, setBoards] = useState<PSABoard[]>([])
|
||||
const [statuses, setStatuses] = useState<PSATicketStatusItem[]>([])
|
||||
const [priorities, setPriorities] = useState<PSAPriority[]>([])
|
||||
const [members, setMembers] = useState<{ id: number; name: string }[]>([])
|
||||
const [selectedTicket, setSelectedTicket] = useState<PSATicketSearchResult | null>(null)
|
||||
const [showNewTicket, setShowNewTicket] = useState(false)
|
||||
|
||||
// Load filter option data once
|
||||
useEffect(() => {
|
||||
integrationsApi.listBoards().then(setBoards).catch(() => {})
|
||||
ticketsApi.listPriorities().then(setPriorities).catch(() => {})
|
||||
integrationsApi.listMembers()
|
||||
.then(ms => setMembers(ms.map(m => ({ id: Number(m.id), name: m.name }))))
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Load statuses when board changes. If no board is selected, aggregate statuses
|
||||
// across all boards (deduped by name) so the filter is useful before the user
|
||||
// picks a board.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (filters.board_id) {
|
||||
integrationsApi.getBoardStatuses(filters.board_id)
|
||||
.then(s => { if (!cancelled) setStatuses(s) })
|
||||
.catch(() => { if (!cancelled) setStatuses([]) })
|
||||
} else if (boards.length > 0) {
|
||||
Promise.all(boards.map(b =>
|
||||
integrationsApi.getBoardStatuses(b.id).catch(() => [] as PSATicketStatusItem[])
|
||||
))
|
||||
.then(lists => {
|
||||
if (cancelled) return
|
||||
const byName = new Map<string, PSATicketStatusItem>()
|
||||
lists.flat().forEach(s => {
|
||||
if (!byName.has(s.name)) byName.set(s.name, s)
|
||||
})
|
||||
setStatuses(Array.from(byName.values()).sort((a, b) => a.name.localeCompare(b.name)))
|
||||
})
|
||||
.catch(() => { if (!cancelled) setStatuses([]) })
|
||||
} else {
|
||||
setStatuses([])
|
||||
}
|
||||
return () => { cancelled = true }
|
||||
}, [filters.board_id, boards])
|
||||
|
||||
// Fetch tickets on filter/page change
|
||||
const fetchTickets = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setPsaError(null)
|
||||
try {
|
||||
// When no board is selected, statuses are aggregated across boards — filter by
|
||||
// name instead of id so we match the same status across every board.
|
||||
const selectedStatusName = filters.status_id
|
||||
? statuses.find(s => s.id === filters.status_id)?.name
|
||||
: undefined
|
||||
const result = await ticketsApi.searchTickets({
|
||||
query: filters.search || undefined,
|
||||
board_id: filters.board_id ?? undefined,
|
||||
status_id: filters.board_id && filters.status_id ? filters.status_id : undefined,
|
||||
status_name: !filters.board_id && selectedStatusName ? selectedStatusName : undefined,
|
||||
include_closed: filters.include_closed,
|
||||
assigned_to_me: filters.assigned === 'me',
|
||||
unassigned: filters.assigned === 'unassigned',
|
||||
priority: filters.priority ?? undefined,
|
||||
company_id: filters.company_id ?? undefined,
|
||||
page,
|
||||
page_size: PAGE_SIZE,
|
||||
})
|
||||
setTickets(result.items)
|
||||
setTotal(result.total)
|
||||
// If the boards API returned empty (CW permissions), derive available boards from ticket data
|
||||
setBoards(prev => {
|
||||
if (prev.length > 0) return prev
|
||||
const seen = new Map<number, string>()
|
||||
result.items.forEach(t => {
|
||||
if (t.board_id && t.board_name) seen.set(t.board_id, t.board_name)
|
||||
})
|
||||
return seen.size > 0 ? Array.from(seen, ([id, name]) => ({ id, name })) : prev
|
||||
})
|
||||
} catch (err: unknown) {
|
||||
setTickets([])
|
||||
setTotal(0)
|
||||
if (axios.isAxiosError(err)) {
|
||||
const status = err.response?.status
|
||||
const detail = (err.response?.data as { detail?: string })?.detail ?? ''
|
||||
if (status === 502 && detail.toLowerCase().includes('permission')) {
|
||||
setPsaError('ConnectWise returned a permissions error. Check that the API member\'s security role has Service Tickets → Inquire → ALL and System → Table Setup → Inquire → ALL.')
|
||||
} else if (status === 502) {
|
||||
setPsaError('ConnectWise is unavailable or returned an error. Check your integration settings.')
|
||||
} else {
|
||||
setPsaError('Failed to load tickets.')
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [filters.search, filters.board_id, filters.status_id, filters.include_closed,
|
||||
filters.assigned, filters.priority, filters.company_id, page, statuses])
|
||||
|
||||
useEffect(() => { fetchTickets() }, [fetchTickets])
|
||||
|
||||
function updateFilters(updated: Partial<TicketFilters>) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
if ('search' in updated) updated.search ? next.set('search', updated.search!) : next.delete('search')
|
||||
if ('board_id' in updated) updated.board_id ? next.set('board', String(updated.board_id)) : next.delete('board')
|
||||
if ('status_id' in updated) updated.status_id ? next.set('status', String(updated.status_id)) : next.delete('status')
|
||||
if ('priority' in updated) updated.priority ? next.set('priority', updated.priority!) : next.delete('priority')
|
||||
if ('company_id' in updated) updated.company_id ? next.set('company', String(updated.company_id)) : next.delete('company')
|
||||
if ('assigned' in updated) {
|
||||
const a = updated.assigned
|
||||
a === 'all' ? next.delete('assigned') : next.set('assigned', String(a))
|
||||
}
|
||||
if ('include_closed' in updated) updated.include_closed ? next.set('closed', 'true') : next.delete('closed')
|
||||
next.delete('page') // reset to 1 on filter change
|
||||
setSearchParams(next)
|
||||
}
|
||||
|
||||
function updatePage(p: number) {
|
||||
const next = new URLSearchParams(searchParams)
|
||||
p === 1 ? next.delete('page') : next.set('page', String(p))
|
||||
setSearchParams(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-default shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Ticket className="w-5 h-5 text-muted-foreground" />
|
||||
<h1 className="font-heading text-xl font-bold text-heading">Tickets</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewTicket(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-accent text-white text-sm font-medium rounded-[5px] hover:bg-accent/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> New Ticket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="px-6 py-3 border-b border-default shrink-0">
|
||||
<TicketFilterBar
|
||||
filters={filters}
|
||||
onChange={updateFilters}
|
||||
boards={boards}
|
||||
statuses={statuses}
|
||||
priorities={priorities}
|
||||
members={members}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={updatePage}
|
||||
loading={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* List + Detail Panel */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Ticket list */}
|
||||
<div className={`flex flex-col overflow-y-auto transition-all ${selectedTicket ? 'w-1/2' : 'w-full'}`}>
|
||||
{loading && tickets.length === 0 && (
|
||||
<div className="flex items-center justify-center py-16 text-muted-foreground text-sm">
|
||||
Loading tickets…
|
||||
</div>
|
||||
)}
|
||||
{!loading && psaError && (
|
||||
<div className="mx-6 mt-6 flex items-start gap-3 px-4 py-3 rounded-lg bg-danger-dim border border-danger/30 text-sm text-danger">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>{psaError}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !psaError && tickets.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground text-sm gap-2">
|
||||
<Ticket className="w-8 h-8 opacity-30" />
|
||||
No tickets match your filters
|
||||
</div>
|
||||
)}
|
||||
{tickets.map(t => (
|
||||
<TicketListRow
|
||||
key={t.id}
|
||||
ticket={t}
|
||||
selected={selectedTicket?.id === t.id}
|
||||
onClick={() => setSelectedTicket(t)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Detail panel */}
|
||||
{selectedTicket && (
|
||||
<div className="w-1/2 border-l border-default overflow-y-auto">
|
||||
<TicketDetailPanel
|
||||
ticket={selectedTicket}
|
||||
onClose={() => setSelectedTicket(null)}
|
||||
onStatusUpdated={(ticketId, newStatus, newStatusId) => {
|
||||
setTickets(prev => prev.map(t =>
|
||||
t.id === String(ticketId) ? { ...t, status_name: newStatus, status_id: newStatusId } : t
|
||||
))
|
||||
setSelectedTicket(prev =>
|
||||
prev && prev.id === String(ticketId)
|
||||
? { ...prev, status_name: newStatus, status_id: newStatusId }
|
||||
: prev
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* New Ticket Modal */}
|
||||
{showNewTicket && (
|
||||
<NewTicketModal
|
||||
defaultTab="quick"
|
||||
onClose={() => setShowNewTicket(false)}
|
||||
onCreated={() => { setShowNewTicket(false); fetchTickets() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user