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

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock } from 'lucide-react'
import { Building2, Users, Mail, Crown, Loader2, AlertCircle, Check, X, Settings, FolderTree, Server, RefreshCw, MessageSquareText, UserCog, AlertTriangle, Clock, Plug } from 'lucide-react'
import { PageMeta } from '@/components/common/PageMeta'
import { accountsApi } from '@/api/accounts'
import type { Account, AccountMember, AccountInvite } from '@/types'
@@ -555,6 +555,23 @@ export function AccountSettingsPage() {
</Link>
)}
{/* Integrations Link (owners only) */}
{isAccountOwner && (
<Link
to="/account/integrations"
className="bg-card border border-border rounded-xl p-4 sm:p-6 flex items-center justify-between group hover:border-border transition-all"
>
<div className="flex items-center gap-3">
<Plug className="h-5 w-5 text-muted-foreground" />
<div>
<h2 className="text-lg font-semibold text-foreground">Integrations</h2>
<p className="text-sm text-muted-foreground">Connect your PSA to sync session documentation to tickets</p>
</div>
</div>
<span className="text-muted-foreground group-hover:text-foreground transition-colors">&rarr;</span>
</Link>
)}
{/* Feedback Link (all users) */}
<Link
to="/feedback"

View File

@@ -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>
)
}

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>

View File

@@ -0,0 +1,799 @@
import { useEffect, useState } from 'react'
import { Plug, CheckCircle2, AlertCircle, Loader2, Pencil, Trash2, Shield, History, Ticket, Users, Zap, Save } from 'lucide-react'
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'
export function IntegrationsPage() {
const [activeTab, setActiveTab] = useState<Tab>('connection')
const [connection, setConnection] = useState<PsaConnectionResponse | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Form state
const [mode, setMode] = useState<'view' | 'setup' | 'edit'>('setup')
const [form, setForm] = useState<ConnectionForm>(emptyForm)
const [isSaving, setIsSaving] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
// Test state
const [isTesting, setIsTesting] = useState(false)
const [testResult, setTestResult] = useState<PsaConnectionTestResponse | null>(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)
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 (
<>
<PageMeta title="Integrations" />
<div className="flex justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</>
)
}
if (error) {
return (
<>
<PageMeta title="Integrations" />
<div className="rounded-md border border-red-400/20 bg-red-400/10 p-4 text-red-400">
<div className="flex items-center gap-2">
<AlertCircle className="h-5 w-5" />
{error}
</div>
</div>
</>
)
}
return (
<>
<PageMeta title="Integrations" />
<div>
<div className="mb-8">
<div className="flex items-center gap-3">
<Plug className="h-8 w-8 text-muted-foreground" />
<h1 className="text-2xl font-bold font-heading text-foreground sm:text-3xl">Integrations</h1>
</div>
<p className="mt-2 text-muted-foreground">
Connect your PSA to post session documentation directly to tickets.
</p>
</div>
{/* Tabs */}
<div className="mb-6 flex gap-1 border-b border-border">
{([
{ 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 },
]).map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => setActiveTab(id)}
className={cn(
'inline-flex items-center gap-2 border-b-2 px-4 py-2.5 text-sm font-medium transition-colors -mb-px',
activeTab === id
? 'border-primary text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
{/* Connection Tab */}
{activeTab === 'connection' && (
<div className="max-w-3xl">
{/* Setup / Edit Form */}
{(mode === 'setup' || mode === 'edit') && (
<div className="glass-card-static p-6">
<div className="flex items-center gap-2 mb-6">
<Shield className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">
{mode === 'setup' ? 'Connect to ConnectWise PSA' : 'Edit Connection'}
</h2>
</div>
<form onSubmit={mode === 'setup' ? handleCreate : handleUpdate} className="space-y-4">
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Display Name
</label>
<Input
type="text"
value={form.display_name}
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
placeholder="My ConnectWise Instance"
required={mode === 'setup'}
className="mt-1"
/>
</div>
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Site URL
</label>
<Input
type="text"
value={form.site_url}
onChange={(e) => setForm({ ...form, site_url: e.target.value })}
placeholder="na.myconnectwise.net"
required={mode === 'setup'}
className="mt-1"
/>
</div>
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Company ID
</label>
<Input
type="text"
value={form.company_id}
onChange={(e) => setForm({ ...form, company_id: e.target.value })}
placeholder="mycompany"
required={mode === 'setup'}
className="mt-1"
/>
</div>
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Public Key
</label>
<Input
type="text"
value={form.public_key}
onChange={(e) => 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"
/>
</div>
<div>
<label className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
Private Key
</label>
<Input
type="password"
value={form.private_key}
onChange={(e) => 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"
/>
</div>
{formError && (
<div className="flex items-center gap-2 rounded-md border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
<AlertCircle className="h-4 w-4 shrink-0" />
{formError}
</div>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="submit"
disabled={isSaving}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-5 py-2.5 text-sm font-semibold',
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20',
'hover:opacity-90 active:scale-[0.97] transition-all',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isSaving && <Loader2 className="h-4 w-4 animate-spin" />}
{mode === 'setup' ? 'Connect' : 'Save Changes'}
</button>
{mode === 'edit' && (
<button
type="button"
onClick={cancelEdit}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
)}
</div>
</form>
</div>
)}
{/* Connected View */}
{mode === 'view' && connection && (
<div className="space-y-4">
{/* Status Card */}
<div className="glass-card-static p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<span className="inline-flex items-center rounded-full bg-primary/10 px-2.5 py-0.5 text-xs font-label font-medium text-primary">
ConnectWise
</span>
<h2 className="text-lg font-semibold text-foreground">{connection.display_name}</h2>
</div>
<div className="flex items-center gap-2">
<span
className={cn(
'inline-flex h-2 w-2 rounded-full',
connection.is_active ? 'bg-emerald-400' : 'bg-amber-400'
)}
/>
<span className={cn(
'text-xs font-label',
connection.is_active ? 'text-emerald-400' : 'text-amber-400'
)}>
{connection.is_active ? 'Connected' : 'Not validated'}
</span>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Site URL</p>
<p className="mt-1 text-sm text-foreground">{connection.site_url}</p>
</div>
<div>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Company ID</p>
<p className="mt-1 text-sm text-foreground">{connection.company_id}</p>
</div>
<div>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Public Key</p>
<p className="mt-1 text-sm text-foreground font-mono">{connection.public_key_hint}</p>
</div>
<div>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Private Key</p>
<p className="mt-1 text-sm text-foreground font-mono">{connection.private_key_hint}</p>
</div>
<div>
<p className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Last Validated</p>
<p className="mt-1 text-sm text-foreground">
{connection.last_validated_at ? formatRelativeTime(connection.last_validated_at) : 'Never'}
</p>
</div>
</div>
</div>
{/* Test Result */}
{testResult && (
<div
className={cn(
'flex items-center gap-2 rounded-xl border p-4 text-sm',
testResult.success
? 'border-emerald-400/20 bg-emerald-400/10 text-emerald-400'
: 'border-red-400/20 bg-red-400/10 text-red-400'
)}
>
{testResult.success ? (
<CheckCircle2 className="h-4 w-4 shrink-0" />
) : (
<AlertCircle className="h-4 w-4 shrink-0" />
)}
<span>{testResult.message}</span>
{testResult.server_version && (
<span className="ml-auto text-xs text-muted-foreground">
v{testResult.server_version}
</span>
)}
</div>
)}
{/* Action Buttons */}
<div className="flex items-center gap-3">
<button
onClick={handleTest}
disabled={isTesting}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
'hover:border-[rgba(255,255,255,0.12)] transition-all',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isTesting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
Test Connection
</button>
<button
onClick={startEdit}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
'hover:border-[rgba(255,255,255,0.12)] transition-all'
)}
>
<Pencil className="h-4 w-4" />
Edit
</button>
{showDeleteConfirm ? (
<div className="flex items-center gap-2 ml-auto">
<span className="text-sm text-muted-foreground">Disconnect?</span>
<button
onClick={handleDelete}
disabled={isDeleting}
className="inline-flex items-center gap-1.5 rounded-[10px] px-3 py-2 text-sm font-medium text-red-400 hover:bg-red-400/10 transition-colors"
>
{isDeleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
Confirm
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setShowDeleteConfirm(true)}
className="inline-flex items-center gap-2 ml-auto text-sm text-red-400 hover:text-red-300 transition-colors"
>
<Trash2 className="h-4 w-4" />
Disconnect
</button>
)}
</div>
</div>
)}
</div>
)}
{/* Member Mapping Tab */}
{activeTab === 'member-mapping' && (
<MemberMappingTab connection={connection} />
)}
{/* Post History Tab */}
{activeTab === 'post-history' && (
<div className="max-w-3xl">
<div className="glass-card-static p-6">
<div className="flex items-center gap-3 mb-4">
<Ticket className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Post History</h2>
</div>
<p className="text-sm text-muted-foreground">
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.
</p>
</div>
</div>
)}
</div>
</>
)
}
/* ─── Member Mapping Tab ─── */
function MemberMappingTab({ connection }: { connection: PsaConnectionResponse | null }) {
const [cwMembers, setCwMembers] = useState<PsaMemberResponse[]>([])
const [mappings, setMappings] = useState<PsaMemberMappingResponse[]>([])
const [localMappings, setLocalMappings] = useState<Record<string, { external_member_id: string; external_member_name: string }>>({})
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<string, { external_member_id: string; external_member_name: string }> = {}
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 (
<div className="max-w-3xl">
<div className="glass-card-static p-6">
<div className="flex items-center gap-3 mb-4">
<Users className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Member Mapping</h2>
</div>
<p className="text-sm text-muted-foreground">
Set up a PSA connection first to map team members to ConnectWise members.
</p>
</div>
</div>
)
}
return (
<div className="max-w-3xl space-y-4">
{/* Header + Auto-Match */}
<div className="glass-card-static p-6">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<Users className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Member Mapping</h2>
</div>
<button
type="button"
onClick={handleAutoMatch}
disabled={isAutoMatching || isLoadingData}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-4 py-2 text-sm font-medium',
'bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] text-foreground',
'hover:border-[rgba(255,255,255,0.12)] transition-all',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isAutoMatching ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Zap className="h-4 w-4" />
)}
Auto-Match by Email
</button>
</div>
<p className="text-sm text-muted-foreground">
Map your ResolutionFlow users to ConnectWise members so session posts are attributed correctly.
</p>
</div>
{/* Loading state */}
{isLoadingData && (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{/* Mapping Table */}
{hasLoaded && !isLoadingData && (
<div className="glass-card-static overflow-hidden">
{uniqueUsers.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
No users found. Use Auto-Match to discover and map users.
</div>
) : (
<>
{/* Table header */}
<div className="grid grid-cols-[1fr_1fr_1fr_auto] gap-4 border-b border-border px-6 py-3">
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">User</span>
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Email</span>
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">Mapped To</span>
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground w-20 text-center">Method</span>
</div>
{/* Rows */}
{uniqueUsers.map((user) => {
const currentMapping = localMappings[user.user_id]
return (
<div
key={user.user_id}
className="grid grid-cols-[1fr_1fr_1fr_auto] gap-4 items-center border-b border-border/50 px-6 py-3 last:border-b-0"
>
<span className="text-sm text-foreground truncate">{user.user_name}</span>
<span className="text-sm text-muted-foreground truncate">{user.user_email}</span>
<select
title={`Map ${user.user_name} to a ConnectWise member`}
value={currentMapping?.external_member_id || ''}
onChange={(e) => handleMemberChange(user.user_id, e.target.value)}
className={cn(
'w-full rounded-lg border bg-card px-3 py-1.5 text-sm text-foreground',
'border-border focus:border-[rgba(6,182,212,0.3)] focus:outline-none',
!currentMapping && 'text-muted-foreground'
)}
>
<option value="">-- Unmapped --</option>
{cwMembers.map((member) => (
<option key={member.id} value={member.id}>
{member.name}{member.email ? ` (${member.email})` : ''}
</option>
))}
</select>
<span className="w-20 text-center">
{currentMapping && !isDirty && user.matched_by ? (
<span className={cn(
'inline-flex items-center rounded-full px-2 py-0.5 text-[0.625rem] font-label',
user.matched_by === 'auto_email'
? 'bg-primary/10 text-primary'
: 'bg-card border border-border text-muted-foreground'
)}>
{user.matched_by === 'auto_email' ? 'auto' : 'manual'}
</span>
) : currentMapping ? (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-[0.625rem] font-label bg-card border border-border text-muted-foreground">
manual
</span>
) : (
<span className="text-[0.625rem] text-muted-foreground/50"></span>
)}
</span>
</div>
)
})}
</>
)}
</div>
)}
{/* Save button */}
{isDirty && (
<div className="flex justify-end">
<button
type="button"
onClick={handleSave}
disabled={isSavingMappings}
className={cn(
'inline-flex items-center gap-2 rounded-[10px] px-5 py-2.5 text-sm font-semibold',
'bg-gradient-brand text-[#101114] shadow-lg shadow-primary/20',
'hover:opacity-90 active:scale-[0.97] transition-all',
'disabled:opacity-50 disabled:cursor-not-allowed'
)}
>
{isSavingMappings ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
)}
Save Mappings
</button>
</div>
)}
</div>
)
}
export default IntegrationsPage