import React, { useState } from 'react';
import { createPortal } from 'react-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '../../api/admin';
import { AppLayout } from '../../components/layout/AppLayout';
import { PageLoader } from '../../components/ui/LoadingSpinner';
import {
Handshake,
Search,
Plus,
X,
Loader2,
Users,
Building2,
ExternalLink,
ShieldCheck,
} from 'lucide-react';
export interface PartnerRecord {
id: number;
name: string;
status: string;
user_id: number | null;
user_email: string | null;
user_name: string | null;
created_at: string;
}
const getPartners = async (): Promise<PartnerRecord[]> => {
const res = await fetch('/api/super-admin/partners', {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) throw new Error('Failed to fetch partners');
const data = await res.json();
// Backend returns { items: [...], nextCursor: null }
return (data.items ?? data.partners ?? []) as PartnerRecord[];
};
export function PartnersPage() {
const [search, setSearch] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
const queryClient = useQueryClient();
const { data: partners, isLoading } = useQuery({
queryKey: ['admin-partners'],
queryFn: getPartners,
});
const filtered = (partners ?? []).filter(
(p) =>
p.name.toLowerCase().includes(search.toLowerCase()) ||
(p.user_email ?? '').toLowerCase().includes(search.toLowerCase())
);
const createMutation = useMutation({
mutationFn: (name: string) =>
fetch('/api/super-admin/partners', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
}).then(async (res) => {
if (!res.ok) throw new Error(await res.text());
return res.json();
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-partners'] });
setShowCreateModal(false);
},
});
if (isLoading) {
return (
<AppLayout title="Partners">
<PageLoader />
</AppLayout>
);
}
return (
<AppLayout title="Partners">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-surface-900">Partners</h1>
<p className="mt-1 text-sm text-surface-500">
{partners?.length ?? 0} partner{partners?.length !== 1 ? 's' : ''} in your network
</p>
<div className="mt-2 flex items-center gap-2">
<div className="flex h-5 w-5 items-center justify-center rounded bg-blue-100">
<Handshake className="h-3 w-3 text-blue-600" />
</div>
<span className="text-xs text-surface-500">
Partners have their own isolated workspace and cannot access other companies' data
</span>
</div>
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-surface-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search partners..."
className="w-full border border-surface-300 pl-9 pr-3 py-2.5 text-sm rounded-lg outline-none focus:border-brand-500 min-h-[44px] w-full sm:w-56"
/>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="inline-flex items-center justify-center gap-2 rounded-lg bg-brand-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-brand-700 min-h-[44px] w-full sm:w-auto"
>
<Plus className="h-4 w-4" />
Add Partner
</button>
</div>
</div>
{/* Stats */}
<div className="mb-6 grid grid-cols-1 gap-4 sm:grid-cols-3">
<div className="rounded-xl border border-surface-200 bg-white p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100">
<Users className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="text-xs font-medium text-surface-500">Active Partners</p>
<p className="text-xl font-bold text-surface-900">
{(partners ?? []).filter((p) => p.status === 'active').length}
</p>
</div>
</div>
</div>
<div className="rounded-xl border border-surface-200 bg-white p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-green-100">
<ShieldCheck className="h-5 w-5 text-green-600" />
</div>
<div>
<p className="text-xs font-medium text-surface-500">Connected</p>
<p className="text-xl font-bold text-surface-900">
{(partners ?? []).filter((p) => p.user_id).length}
</p>
</div>
</div>
</div>
<div className="rounded-xl border border-surface-200 bg-white p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-amber-100">
<Building2 className="h-5 w-5 text-amber-600" />
</div>
<div>
<p className="text-xs font-medium text-surface-500">Pending</p>
<p className="text-xl font-bold text-surface-900">
{(partners ?? []).filter((p) => !p.user_id).length}
</p>
</div>
</div>
</div>
</div>
{/* Partners Grid */}
{(partners ?? []).length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-surface-200 bg-white py-16">
<Handshake className="h-12 w-12 text-surface-300" />
<p className="mt-4 text-sm font-medium text-surface-700">No partners yet</p>
<p className="mt-1 text-xs text-surface-500">Add a partner to start building your network</p>
</div>
) : filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-surface-200 bg-white py-16">
<Search className="h-12 w-12 text-surface-300" />
<p className="mt-4 text-sm font-medium text-surface-700">No partners match your search</p>
</div>
) : (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
{filtered.map((partner) => (
<div
key={partner.id}
className="group rounded-xl border border-surface-200 bg-white p-5 transition-shadow hover:shadow-md"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-blue-100 text-sm font-semibold text-blue-700">
{partner.name.charAt(0).toUpperCase()}
</div>
<div>
<h3 className="text-sm font-semibold text-surface-900">{partner.name}</h3>
<p className="text-xs text-surface-500">Partner #{partner.id}</p>
</div>
</div>
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
partner.status === 'active'
? 'bg-success/10 text-success'
: 'bg-surface-200 text-surface-600'
}`}
>
{partner.status}
</span>
</div>
{partner.user_name && (
<div className="mt-4 flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-surface-100 text-xs text-surface-600">
{partner.user_name.charAt(0).toUpperCase()}
</div>
<span className="text-sm text-surface-600">{partner.user_name}</span>
</div>
)}
{partner.user_email && (
<p className="mt-1 text-xs text-surface-500">{partner.user_email}</p>
)}
{!partner.user_id && (
<div className="mt-3 rounded-md border border-dashed border-surface-300 bg-surface-50 px-3 py-2">
<p className="text-xs text-surface-500">
No user assigned yet — create a user and assign them the Partner role
</p>
</div>
)}
<div className="mt-4 flex items-center justify-between border-t border-surface-100 pt-3">
<span className="text-xs text-surface-400">
{new Date(partner.created_at).toLocaleDateString()}
</span>
<button className="inline-flex items-center gap-1 text-xs font-medium text-brand-600 hover:text-brand-700">
View Details
<ExternalLink className="h-3 w-3" />
</button>
</div>
</div>
))}
</div>
)}
{/* Create Modal */}
{showCreateModal && (
<CreatePartnerModal
onClose={() => setShowCreateModal(false)}
onCreate={createMutation.mutate}
isPending={createMutation.isPending}
/>
)}
</AppLayout>
);
}
function CreatePartnerModal({
onClose,
onCreate,
isPending,
}: {
onClose: () => void;
onCreate: (name: string) => void;
isPending: boolean;
}) {
const [name, setName] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) {
onCreate(name.trim());
}
};
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50" onClick={onClose} />
<div className="relative w-full max-w-md rounded-xl bg-white shadow-xl">
<div className="flex items-center justify-between border-b border-surface-200 p-5">
<div className="flex items-center gap-2">
<Handshake className="h-4 w-4 text-blue-600" />
<h2 className="text-lg font-semibold text-surface-900">Add Partner</h2>
</div>
<button onClick={onClose} className="rounded-md p-1 text-surface-400 hover:text-surface-600">
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-5 space-y-4">
<div>
<label className="block text-sm font-medium text-surface-700 mb-1">Partner Name *</label>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-lg border border-surface-300 px-3 py-2 text-sm outline-none focus:border-brand-500"
placeholder="e.g. Acme Marketing Group"
/>
</div>
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3">
<p className="text-xs text-blue-700">
This creates a partner workspace. Assign a user with the Partner role to link them to this workspace.
</p>
</div>
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-surface-300 px-4 py-2 text-sm font-medium text-surface-700 hover:bg-surface-50 min-h-[44px]"
>
Cancel
</button>
<button
type="submit"
disabled={isPending}
className="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50 min-h-[44px]"
>
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
<Plus className="h-4 w-4" />
Create Partner
</button>
</div>
</form>
</div>
</div>,
document.body
);
}