All modal inner containers now have border-2 border-oliver-azure for consistent Oliver branding across: - CreateCampaignModal, CreateProjectModal - FeedbackReport (resolve + flag modals) - UserManagement (confirmation + history modals) - Campaigns (upload, delete confirmation, version history modals) - Projects (upload, delete modals) - Login (support contact modal) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
515 lines
29 KiB
TypeScript
515 lines
29 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import apiService from '../services/apiService';
|
|
import type { UserManagementResponse, AgencyResponse, UserChangeLogEntry } from '../services/apiService';
|
|
import { UserIcon } from './icons/UserIcon';
|
|
import { PlusIcon } from './icons/PlusIcon';
|
|
import { ChevronDownIcon } from './icons/ChevronDownIcon';
|
|
import { ClockIcon } from './icons/ClockIcon';
|
|
import { useUser } from '../contexts/UserContext';
|
|
import type { UserRole } from '../types';
|
|
|
|
const ROLE_OPTIONS: { value: UserRole; label: string }[] = [
|
|
{ value: 'super_admin', label: 'Super Admin' },
|
|
{ value: 'oversight_admin', label: 'Oversight Admin' },
|
|
{ value: 'agency_admin', label: 'Agency Admin' },
|
|
{ value: 'basic_user', label: 'Basic User' },
|
|
];
|
|
|
|
const formatDate = (iso: string) => {
|
|
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
};
|
|
|
|
export const UserManagement: React.FC = () => {
|
|
const { user: currentUser, isSuperAdmin, refresh } = useUser();
|
|
const [users, setUsers] = useState<UserManagementResponse[]>([]);
|
|
const [agencies, setAgencies] = useState<AgencyResponse[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [savedUserId, setSavedUserId] = useState<string | null>(null);
|
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
|
|
// Super Admin confirmation modal
|
|
const [pendingSuperAdminUser, setPendingSuperAdminUser] = useState<{ userId: string; userName: string } | null>(null);
|
|
const [confirmationText, setConfirmationText] = useState('');
|
|
const [confirmationError, setConfirmationError] = useState(false);
|
|
|
|
// Change history modal
|
|
const [historyUser, setHistoryUser] = useState<{ id: string; name: string } | null>(null);
|
|
const [historyEntries, setHistoryEntries] = useState<UserChangeLogEntry[]>([]);
|
|
const [historyLoading, setHistoryLoading] = useState(false);
|
|
|
|
// New agency form
|
|
const [newAgencyName, setNewAgencyName] = useState('');
|
|
const [isCreatingAgency, setIsCreatingAgency] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
const loadData = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const [usersData, agenciesData] = await Promise.all([
|
|
apiService.getUsers(),
|
|
apiService.getAgencies(),
|
|
]);
|
|
setUsers(usersData);
|
|
setAgencies(agenciesData);
|
|
} catch (err) {
|
|
console.error('Failed to load user management data:', err);
|
|
setError('Failed to load data. You may not have permission.');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const showSavedIndicator = (userId: string) => {
|
|
setSavedUserId(userId);
|
|
setError(null);
|
|
setTimeout(() => setSavedUserId(prev => prev === userId ? null : prev), 2000);
|
|
};
|
|
|
|
const handleRoleChange = async (userId: string, newRole: string) => {
|
|
const user = users.find(u => u.id === userId);
|
|
if (newRole === 'super_admin' && user?.role !== 'super_admin') {
|
|
setPendingSuperAdminUser({ userId, userName: user?.name || 'this user' });
|
|
setConfirmationText('');
|
|
setConfirmationError(false);
|
|
return;
|
|
}
|
|
try {
|
|
const updated = await apiService.updateUser(userId, { role: newRole });
|
|
setUsers(prev => prev.map(u => u.id === userId ? updated : u));
|
|
showSavedIndicator(userId);
|
|
if (userId === currentUser?.id) await refresh();
|
|
} catch (err) {
|
|
console.error('Failed to update user role:', err);
|
|
setError('Failed to update user role.');
|
|
}
|
|
};
|
|
|
|
const handleConfirmSuperAdmin = async () => {
|
|
if (confirmationText !== 'make this user super admin') {
|
|
setConfirmationError(true);
|
|
return;
|
|
}
|
|
if (!pendingSuperAdminUser) return;
|
|
const { userId } = pendingSuperAdminUser;
|
|
setPendingSuperAdminUser(null);
|
|
setConfirmationText('');
|
|
setConfirmationError(false);
|
|
try {
|
|
const updated = await apiService.updateUser(userId, { role: 'super_admin' });
|
|
setUsers(prev => prev.map(u => u.id === userId ? updated : u));
|
|
showSavedIndicator(userId);
|
|
if (userId === currentUser?.id) await refresh();
|
|
} catch (err) {
|
|
console.error('Failed to update user role:', err);
|
|
setError('Failed to update user role.');
|
|
}
|
|
};
|
|
|
|
const handleCancelSuperAdmin = () => {
|
|
setPendingSuperAdminUser(null);
|
|
setConfirmationText('');
|
|
setConfirmationError(false);
|
|
};
|
|
|
|
const handleAgencyChange = async (userId: string, agencyId: string | null) => {
|
|
try {
|
|
const updated = await apiService.updateUser(userId, { agency_id: agencyId });
|
|
setUsers(prev => prev.map(u => u.id === userId ? updated : u));
|
|
showSavedIndicator(userId);
|
|
if (userId === currentUser?.id) await refresh();
|
|
} catch (err) {
|
|
console.error('Failed to update user agency:', err);
|
|
setError('Failed to update user agency.');
|
|
}
|
|
};
|
|
|
|
const formatRoleLabel = (role: string): string => {
|
|
const found = ROLE_OPTIONS.find(r => r.value === role);
|
|
return found ? found.label : role;
|
|
};
|
|
|
|
const formatChangeDescription = (entry: UserChangeLogEntry): string => {
|
|
if (entry.change_type === 'user_created') {
|
|
return `User account created (${formatRoleLabel(entry.new_value || 'basic_user')})`;
|
|
}
|
|
if (entry.change_type === 'role_changed') {
|
|
return `Role changed from ${formatRoleLabel(entry.old_value || '')} to ${formatRoleLabel(entry.new_value || '')}`;
|
|
}
|
|
if (entry.change_type === 'agency_changed') {
|
|
return `Agency changed from ${entry.old_value || 'None'} to ${entry.new_value || 'None'}`;
|
|
}
|
|
return entry.change_type;
|
|
};
|
|
|
|
const formatHistoryDate = (iso: string): string => {
|
|
const d = new Date(iso);
|
|
return d.toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })
|
|
+ ', '
|
|
+ d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' });
|
|
};
|
|
|
|
const handleOpenHistory = async (user: UserManagementResponse) => {
|
|
setHistoryUser({ id: user.id, name: user.name });
|
|
setHistoryLoading(true);
|
|
setHistoryEntries([]);
|
|
try {
|
|
const entries = await apiService.getUserChangeHistory(user.id);
|
|
setHistoryEntries(entries);
|
|
} catch (err) {
|
|
console.error('Failed to load change history:', err);
|
|
} finally {
|
|
setHistoryLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCloseHistory = () => {
|
|
setHistoryUser(null);
|
|
setHistoryEntries([]);
|
|
};
|
|
|
|
const handleCreateAgency = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!newAgencyName.trim()) return;
|
|
|
|
setIsCreatingAgency(true);
|
|
try {
|
|
const newAgency = await apiService.createAgency(newAgencyName.trim());
|
|
setAgencies(prev => [...prev, newAgency].sort((a, b) => a.name.localeCompare(b.name)));
|
|
setNewAgencyName('');
|
|
setError(null);
|
|
setSuccessMessage('Agency created successfully');
|
|
setTimeout(() => setSuccessMessage(prev => prev === 'Agency created successfully' ? null : prev), 3000);
|
|
} catch (err) {
|
|
console.error('Failed to create agency:', err);
|
|
setError('Failed to create agency.');
|
|
} finally {
|
|
setIsCreatingAgency(false);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="p-8 flex items-center justify-center h-full">
|
|
<div className="flex flex-col items-center gap-3">
|
|
<svg className="animate-spin h-8 w-8 text-oliver-azure" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
</svg>
|
|
<p className="text-oliver-black/60 text-sm">Loading user management...</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="p-4 sm:p-6 lg:p-8 h-full bg-white overflow-y-auto">
|
|
<header className="mb-6">
|
|
<h1 className="text-3xl lg:text-4xl font-semibold text-oliver-black">User Management</h1>
|
|
<p className="text-base lg:text-lg text-oliver-black mt-1">
|
|
{isSuperAdmin
|
|
? 'Manage user roles and agency assignments. Users are provisioned automatically via Azure AD.'
|
|
: 'View user roles and agency assignments.'}
|
|
</p>
|
|
</header>
|
|
|
|
{error && (
|
|
<div className="mb-4 p-3 bg-error/10 border border-error/30 rounded-[10px] text-error text-sm">
|
|
{error}
|
|
<button onClick={() => setError(null)} className="ml-2 font-semibold hover:underline">Dismiss</button>
|
|
</div>
|
|
)}
|
|
|
|
{successMessage && (
|
|
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-[10px] text-green-700 text-sm">
|
|
{successMessage}
|
|
<button onClick={() => setSuccessMessage(null)} className="ml-2 font-semibold hover:underline">Dismiss</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Users Table */}
|
|
<section className="mb-10">
|
|
<h2 className="text-xl font-semibold text-oliver-black mb-4">Users ({users.length})</h2>
|
|
<div className="bg-white rounded-[10px] shadow-md overflow-hidden border border-grey-300">
|
|
<div className="overflow-x-auto">
|
|
<table className="min-w-full">
|
|
<thead className="bg-oliver-sky">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Name</th>
|
|
<th className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Email</th>
|
|
<th className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Role</th>
|
|
<th className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Agency</th>
|
|
<th className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Created</th>
|
|
<th className="px-6 py-3 text-left text-xs font-bold text-oliver-black uppercase tracking-wider w-20"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-grey-300">
|
|
{users.map((user, index) => (
|
|
<tr key={user.id} className={index % 2 === 0 ? 'bg-white' : 'bg-oliver-grey'}>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
<div className="flex items-center">
|
|
<div className="flex-shrink-0 h-8 w-8 rounded-full bg-oliver-grey flex items-center justify-center text-oliver-azure">
|
|
<UserIcon className="h-4 w-4" />
|
|
</div>
|
|
<div className="ml-3">
|
|
<div className="text-sm font-medium text-oliver-black">{user.name}</div>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black">
|
|
{user.email}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
{isSuperAdmin ? (
|
|
<div className="relative max-w-[180px]">
|
|
<select
|
|
value={user.role}
|
|
onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
|
className="w-full bg-white border-2 border-oliver-azure text-oliver-black py-1.5 pl-3 pr-8 rounded-[10px] text-sm focus:outline-none focus:ring-2 focus:ring-oliver-azure appearance-none"
|
|
>
|
|
{ROLE_OPTIONS.map(r => (
|
|
<option key={r.value} value={r.value}>{r.label}</option>
|
|
))}
|
|
</select>
|
|
<ChevronDownIcon className="absolute right-2 top-1/2 -translate-y-1/2 h-3 w-3 text-oliver-azure pointer-events-none" />
|
|
</div>
|
|
) : (
|
|
<span className="text-sm text-oliver-black">{formatRoleLabel(user.role)}</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap">
|
|
{isSuperAdmin ? (
|
|
<div className="relative max-w-[200px]">
|
|
<select
|
|
value={user.agency_id || ''}
|
|
onChange={(e) => handleAgencyChange(user.id, e.target.value || null)}
|
|
className="w-full bg-white border-2 border-oliver-azure text-oliver-black py-1.5 pl-3 pr-8 rounded-[10px] text-sm focus:outline-none focus:ring-2 focus:ring-oliver-azure appearance-none"
|
|
>
|
|
<option value="">None (Unassigned)</option>
|
|
{agencies.map(a => (
|
|
<option key={a.id} value={a.id}>{a.name}</option>
|
|
))}
|
|
</select>
|
|
<ChevronDownIcon className="absolute right-2 top-1/2 -translate-y-1/2 h-3 w-3 text-oliver-azure pointer-events-none" />
|
|
</div>
|
|
) : (
|
|
<span className="text-sm text-oliver-black">
|
|
{user.agency || 'Unassigned'}
|
|
</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-oliver-black/60">
|
|
{formatDate(user.created_at)}
|
|
</td>
|
|
<td className="px-6 py-4 whitespace-nowrap text-sm">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => handleOpenHistory(user)}
|
|
title="View change history"
|
|
className="p-1 text-oliver-black/60 hover:text-oliver-azure transition-colors rounded-full hover:bg-oliver-grey"
|
|
>
|
|
<ClockIcon className="h-4 w-4" />
|
|
</button>
|
|
{savedUserId === user.id && (
|
|
<span className="inline-flex items-center gap-1 text-green-700 font-medium">
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
Saved
|
|
</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{users.length === 0 && (
|
|
<tr>
|
|
<td colSpan={6} className="px-6 py-8 text-center text-oliver-black/60">
|
|
No users found.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Super Admin Confirmation Modal */}
|
|
{pendingSuperAdminUser && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
onClick={handleCancelSuperAdmin}
|
|
>
|
|
<div
|
|
className="bg-white rounded-[10px] shadow-xl max-w-md w-full mx-4 p-6 border-2 border-oliver-azure"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="flex-shrink-0 h-10 w-10 rounded-full bg-amber-100 flex items-center justify-center">
|
|
<svg className="h-5 w-5 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-lg font-semibold text-oliver-black">Confirm Super Admin Assignment</h3>
|
|
</div>
|
|
<p className="text-sm text-oliver-black/60 mb-4">
|
|
You are about to grant Super Admin privileges to <strong className="text-oliver-black">{pendingSuperAdminUser.userName}</strong>. This gives full system access including user management. This action should only be performed intentionally.
|
|
</p>
|
|
<p className="text-sm text-oliver-black/60 mb-2">
|
|
To confirm, type <strong className="text-oliver-black">make this user super admin</strong> below:
|
|
</p>
|
|
<input
|
|
type="text"
|
|
value={confirmationText}
|
|
onChange={(e) => {
|
|
setConfirmationText(e.target.value);
|
|
setConfirmationError(false);
|
|
}}
|
|
onPaste={(e) => e.preventDefault()}
|
|
onDrop={(e) => e.preventDefault()}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
placeholder="Type the phrase above..."
|
|
className={`w-full p-2 border-2 rounded-[10px] text-sm text-oliver-black focus:outline-none focus:ring-2 transition ${
|
|
confirmationError
|
|
? 'border-error focus:ring-error'
|
|
: 'border-oliver-azure focus:ring-oliver-azure focus:border-oliver-azure'
|
|
}`}
|
|
autoFocus
|
|
/>
|
|
{confirmationError && (
|
|
<p className="text-error text-xs mt-1">Please type the exact phrase to confirm.</p>
|
|
)}
|
|
<div className="flex justify-end gap-3 mt-5">
|
|
<button
|
|
onClick={handleCancelSuperAdmin}
|
|
className="px-4 py-2 text-sm font-medium text-oliver-black/60 hover:text-oliver-black border border-grey-300 rounded-full hover:bg-oliver-grey transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={handleConfirmSuperAdmin}
|
|
disabled={confirmationText !== 'make this user super admin'}
|
|
className="px-4 py-2 text-sm font-medium text-white bg-oliver-azure rounded-full hover:bg-oliver-azure/90 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed"
|
|
>
|
|
Confirm
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Change History Modal */}
|
|
{historyUser && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
|
onClick={handleCloseHistory}
|
|
>
|
|
<div
|
|
className="bg-white rounded-[10px] shadow-xl max-w-2xl w-full mx-4 p-6 max-h-[80vh] flex flex-col border-2 border-oliver-azure"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold text-oliver-black">
|
|
Change History — {historyUser.name}
|
|
</h3>
|
|
<button
|
|
onClick={handleCloseHistory}
|
|
className="p-1 text-oliver-black/60 hover:text-oliver-black transition-colors"
|
|
>
|
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div className="overflow-y-auto flex-1">
|
|
{historyLoading ? (
|
|
<div className="flex items-center justify-center py-8">
|
|
<svg className="animate-spin h-6 w-6 text-oliver-azure" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
|
</svg>
|
|
</div>
|
|
) : historyEntries.length === 0 ? (
|
|
<p className="text-center py-8 text-oliver-black/60">No change history recorded.</p>
|
|
) : (
|
|
<table className="min-w-full">
|
|
<thead className="bg-oliver-grey sticky top-0">
|
|
<tr>
|
|
<th className="px-4 py-2 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Date</th>
|
|
<th className="px-4 py-2 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Change</th>
|
|
<th className="px-4 py-2 text-left text-xs font-bold text-oliver-black uppercase tracking-wider">Changed by</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-grey-300">
|
|
{historyEntries.map((entry) => (
|
|
<tr key={entry.id}>
|
|
<td className="px-4 py-3 whitespace-nowrap text-sm text-oliver-black/60">
|
|
{formatHistoryDate(entry.created_at)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-oliver-black">
|
|
{formatChangeDescription(entry)}
|
|
</td>
|
|
<td className="px-4 py-3 whitespace-nowrap text-sm text-oliver-black/60">
|
|
{entry.changed_by_name || 'System'}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Agency Management */}
|
|
<section>
|
|
<h2 className="text-xl font-semibold text-oliver-black mb-4">Agencies ({agencies.length})</h2>
|
|
<div className="max-w-xl">
|
|
{isSuperAdmin && (
|
|
<form onSubmit={handleCreateAgency} className="flex gap-2 mb-4">
|
|
<input
|
|
type="text"
|
|
value={newAgencyName}
|
|
onChange={(e) => setNewAgencyName(e.target.value)}
|
|
placeholder="New agency name..."
|
|
className="flex-grow p-2 border-2 border-oliver-azure rounded-[10px] focus:ring-2 focus:ring-oliver-azure focus:border-oliver-azure transition text-oliver-black"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={!newAgencyName.trim() || isCreatingAgency}
|
|
className="bg-oliver-azure text-white font-semibold py-2 px-6 rounded-full hover:bg-oliver-azure/90 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed flex items-center gap-2"
|
|
>
|
|
<PlusIcon className="h-4 w-4" />
|
|
Add
|
|
</button>
|
|
</form>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
{agencies.map(agency => (
|
|
<div
|
|
key={agency.id}
|
|
className="flex items-center justify-between bg-oliver-grey p-3 rounded-[10px] border border-grey-300"
|
|
>
|
|
<span className="text-oliver-black font-medium">{agency.name}</span>
|
|
<span className="text-xs text-oliver-black/60">
|
|
{users.filter(u => u.agency_id === agency.id).length} user(s)
|
|
</span>
|
|
</div>
|
|
))}
|
|
{agencies.length === 0 && (
|
|
<div className="text-center py-6 text-oliver-black/60 bg-oliver-grey rounded-[10px]">
|
|
No agencies created yet.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
);
|
|
};
|