From db305f54e7d156ae92a83d967561a7dfd720a47d Mon Sep 17 00:00:00 2001 From: Michael Chihlas Date: Sat, 14 Mar 2026 23:53:02 -0400 Subject: [PATCH] feat(psa): add Member Mapping tab with auto-match and manual assignment Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/api/integrations.ts | 10 +- .../src/pages/account/IntegrationsPage.tsx | 270 +++++++++++++++++- frontend/src/types/integrations.ts | 22 ++ 3 files changed, 299 insertions(+), 3 deletions(-) diff --git a/frontend/src/api/integrations.ts b/frontend/src/api/integrations.ts index 49958403..ed90f88d 100644 --- a/frontend/src/api/integrations.ts +++ b/frontend/src/api/integrations.ts @@ -1,6 +1,6 @@ import { apiClient } from './client' import type { PsaConnectionResponse, PsaConnectionCreate, PsaConnectionUpdate, PsaConnectionTestResponse } from '@/types' -import type { TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry } from '@/types/integrations' +import type { TicketLinkResponse, PSATicketSearchResult, PSATicketInfo, PSATicketStatusItem, PsaPreviewResponse, PsaPostResponse, PsaPostLogEntry, PsaMemberResponse, PsaMemberMappingResponse, AutoMatchResult } from '@/types/integrations' export const integrationsApi = { getConnection: () => @@ -19,6 +19,14 @@ export const integrationsApi = { apiClient.get(`/integrations/psa/tickets/${id}`).then(r => r.data), getTicketStatuses: (ticketId: string) => apiClient.get(`/integrations/psa/tickets/${ticketId}/statuses`).then(r => r.data), + listMembers: () => + apiClient.get('/integrations/psa/members').then(r => r.data), + getMemberMappings: () => + apiClient.get('/integrations/psa/member-mappings').then(r => r.data), + saveMemberMappings: (mappings: { user_id: string; external_member_id: string; external_member_name: string }[]) => + apiClient.post('/integrations/psa/member-mappings', mappings).then(r => r.data), + autoMatchMembers: () => + apiClient.post('/integrations/psa/member-mappings/auto-match').then(r => r.data), } export const sessionPsaApi = { diff --git a/frontend/src/pages/account/IntegrationsPage.tsx b/frontend/src/pages/account/IntegrationsPage.tsx index fd5595d3..15076550 100644 --- a/frontend/src/pages/account/IntegrationsPage.tsx +++ b/frontend/src/pages/account/IntegrationsPage.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from 'react' -import { Plug, CheckCircle2, AlertCircle, Loader2, Pencil, Trash2, Shield, History, Ticket } from 'lucide-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' @@ -38,7 +40,7 @@ const emptyForm: ConnectionForm = { client_id: '', } -type Tab = 'connection' | 'post-history' +type Tab = 'connection' | 'member-mapping' | 'post-history' export function IntegrationsPage() { const [activeTab, setActiveTab] = useState('connection') @@ -231,6 +233,7 @@ export function IntegrationsPage() {
{([ { 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 }) => ( +
+

+ 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 && ( +
+ +
+ )} + + ) +} + export default IntegrationsPage diff --git a/frontend/src/types/integrations.ts b/frontend/src/types/integrations.ts index 40060122..b80da91f 100644 --- a/frontend/src/types/integrations.ts +++ b/frontend/src/types/integrations.ts @@ -101,3 +101,25 @@ export interface PsaPostLogEntry { posted_at: string content_preview: string } + +export interface PsaMemberResponse { + id: string + identifier: string + name: string + email: string | null +} + +export interface PsaMemberMappingResponse { + id: string + user_id: string + user_email: string + user_name: string + external_member_id: string + external_member_name: string + matched_by: string +} + +export interface AutoMatchResult { + matched: PsaMemberMappingResponse[] + unmatched_users: number +}