feat: ConnectWise PSA integration (#106)

PSA abstraction layer with provider pattern, ConnectWise integration (connection management, ticket linking, note posting, status updates, member mapping), Integrations page UI, Fernet credential encryption, in-memory TTL cache, 6 DB migrations, ConnectWise API reference docs.
This commit was merged in pull request #106.
This commit is contained in:
chihlasm
2026-03-15 01:45:35 -04:00
committed by GitHub
parent 80e094215f
commit 46865882c6
60 changed files with 726716 additions and 11 deletions

View File

@@ -22,6 +22,11 @@ import { buildSessionShareUrl, getLatestActiveShareForSession } from '@/lib/sess
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
import { Button } from '@/components/ui/Button'
import { integrationsApi, sessionPsaApi } from '@/api/integrations'
import { TicketPickerModal } from '@/components/session/TicketPickerModal'
import { TicketLinkIndicator } from '@/components/session/TicketLinkIndicator'
import { UpdateTicketModal } from '@/components/session/UpdateTicketModal'
import type { PSATicketInfo } from '@/types/integrations'
interface LocationState {
sessionId?: string
@@ -65,6 +70,12 @@ export function TreeNavigationPage() {
const sharePopoverRef = useRef<HTMLDivElement>(null)
const [copilotOpen, setCopilotOpen] = useState(false)
// PSA ticket link state
const [hasConnection, setHasConnection] = useState(false)
const [showTicketPicker, setShowTicketPicker] = useState(false)
const [showUpdateModal, setShowUpdateModal] = useState(false)
const [ticketInfo, setTicketInfo] = useState<PSATicketInfo | null>(null)
const handleCopyCommand = (text: string) => {
navigator.clipboard.writeText(text)
setCopiedCommand(text)
@@ -272,6 +283,32 @@ export function TreeNavigationPage() {
}
}, [treeId])
// Check for PSA connection on mount
useEffect(() => {
integrationsApi.getConnection()
.then((conn) => setHasConnection(!!conn))
.catch(() => setHasConnection(false))
}, [])
const handleTicketLinked = (linkedTicketId: string, ticket: PSATicketInfo) => {
setTicketInfo(ticket)
setSession((prev) => prev ? { ...prev, psa_ticket_id: linkedTicketId } : prev)
setShowTicketPicker(false)
toast.success(`Linked to CW #${linkedTicketId}`)
}
const handleTicketUnlink = async () => {
if (!session) return
try {
await sessionPsaApi.linkTicket(session.id, null)
setSession((prev) => prev ? { ...prev, psa_ticket_id: null } : prev)
setTicketInfo(null)
toast.success('Ticket unlinked')
} catch {
toast.error('Failed to unlink ticket')
}
}
const loadTreeAndSession = async () => {
setIsLoading(true)
setError(null)
@@ -656,6 +693,16 @@ export function TreeNavigationPage() {
{clientName && `Client: ${clientName}`}
</p>
)}
{session && (
<TicketLinkIndicator
session={session}
hasConnection={hasConnection}
onLinkClick={() => setShowTicketPicker(true)}
onUnlink={handleTicketUnlink}
onUpdateClick={session.psa_ticket_id ? () => setShowUpdateModal(true) : undefined}
ticketInfo={ticketInfo}
/>
)}
</div>
<div className="flex items-center gap-2">
{/* Share Progress Popover */}
@@ -1251,6 +1298,23 @@ export function TreeNavigationPage() {
onClose={() => setShowShareModal(false)}
/>
)}
{/* Ticket Picker Modal */}
{session && (
<TicketPickerModal
open={showTicketPicker}
onClose={() => setShowTicketPicker(false)}
sessionId={session.id}
onLinked={handleTicketLinked}
/>
)}
{session && (
<UpdateTicketModal
open={showUpdateModal}
onClose={() => setShowUpdateModal(false)}
sessionId={session.id}
/>
)}
</div>
</div>