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:
@@ -24,6 +24,11 @@ import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
|
||||
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
|
||||
import { CopilotPanel } from '@/components/copilot/CopilotPanel'
|
||||
import { CopilotToggle } from '@/components/copilot/CopilotToggle'
|
||||
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 StepState {
|
||||
notes: string
|
||||
@@ -86,6 +91,12 @@ export function ProceduralNavigationPage() {
|
||||
const [isSavingStep, setIsSavingStep] = useState(false)
|
||||
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 [psaTicketInfo, setPsaTicketInfo] = useState<PSATicketInfo | null>(null)
|
||||
|
||||
// Editable variables panel state
|
||||
const [editingVarName, setEditingVarName] = useState<string | null>(null)
|
||||
const [editingVarValue, setEditingVarValue] = useState('')
|
||||
@@ -131,6 +142,32 @@ export function ProceduralNavigationPage() {
|
||||
}
|
||||
}, [treeId])
|
||||
|
||||
// Check for PSA connection on mount
|
||||
useEffect(() => {
|
||||
integrationsApi.getConnection()
|
||||
.then((conn) => setHasConnection(!!conn))
|
||||
.catch(() => setHasConnection(false))
|
||||
}, [])
|
||||
|
||||
const handleTicketLinked = (linkedTicketId: string, ticket: PSATicketInfo) => {
|
||||
setPsaTicketInfo(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)
|
||||
setPsaTicketInfo(null)
|
||||
toast.success('Ticket unlinked')
|
||||
} catch {
|
||||
toast.error('Failed to unlink ticket')
|
||||
}
|
||||
}
|
||||
|
||||
// Parse backend timestamp — ensure UTC if no timezone info
|
||||
const parseTimestamp = (ts: string) => {
|
||||
if (!ts.endsWith('Z') && !ts.includes('+') && !/\d{2}:\d{2}$/.test(ts.slice(-5))) {
|
||||
@@ -584,6 +621,18 @@ export function ProceduralNavigationPage() {
|
||||
Exit
|
||||
</button>
|
||||
</div>
|
||||
{session && (
|
||||
<div className="mt-1.5">
|
||||
<TicketLinkIndicator
|
||||
session={session}
|
||||
hasConnection={hasConnection}
|
||||
onLinkClick={() => setShowTicketPicker(true)}
|
||||
onUnlink={handleTicketUnlink}
|
||||
onUpdateClick={session.psa_ticket_id ? () => setShowUpdateModal(true) : undefined}
|
||||
ticketInfo={psaTicketInfo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<ProgressBar
|
||||
currentStep={completedStepIds.size}
|
||||
@@ -877,6 +926,23 @@ export function ProceduralNavigationPage() {
|
||||
{treeId && (
|
||||
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user