import { useEffect, useState } from 'react' import { Plug, CheckCircle2, AlertCircle, Loader2, Pencil, Trash2, Shield, History, Ticket, Users, Zap, Save, Bell } from 'lucide-react' import { NotificationSettings } from '@/components/account/NotificationSettings' import { analytics } from '@/lib/analytics' import { EmptyState } from '@/components/common/EmptyState' import { IntegrationIllustration } from '@/components/common/EmptyStateIllustrations' import { PageMeta } from '@/components/common/PageMeta' import { integrationsApi } from '@/api/integrations' import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types' import type { PsaMemberResponse, PsaMemberMappingResponse } from '@/types/integrations' import { toast } from '@/lib/toast' import { cn } from '@/lib/utils' import { Input } from '@/components/ui/Input' function formatRelativeTime(dateStr: string): string { const date = new Date(dateStr) const now = new Date() const diffMs = now.getTime() - date.getTime() const diffMins = Math.floor(diffMs / 60000) if (diffMins < 1) return 'Just now' if (diffMins < 60) return `${diffMins}m ago` const diffHours = Math.floor(diffMins / 60) if (diffHours < 24) return `${diffHours}h ago` const diffDays = Math.floor(diffHours / 24) if (diffDays < 30) return `${diffDays}d ago` return date.toLocaleDateString() } interface ConnectionForm { display_name: string site_url: string company_id: string public_key: string private_key: string } const emptyForm: ConnectionForm = { display_name: '', site_url: '', company_id: '', public_key: '', private_key: '', } type Tab = 'connection' | 'member-mapping' | 'post-history' | 'flowpilot-settings' | 'notifications' export function IntegrationsPage() { const [activeTab, setActiveTab] = useState('connection') const [connection, setConnection] = useState(null) const [isLoading, setIsLoading] = useState(true) const [error, setError] = useState(null) // Form state const [mode, setMode] = useState<'view' | 'setup' | 'edit'>('setup') const [form, setForm] = useState(emptyForm) const [isSaving, setIsSaving] = useState(false) const [formError, setFormError] = useState(null) // Test state const [isTesting, setIsTesting] = useState(false) const [testResult, setTestResult] = useState(null) // Delete state const [isDeleting, setIsDeleting] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) useEffect(() => { loadConnection() }, []) const loadConnection = async () => { setIsLoading(true) setError(null) try { const data = await integrationsApi.getConnection() setConnection(data) setMode(data ? 'view' : 'setup') } catch (err) { // 404 means no connection exists — that's fine const axiosErr = err as { response?: { status?: number } } if (axiosErr.response?.status === 404) { setConnection(null) setMode('setup') } else { setError('Failed to load integration settings') console.error(err) } } finally { setIsLoading(false) } } const handleCreate = async (e: React.FormEvent) => { e.preventDefault() setIsSaving(true) setFormError(null) try { const payload: PsaConnectionCreate = { provider: 'connectwise', ...form, } const created = await integrationsApi.createConnection(payload) setConnection(created) analytics.psaConnected({ provider: 'connectwise' }) setMode('view') setForm(emptyForm) } catch (err) { const axiosErr = err as { response?: { data?: { detail?: string } } } setFormError(axiosErr.response?.data?.detail || 'Failed to create connection. Please check your credentials and try again.') console.error(err) } finally { setIsSaving(false) } } const handleUpdate = async (e: React.FormEvent) => { e.preventDefault() if (!connection) return setIsSaving(true) setFormError(null) try { const update: PsaConnectionUpdate = {} if (form.display_name && form.display_name !== connection.display_name) update.display_name = form.display_name if (form.site_url && form.site_url !== connection.site_url) update.site_url = form.site_url if (form.company_id && form.company_id !== connection.company_id) update.company_id = form.company_id if (form.public_key) update.public_key = form.public_key if (form.private_key) update.private_key = form.private_key // client_id is server-side (settings.CW_CLIENT_ID), not per-account const updated = await integrationsApi.updateConnection(connection.id, update) setConnection(updated) setMode('view') setForm(emptyForm) } catch (err) { const axiosErr = err as { response?: { data?: { detail?: string } } } setFormError(axiosErr.response?.data?.detail || 'Failed to update connection.') console.error(err) } finally { setIsSaving(false) } } const handleTest = async () => { if (!connection) return setIsTesting(true) setTestResult(null) try { const result = await integrationsApi.testConnection(connection.id) setTestResult(result) } catch (err) { setTestResult({ success: false, message: 'Connection test failed. Check your credentials.', server_version: null }) console.error(err) } finally { setIsTesting(false) } } const handleDelete = async () => { if (!connection) return setIsDeleting(true) try { await integrationsApi.deleteConnection(connection.id) setConnection(null) setMode('setup') setForm(emptyForm) setShowDeleteConfirm(false) setTestResult(null) } catch (err) { console.error('Failed to delete connection:', err) } finally { setIsDeleting(false) } } const startEdit = () => { if (!connection) return setForm({ display_name: connection.display_name, site_url: connection.site_url, company_id: connection.company_id, public_key: '', private_key: '', }) setFormError(null) setTestResult(null) setMode('edit') } const cancelEdit = () => { setMode('view') setForm(emptyForm) setFormError(null) } if (isLoading) { return ( <>
) } if (error) { return ( <>
{error}
) } return ( <>

Integrations

Connect your PSA to post session documentation directly to tickets.

{/* Tabs */}
{([ { id: 'connection' as Tab, label: 'Connection', icon: Plug }, { id: 'member-mapping' as Tab, label: 'Member Mapping', icon: Users }, { id: 'post-history' as Tab, label: 'Post History', icon: History }, { id: 'flowpilot-settings' as Tab, label: 'FlowPilot', icon: Zap }, { id: 'notifications' as Tab, label: 'Notifications', icon: Bell }, ]).map(({ id, label, icon: Icon }) => ( ))}
{/* Connection Tab */} {activeTab === 'connection' && (
{/* PSA Provider Grid */}

Available PSA Integrations

{/* ConnectWise — active */}
ConnectWise PSA {connection ? ( Connected ) : ( Available )}

Manage, ticket linking, time entries

{/* Autotask — coming soon */}
Autotask PSA Coming Soon

Datto / Kaseya integration

{/* Halo PSA — coming soon */}
Halo PSA Coming Soon

HaloPSA / HaloITSM integration

{/* Illustrative empty state when no connection exists */} {mode === 'setup' && (
} title="Connect your PSA for seamless workflows" description="Link ConnectWise or other PSA tools to pull ticket context into sessions and push documentation back automatically." learnMoreLink="/guides/psa-setup" />
)} {/* Setup / Edit Form */} {(mode === 'setup' || mode === 'edit') && (

{mode === 'setup' ? 'Connect to ConnectWise PSA' : 'Edit Connection'}

setForm({ ...form, display_name: e.target.value })} placeholder="My ConnectWise Instance" required={mode === 'setup'} className="mt-1" />
setForm({ ...form, site_url: e.target.value })} placeholder="na.myconnectwise.net" required={mode === 'setup'} className="mt-1" />
setForm({ ...form, company_id: e.target.value })} placeholder="mycompany" required={mode === 'setup'} className="mt-1" />
setForm({ ...form, public_key: e.target.value })} placeholder={mode === 'edit' && connection ? `Current: ${connection.public_key_hint}` : 'Enter public key'} required={mode === 'setup'} className="mt-1" />
setForm({ ...form, private_key: e.target.value })} placeholder={mode === 'edit' && connection ? `Current: ${connection.private_key_hint}` : 'Enter private key'} required={mode === 'setup'} className="mt-1" />
{formError && (
{formError}
)}
{mode === 'edit' && ( )}
)} {/* Connected View */} {mode === 'view' && connection && (
{/* Status Card */}
ConnectWise

{connection.display_name}

{connection.is_active ? 'Connected' : 'Not validated'}

Site URL

{connection.site_url}

Company ID

{connection.company_id}

Public Key

{connection.public_key_hint}

Private Key

{connection.private_key_hint}

Last Validated

{connection.last_validated_at ? formatRelativeTime(connection.last_validated_at) : 'Never'}

{/* Test Result */} {testResult && (
{testResult.success ? ( ) : ( )} {testResult.message} {testResult.server_version && ( v{testResult.server_version} )}
)} {/* Action Buttons */}
{showDeleteConfirm ? (
Disconnect?
) : ( )}
)}
)} {/* Member Mapping Tab */} {activeTab === 'member-mapping' && ( )} {/* Post History Tab */} {activeTab === 'post-history' && (

Post History

View post history from individual sessions by clicking on linked tickets. When a session has a ConnectWise ticket linked, use the Update button to post session documentation and view previous posts.

)} {/* FlowPilot Settings Tab */} {activeTab === 'flowpilot-settings' && ( )} {/* Notifications Tab */} {activeTab === 'notifications' && ( )}
) } /* ─── Member Mapping Tab ─── */ function MemberMappingTab({ connection }: { connection: PsaConnectionResponse | null }) { const [cwMembers, setCwMembers] = useState([]) const [mappings, setMappings] = useState([]) const [localMappings, setLocalMappings] = useState>({}) const [isLoadingData, setIsLoadingData] = useState(false) const [isAutoMatching, setIsAutoMatching] = useState(false) const [isSavingMappings, setIsSavingMappings] = useState(false) const [isDirty, setIsDirty] = useState(false) const [hasLoaded, setHasLoaded] = useState(false) useEffect(() => { if (connection) { loadMappingData() } // eslint-disable-next-line react-hooks/exhaustive-deps }, [connection?.id]) const loadMappingData = async () => { setIsLoadingData(true) try { const [members, existingMappings] = await Promise.all([ integrationsApi.listMembers(), integrationsApi.getMemberMappings(), ]) setCwMembers(members) setMappings(existingMappings) // Build local mapping state from existing mappings const lookup: Record = {} for (const m of existingMappings) { lookup[m.user_id] = { external_member_id: m.external_member_id, external_member_name: m.external_member_name } } setLocalMappings(lookup) setIsDirty(false) setHasLoaded(true) } catch (err) { console.error('Failed to load mapping data:', err) toast.error('Failed to load member data') } finally { setIsLoadingData(false) } } const handleAutoMatch = async () => { setIsAutoMatching(true) try { const result = await integrationsApi.autoMatchMembers() toast.success(`Matched ${result.matched.length} user${result.matched.length !== 1 ? 's' : ''}${result.unmatched_users > 0 ? `, ${result.unmatched_users} remain unmapped` : ''}`) await loadMappingData() } catch (err) { console.error('Auto-match failed:', err) toast.error('Auto-match failed') } finally { setIsAutoMatching(false) } } const handleMemberChange = (userId: string, externalMemberId: string) => { setLocalMappings(prev => { const next = { ...prev } if (!externalMemberId) { delete next[userId] } else { const member = cwMembers.find(m => m.id === externalMemberId) next[userId] = { external_member_id: externalMemberId, external_member_name: member?.name || '', } } return next }) setIsDirty(true) } const handleSave = async () => { setIsSavingMappings(true) try { const payload = Object.entries(localMappings).map(([user_id, mapping]) => ({ user_id, external_member_id: mapping.external_member_id, external_member_name: mapping.external_member_name, })) await integrationsApi.saveMemberMappings(payload) toast.success('Member mappings saved') setIsDirty(false) // Reload to get fresh data with matched_by etc. await loadMappingData() } catch (err) { console.error('Failed to save mappings:', err) toast.error('Failed to save mappings') } finally { setIsSavingMappings(false) } } // Derive user list from mappings response (all account users are returned) const userRows = mappings.length > 0 ? mappings.map(m => ({ user_id: m.user_id, user_email: m.user_email, user_name: m.user_name, matched_by: m.matched_by })) : [] // Deduplicate: mappings may only contain mapped users, so we show what we have const uniqueUsers = hasLoaded ? userRows : [] if (!connection) { return (

Member Mapping

Set up a PSA connection first to map team members to ConnectWise members.

) } return (
{/* Header + Auto-Match */}

Member Mapping

Map your ResolutionFlow users to ConnectWise members so session posts are attributed correctly.

{/* Loading state */} {isLoadingData && (
)} {/* Mapping Table */} {hasLoaded && !isLoadingData && (
{uniqueUsers.length === 0 ? (
No users found. Use Auto-Match to discover and map users.
) : ( <> {/* Table header */}
User Email Mapped To Method
{/* Rows */} {uniqueUsers.map((user) => { const currentMapping = localMappings[user.user_id] return (
{user.user_name} {user.user_email} {currentMapping && !isDirty && user.matched_by ? ( {user.matched_by === 'auto_email' ? 'auto' : 'manual'} ) : currentMapping ? ( manual ) : ( )}
) })} )}
)} {/* Save button */} {isDirty && (
)}
) } /* ─── FlowPilot Settings Tab ─── */ function FlowPilotSettingsTab({ connection }: { connection: PsaConnectionResponse | null }) { const [settings, setSettings] = useState | null>(null) const [isLoading, setIsLoading] = useState(true) const [isSaving, setIsSaving] = useState(false) useEffect(() => { if (!connection) { setIsLoading(false) return } integrationsApi.getFlowpilotSettings(connection.id) .then(s => setSettings(s as unknown as Record)) .catch(() => toast.error('Failed to load FlowPilot settings')) .finally(() => setIsLoading(false)) }, [connection]) const updateSetting = async (key: string, value: unknown) => { if (!connection || !settings) return const updated = { ...settings, [key]: value } setSettings(updated) setIsSaving(true) try { await integrationsApi.updateFlowpilotSettings(connection.id, { [key]: value }) } catch { toast.error('Failed to save setting') // Revert setSettings(settings) } finally { setIsSaving(false) } } if (!connection) { return (

Connect your PSA first to configure FlowPilot settings.

) } if (isLoading) { return (
) } if (!settings) return null return (

FlowPilot Settings

Configure how FlowPilot integrates with your ConnectWise PSA when sessions are resolved or escalated.

{/* Auto-push documentation */} updateSetting('auto_push', v)} disabled={isSaving} /> {/* Auto-create time entry */} updateSetting('auto_time_entry', v)} disabled={isSaving} /> {/* Time rounding */} updateSetting('time_rounding', v)} disabled={isSaving} /> {/* Note visibility */} updateSetting('note_visibility', v)} disabled={isSaving} /> {/* Include diagnostic steps */} updateSetting('include_diagnostic_steps', v)} disabled={isSaving} /> {/* Prompt for status on resolution */} updateSetting('prompt_status_on_resolution', v)} disabled={isSaving} /> {/* Prompt for status on escalation */} updateSetting('prompt_status_on_escalation', v)} disabled={isSaving} />
) } /* ─── Setting Components ─── */ function SettingToggle({ label, description, checked, onChange, disabled, }: { label: string description: string checked: boolean onChange: (value: boolean) => void disabled?: boolean }) { return (

{label}

{description}

) } function SettingSelect({ label, description, value, options, onChange, disabled, }: { label: string description: string value: string options: { value: string; label: string }[] onChange: (value: string) => void disabled?: boolean }) { return (

{label}

{description}

) } export default IntegrationsPage