- Build complete Next.js CRM for commercial real estate - Add authentication with JWT sessions and role-based access - Add GoHighLevel API integration for contacts, conversations, opportunities - Add AI-powered Control Center with tool calling - Add Setup page with onboarding checklist (/setup) - Add sidebar navigation with Setup menu item - Fix type errors in onboarding API, GHL services, and control center tools - Add Prisma schema with SQLite for local development - Add UI components with clay morphism design system Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
429 lines
16 KiB
TypeScript
429 lines
16 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { ClayCard } from './ClayCard';
|
|
import { Button } from './Button';
|
|
import {
|
|
ChevronUp,
|
|
ChevronDown,
|
|
Search,
|
|
Filter,
|
|
Bell,
|
|
CheckCircle,
|
|
XCircle,
|
|
AlertTriangle,
|
|
DollarSign,
|
|
Users
|
|
} from 'lucide-react';
|
|
|
|
export interface UserOnboardingRecord {
|
|
id: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
email: string;
|
|
brokerage: string;
|
|
yearsInBusiness: string;
|
|
gciLast12Months: string;
|
|
currentCRM: string | null;
|
|
goalsSelected: string[];
|
|
setupStatus: {
|
|
smsConfigured: boolean;
|
|
emailConfigured: boolean;
|
|
contactsImported: boolean;
|
|
campaignsSetup: boolean;
|
|
};
|
|
createdAt: Date;
|
|
}
|
|
|
|
interface AdminViewProps {
|
|
users: UserOnboardingRecord[];
|
|
onUserClick?: (userId: string) => void;
|
|
onNotifyUser?: (userId: string) => void;
|
|
}
|
|
|
|
type SortField = 'name' | 'email' | 'brokerage' | 'yearsInBusiness' | 'gci' | 'createdAt' | 'setupCompletion';
|
|
type SortDirection = 'asc' | 'desc';
|
|
type FilterType = 'all' | 'highGCI' | 'incompleteSetup' | 'completeSetup';
|
|
|
|
const parseGCI = (gciString: string): number => {
|
|
const cleaned = gciString.replace(/[$,]/g, '');
|
|
return parseFloat(cleaned) || 0;
|
|
};
|
|
|
|
const getSetupCompletionCount = (status: UserOnboardingRecord['setupStatus']): number => {
|
|
return [
|
|
status.smsConfigured,
|
|
status.emailConfigured,
|
|
status.contactsImported,
|
|
status.campaignsSetup
|
|
].filter(Boolean).length;
|
|
};
|
|
|
|
const isHighGCI = (gciString: string): boolean => {
|
|
return parseGCI(gciString) >= 100000;
|
|
};
|
|
|
|
const hasIncompleteSetup = (status: UserOnboardingRecord['setupStatus']): boolean => {
|
|
return getSetupCompletionCount(status) < 4;
|
|
};
|
|
|
|
export const AdminView: React.FC<AdminViewProps> = ({
|
|
users,
|
|
onUserClick,
|
|
onNotifyUser
|
|
}) => {
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [sortField, setSortField] = useState<SortField>('createdAt');
|
|
const [sortDirection, setSortDirection] = useState<SortDirection>('desc');
|
|
const [filterType, setFilterType] = useState<FilterType>('all');
|
|
|
|
const handleSort = (field: SortField) => {
|
|
if (sortField === field) {
|
|
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
|
} else {
|
|
setSortField(field);
|
|
setSortDirection('asc');
|
|
}
|
|
};
|
|
|
|
const filteredAndSortedUsers = useMemo(() => {
|
|
let result = [...users];
|
|
|
|
// Apply search filter
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
result = result.filter(user =>
|
|
user.firstName.toLowerCase().includes(query) ||
|
|
user.lastName.toLowerCase().includes(query) ||
|
|
user.email.toLowerCase().includes(query) ||
|
|
user.brokerage.toLowerCase().includes(query)
|
|
);
|
|
}
|
|
|
|
// Apply type filter
|
|
switch (filterType) {
|
|
case 'highGCI':
|
|
result = result.filter(user => isHighGCI(user.gciLast12Months));
|
|
break;
|
|
case 'incompleteSetup':
|
|
result = result.filter(user => hasIncompleteSetup(user.setupStatus));
|
|
break;
|
|
case 'completeSetup':
|
|
result = result.filter(user => !hasIncompleteSetup(user.setupStatus));
|
|
break;
|
|
}
|
|
|
|
// Apply sorting
|
|
result.sort((a, b) => {
|
|
let comparison = 0;
|
|
|
|
switch (sortField) {
|
|
case 'name':
|
|
comparison = `${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`);
|
|
break;
|
|
case 'email':
|
|
comparison = a.email.localeCompare(b.email);
|
|
break;
|
|
case 'brokerage':
|
|
comparison = a.brokerage.localeCompare(b.brokerage);
|
|
break;
|
|
case 'yearsInBusiness':
|
|
comparison = parseFloat(a.yearsInBusiness) - parseFloat(b.yearsInBusiness);
|
|
break;
|
|
case 'gci':
|
|
comparison = parseGCI(a.gciLast12Months) - parseGCI(b.gciLast12Months);
|
|
break;
|
|
case 'createdAt':
|
|
comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
|
|
break;
|
|
case 'setupCompletion':
|
|
comparison = getSetupCompletionCount(a.setupStatus) - getSetupCompletionCount(b.setupStatus);
|
|
break;
|
|
}
|
|
|
|
return sortDirection === 'asc' ? comparison : -comparison;
|
|
});
|
|
|
|
return result;
|
|
}, [users, searchQuery, sortField, sortDirection, filterType]);
|
|
|
|
const SortIcon: React.FC<{ field: SortField }> = ({ field }) => {
|
|
if (sortField !== field) return null;
|
|
return sortDirection === 'asc'
|
|
? <ChevronUp size={16} className="inline ml-1" />
|
|
: <ChevronDown size={16} className="inline ml-1" />;
|
|
};
|
|
|
|
const StatusBadge: React.FC<{ configured: boolean; label: string }> = ({ configured, label }) => (
|
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
|
|
configured
|
|
? 'bg-green-100 text-green-700'
|
|
: 'bg-red-100 text-red-700'
|
|
}`}>
|
|
{configured ? <CheckCircle size={12} /> : <XCircle size={12} />}
|
|
{label}
|
|
</span>
|
|
);
|
|
|
|
const highGCICount = users.filter(u => isHighGCI(u.gciLast12Months)).length;
|
|
const incompleteSetupCount = users.filter(u => hasIncompleteSetup(u.setupStatus)).length;
|
|
|
|
return (
|
|
<div className="max-w-7xl mx-auto py-10 px-4">
|
|
{/* Header */}
|
|
<div className="mb-8">
|
|
<h1 className="text-3xl font-bold text-gray-800">Admin Dashboard</h1>
|
|
<p className="text-gray-500 mt-2">Manage and monitor user onboarding progress</p>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
|
<ClayCard className="flex items-center gap-4">
|
|
<div className="bg-indigo-100 p-4 rounded-2xl text-indigo-600">
|
|
<Users size={28} />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-500">Total Users</p>
|
|
<p className="text-2xl font-bold text-gray-800">{users.length}</p>
|
|
</div>
|
|
</ClayCard>
|
|
|
|
<ClayCard className="flex items-center gap-4">
|
|
<div className="bg-yellow-100 p-4 rounded-2xl text-yellow-600">
|
|
<DollarSign size={28} />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-500">High GCI Users ($100K+)</p>
|
|
<p className="text-2xl font-bold text-gray-800">{highGCICount}</p>
|
|
</div>
|
|
</ClayCard>
|
|
|
|
<ClayCard className="flex items-center gap-4">
|
|
<div className="bg-red-100 p-4 rounded-2xl text-red-600">
|
|
<AlertTriangle size={28} />
|
|
</div>
|
|
<div>
|
|
<p className="text-sm text-gray-500">Incomplete Setup</p>
|
|
<p className="text-2xl font-bold text-gray-800">{incompleteSetupCount}</p>
|
|
</div>
|
|
</ClayCard>
|
|
</div>
|
|
|
|
{/* Filters and Search */}
|
|
<ClayCard className="mb-6">
|
|
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center justify-between">
|
|
{/* Search */}
|
|
<div className="relative flex-1 max-w-md">
|
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by name, email, or brokerage..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-3 rounded-xl bg-gray-50 border-none shadow-inner focus:ring-2 focus:ring-indigo-500 outline-none"
|
|
/>
|
|
</div>
|
|
|
|
{/* Filter Buttons */}
|
|
<div className="flex gap-2 flex-wrap">
|
|
<button
|
|
onClick={() => setFilterType('all')}
|
|
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
|
|
filterType === 'all'
|
|
? 'bg-indigo-600 text-white'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
All
|
|
</button>
|
|
<button
|
|
onClick={() => setFilterType('highGCI')}
|
|
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${
|
|
filterType === 'highGCI'
|
|
? 'bg-yellow-500 text-white'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
<DollarSign size={14} /> High GCI
|
|
</button>
|
|
<button
|
|
onClick={() => setFilterType('incompleteSetup')}
|
|
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${
|
|
filterType === 'incompleteSetup'
|
|
? 'bg-red-500 text-white'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
<AlertTriangle size={14} /> Incomplete
|
|
</button>
|
|
<button
|
|
onClick={() => setFilterType('completeSetup')}
|
|
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all flex items-center gap-1 ${
|
|
filterType === 'completeSetup'
|
|
? 'bg-green-500 text-white'
|
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
<CheckCircle size={14} /> Complete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</ClayCard>
|
|
|
|
{/* Users Table */}
|
|
<ClayCard className="overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead>
|
|
<tr className="border-b border-gray-200">
|
|
<th
|
|
className="text-left py-4 px-4 font-semibold text-gray-600 cursor-pointer hover:text-indigo-600"
|
|
onClick={() => handleSort('name')}
|
|
>
|
|
Name <SortIcon field="name" />
|
|
</th>
|
|
<th
|
|
className="text-left py-4 px-4 font-semibold text-gray-600 cursor-pointer hover:text-indigo-600"
|
|
onClick={() => handleSort('email')}
|
|
>
|
|
Email <SortIcon field="email" />
|
|
</th>
|
|
<th
|
|
className="text-left py-4 px-4 font-semibold text-gray-600 cursor-pointer hover:text-indigo-600"
|
|
onClick={() => handleSort('brokerage')}
|
|
>
|
|
Brokerage <SortIcon field="brokerage" />
|
|
</th>
|
|
<th
|
|
className="text-left py-4 px-4 font-semibold text-gray-600 cursor-pointer hover:text-indigo-600"
|
|
onClick={() => handleSort('yearsInBusiness')}
|
|
>
|
|
Years <SortIcon field="yearsInBusiness" />
|
|
</th>
|
|
<th
|
|
className="text-left py-4 px-4 font-semibold text-gray-600 cursor-pointer hover:text-indigo-600"
|
|
onClick={() => handleSort('gci')}
|
|
>
|
|
GCI (12mo) <SortIcon field="gci" />
|
|
</th>
|
|
<th className="text-left py-4 px-4 font-semibold text-gray-600">
|
|
CRM
|
|
</th>
|
|
<th className="text-left py-4 px-4 font-semibold text-gray-600">
|
|
Goals
|
|
</th>
|
|
<th
|
|
className="text-left py-4 px-4 font-semibold text-gray-600 cursor-pointer hover:text-indigo-600"
|
|
onClick={() => handleSort('setupCompletion')}
|
|
>
|
|
Setup Status <SortIcon field="setupCompletion" />
|
|
</th>
|
|
<th className="text-left py-4 px-4 font-semibold text-gray-600">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{filteredAndSortedUsers.map((user) => {
|
|
const userIsHighGCI = isHighGCI(user.gciLast12Months);
|
|
const userHasIncompleteSetup = hasIncompleteSetup(user.setupStatus);
|
|
|
|
return (
|
|
<tr
|
|
key={user.id}
|
|
className={`border-b border-gray-100 transition-colors hover:bg-gray-50 ${
|
|
userIsHighGCI ? 'bg-yellow-50/50' : ''
|
|
} ${userHasIncompleteSetup && !userIsHighGCI ? 'bg-red-50/30' : ''}`}
|
|
>
|
|
<td className="py-4 px-4">
|
|
<div
|
|
className="flex items-center gap-2 cursor-pointer hover:text-indigo-600"
|
|
onClick={() => onUserClick?.(user.id)}
|
|
>
|
|
<span className="font-medium text-gray-800">
|
|
{user.firstName} {user.lastName}
|
|
</span>
|
|
{userIsHighGCI && (
|
|
<span className="px-2 py-0.5 bg-yellow-100 text-yellow-700 text-xs rounded-full font-medium">
|
|
High GCI
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="py-4 px-4 text-gray-600">{user.email}</td>
|
|
<td className="py-4 px-4 text-gray-600">{user.brokerage}</td>
|
|
<td className="py-4 px-4 text-gray-600">{user.yearsInBusiness}</td>
|
|
<td className="py-4 px-4">
|
|
<span className={`font-semibold ${userIsHighGCI ? 'text-yellow-600' : 'text-gray-800'}`}>
|
|
{user.gciLast12Months}
|
|
</span>
|
|
</td>
|
|
<td className="py-4 px-4 text-gray-600">
|
|
{user.currentCRM || <span className="text-gray-400 italic">None</span>}
|
|
</td>
|
|
<td className="py-4 px-4">
|
|
<div className="flex flex-wrap gap-1">
|
|
{user.goalsSelected.length > 0 ? (
|
|
user.goalsSelected.map((goal, idx) => (
|
|
<span
|
|
key={idx}
|
|
className="px-2 py-0.5 bg-indigo-100 text-indigo-700 text-xs rounded-full"
|
|
>
|
|
{goal}
|
|
</span>
|
|
))
|
|
) : (
|
|
<span className="text-gray-400 italic text-sm">No goals</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
<td className="py-4 px-4">
|
|
<div className="flex flex-wrap gap-1">
|
|
<StatusBadge configured={user.setupStatus.smsConfigured} label="SMS" />
|
|
<StatusBadge configured={user.setupStatus.emailConfigured} label="Email" />
|
|
<StatusBadge configured={user.setupStatus.contactsImported} label="Contacts" />
|
|
<StatusBadge configured={user.setupStatus.campaignsSetup} label="Campaigns" />
|
|
</div>
|
|
</td>
|
|
<td className="py-4 px-4">
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => onUserClick?.(user.id)}
|
|
className="!p-2"
|
|
>
|
|
View
|
|
</Button>
|
|
{userHasIncompleteSetup && onNotifyUser && (
|
|
<button
|
|
onClick={() => onNotifyUser(user.id)}
|
|
className="p-2 text-orange-500 hover:bg-orange-50 rounded-lg transition-colors"
|
|
title="Send reminder notification"
|
|
>
|
|
<Bell size={18} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
|
|
{filteredAndSortedUsers.length === 0 && (
|
|
<div className="text-center py-12 text-gray-500">
|
|
<Users size={48} className="mx-auto mb-4 text-gray-300" />
|
|
<p className="text-lg font-medium">No users found</p>
|
|
<p className="text-sm mt-1">Try adjusting your search or filter criteria</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</ClayCard>
|
|
|
|
{/* Results Summary */}
|
|
<div className="mt-4 text-center text-gray-500 text-sm">
|
|
Showing {filteredAndSortedUsers.length} of {users.length} users
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|