Tech4biz-channel/Channel-Frontend/src/pages/admin/DirectoryPage.tsx

372 lines
21 KiB
TypeScript

import React, { useState } from 'react';
import { Users, Mail, CheckCircle, AlertCircle, ChevronRight, UserPlus, Clock, ShieldCheck, RefreshCw } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Link } from 'react-router-dom';
import { usePartnersQuery } from '../../hooks/use-partners-query';
import { useInvitePartnerMutation } from '../../hooks/use-partners-mutation';
import PageHeader from '../../components/ui/PageHeader';
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
PENDING_ONBOARDING: {
label: 'Pending Onboarding',
color: 'text-ink-500',
bg: 'bg-ink-50',
border: 'border-ink-200',
},
PENDING_APPROVAL: {
label: 'Awaiting Approval',
color: 'text-ink-0 bg-ink-900',
bg: 'bg-ink-900',
border: 'border-ink-800',
},
APPROVED: {
label: 'Active',
color: 'text-ink-900 font-extrabold',
bg: 'bg-ink-100',
border: 'border-ink-300',
},
};
const getStatusConfig = (status: string) => STATUS_CONFIG[status] ?? {
label: status,
color: 'text-ink-500',
bg: 'bg-ink-100',
border: 'border-ink-200',
};
export const DirectoryPage: React.FC = () => {
const [email, setEmail] = useState('');
const [inviteResult, setInviteResult] = useState<{ token?: string; error?: string } | null>(null);
const [isInviteOpen, setIsInviteOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 5;
const { data: partners = [], isLoading: loadingPartners, refetch: fetchPartners } = usePartnersQuery();
const inviteMutation = useInvitePartnerMutation();
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
setInviteResult(null);
inviteMutation.mutate(email, {
onSuccess: (data) => {
setInviteResult({ token: data.token });
setEmail('');
},
onError: (err: any) => {
setInviteResult({ error: err.response?.data?.error || 'Failed to send invite' });
}
});
};
const counts = {
total: partners.length,
active: partners.filter(p => p.onboardingStatus === 'APPROVED').length,
pendingOnboarding: partners.filter(p => p.onboardingStatus === 'PENDING_ONBOARDING').length,
awaitingApproval: partners.filter(p => p.onboardingStatus === 'PENDING_APPROVAL').length,
};
const statCards = [
{ label: 'Total Partners', value: counts.total, icon: Users, accentClass: '' },
{ label: 'Active', value: counts.active, icon: ShieldCheck, accentClass: '' },
{ label: 'Pending Onboarding', value: counts.pendingOnboarding, icon: Clock, accentClass: '' },
];
if (counts.awaitingApproval > 0) {
statCards.push({
label: 'Awaiting Approval',
value: counts.awaitingApproval,
icon: AlertCircle,
accentClass: 'border-ink-900/30 bg-ink-900/5 shadow-[0_0_15px_rgba(0,0,0,0.01)] border-dashed animate-pulse'
});
}
const totalItems = partners.length;
const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE) || 1;
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, totalItems);
const paginatedPartners = partners.slice(startIndex, endIndex);
return (
<div className="w-full space-y-6 animate-fade-in text-ink-900">
<PageHeader
title="Partner Directory"
subtitle="Manage your network and invite new partners to the platform."
actions={
<>
<button
onClick={() => {
setInviteResult(null);
setEmail('');
setIsInviteOpen(true);
}}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-all cursor-pointer shadow-sm"
>
<UserPlus className="w-4 h-4" />
<span>Invite Partner</span>
</button>
<button
onClick={() => { fetchPartners(); }}
className="p-1.5 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group cursor-pointer"
title="Refresh"
>
<RefreshCw className="w-4 h-4 group-hover:rotate-180 transition-transform duration-500" />
</button>
</>
}
/>
{/* Dynamic Real-time Approval Notification Banner */}
{counts.awaitingApproval > 0 && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between p-4 bg-ink-900 border border-ink-800 rounded-xl shadow-sm text-ink-0 relative overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-r from-ink-800 via-ink-900 to-ink-800 opacity-50" />
<div className="flex items-center gap-3 relative z-10">
<div className="w-8 h-8 rounded-lg bg-ink-0/10 flex items-center justify-center text-ink-0 shrink-0">
<AlertCircle className="w-4 h-4 animate-bounce" />
</div>
<div>
<h4 className="font-bold text-sm text-ink-0">Partner approvals pending</h4>
<p className="text-xs text-ink-300 mt-0.5">There are {counts.awaitingApproval} partners awaiting document review and access authorization.</p>
</div>
</div>
<Link
to="/admin/approvals"
className="relative z-10 px-3 py-1.5 bg-ink-0 hover:bg-ink-100 text-ink-900 text-xs font-bold rounded-lg transition-all shadow-sm flex items-center gap-1.5 shrink-0"
>
Review Queue
<ChevronRight className="w-3.5 h-3.5" />
</Link>
</motion.div>
)}
{/* Stats Row */}
<div className={`grid gap-4 ${counts.awaitingApproval > 0 ? 'grid-cols-2 md:grid-cols-4' : 'grid-cols-1 md:grid-cols-3'}`}>
{statCards.map((stat, i) => (
<div key={i} className={`bg-ink-0 rounded-xl border p-4 shadow-sm transition-all duration-300 ${stat.accentClass || 'border-ink-200'}`}>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-500">{stat.label}</p>
<stat.icon className={`w-4 h-4 ${stat.accentClass ? 'text-ink-900 animate-pulse' : 'text-ink-400'}`} />
</div>
<p className="text-xl font-bold tracking-tight text-ink-900">{stat.value}</p>
</div>
))}
</div>
{/* Partner List Table (Full Width) */}
<div className="bg-ink-0 rounded-xl border border-ink-200 shadow-sm overflow-hidden w-full">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm whitespace-nowrap">
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs">
<tr>
<th className="px-4 py-2.5">Partner</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5">MFA</th>
<th className="px-4 py-2.5">Joined</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-200">
{loadingPartners ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center">
<div className="w-6 h-6 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin mx-auto" />
</td>
</tr>
) : partners.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center">
<div className="w-12 h-12 bg-ink-50 rounded-full flex items-center justify-center mx-auto mb-3">
<Users className="w-6 h-6 text-ink-400" />
</div>
<p className="text-sm font-bold text-ink-900">No partners yet</p>
<p className="text-xs text-ink-500 mt-1">Use the invite button to add your first partner.</p>
</td>
</tr>
) : (
paginatedPartners.map(partner => {
const sc = getStatusConfig(partner.onboardingStatus);
return (
<tr key={partner.id} className="hover:bg-ink-50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
{partner.email.charAt(0).toUpperCase()}
</div>
<span className="font-bold text-ink-900 text-sm">{partner.email}</span>
</div>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-md border ${sc.color} ${sc.bg} ${sc.border}`}>
{sc.label}
</span>
</td>
<td className="px-4 py-3">
{partner.mfaEnabled ? (
<span className="text-xs font-bold text-ink-900">Enabled</span>
) : (
<span className="text-xs font-bold text-ink-400">Disabled</span>
)}
</td>
<td className="px-4 py-3 text-xs text-ink-500 font-medium">
{new Date(partner.createdAt).toLocaleDateString()}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination Controls */}
{totalItems > 0 && (
<div className="px-4 py-3 border-t border-ink-200 bg-ink-50 flex flex-col sm:flex-row justify-between items-center gap-4 text-xs font-semibold text-ink-500">
<p>
Showing <span className="text-ink-900 font-extrabold">{startIndex + 1}-{endIndex}</span> of <span className="text-ink-900 font-extrabold">{totalItems}</span> partners
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className="px-2 py-1 rounded-md border border-ink-200 bg-ink-0 hover:bg-ink-100 disabled:opacity-50 disabled:hover:bg-ink-0 transition-all font-bold text-ink-900 cursor-pointer"
>
Previous
</button>
{Array.from({ length: totalPages }).map((_, idx) => {
const pageNum = idx + 1;
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`w-7 h-7 rounded-md flex items-center justify-center font-bold transition-all cursor-pointer ${
currentPage === pageNum
? 'bg-ink-900 text-ink-0 shadow-sm'
: 'border border-ink-200 bg-ink-0 hover:bg-ink-100 text-ink-500 hover:text-ink-900'
}`}
>
{pageNum}
</button>
);
})}
<button
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
className="px-2 py-1 rounded-md border border-ink-200 bg-ink-0 hover:bg-ink-100 disabled:opacity-50 disabled:hover:bg-ink-0 transition-all font-bold text-ink-900 cursor-pointer"
>
Next
</button>
</div>
</div>
)}
</div>
{/* Invite Modal Overlay */}
<AnimatePresence>
{isInviteOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setIsInviteOpen(false)}
className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ type: 'spring', damping: 25, stiffness: 350 }}
className="bg-ink-0 border border-ink-200 rounded-xl shadow-xl p-5 max-w-md w-full relative z-10 overflow-hidden text-ink-900"
>
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-ink-300 to-transparent" />
<div className="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
<UserPlus className="w-24 h-24 text-ink-900" />
</div>
<h3 className="text-lg font-bold text-ink-900 mb-2 tracking-tight">Invite Partner</h3>
<p className="text-xs text-ink-500 mb-6 font-medium">
Generate a secure invitation link for a new partner.
</p>
{!inviteResult?.token ? (
<form onSubmit={handleInvite} className="space-y-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Email Address</label>
<div className="relative">
<Mail className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="partner@company.com"
required
className="w-full pl-9 pr-4 py-2 bg-ink-50 border border-ink-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 transition-all font-semibold"
/>
</div>
</div>
{inviteResult?.error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
<AlertCircle className="w-4 h-4 text-red-650 shrink-0 mt-0.5" />
<p className="text-xs font-bold text-red-650">{inviteResult.error}</p>
</div>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
onClick={() => setIsInviteOpen(false)}
className="flex-1 py-2.5 rounded-lg border border-ink-200 text-ink-700 font-bold text-sm hover:bg-ink-50 transition-all cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={inviteMutation.isPending || !email}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 transition-all disabled:opacity-50 cursor-pointer"
>
{inviteMutation.isPending ? 'Generating...' : 'Generate'}
<ChevronRight className="w-4 h-4" />
</button>
</div>
</form>
) : (
<div className="space-y-6">
<div className="p-3 bg-ink-100 border border-ink-300 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-4 h-4 text-ink-900" />
<span className="text-xs font-bold text-ink-900">Invite Created!</span>
</div>
<p className="text-xs text-ink-500 mb-2 font-medium">Send this secure link to the partner:</p>
<div className="p-2.5 bg-ink-0 border border-ink-300 rounded-lg text-xs break-all font-mono text-ink-900 select-all">
{window.location.origin}/invite?token={inviteResult.token}
</div>
</div>
<button
type="button"
onClick={() => {
setIsInviteOpen(false);
setEmail('');
setInviteResult(null);
}}
className="w-full py-2.5 rounded-lg bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 transition-all cursor-pointer"
>
Done
</button>
</div>
)}
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
};
export default DirectoryPage;