218 lines
8.0 KiB
TypeScript
218 lines
8.0 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
import api from '@/lib/api';
|
|
|
|
interface TenantUser {
|
|
id: string;
|
|
email: string;
|
|
firstName: string;
|
|
lastName: string;
|
|
isActive: boolean;
|
|
roles: { role: string }[];
|
|
tenantRoles: { tenantRole: { name: string; slug: string } }[];
|
|
}
|
|
|
|
interface Tenant {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
isActive: boolean;
|
|
settings: Record<string, any>;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
users: TenantUser[];
|
|
_count: {
|
|
clients: number;
|
|
subscriptions: number;
|
|
invoices: number;
|
|
payments: number;
|
|
tickets: number;
|
|
};
|
|
totalRevenue: number;
|
|
}
|
|
|
|
export default function TenantDetailPage() {
|
|
const params = useParams();
|
|
const router = useRouter();
|
|
const tenantId = params.id as string;
|
|
|
|
const [tenant, setTenant] = useState<Tenant | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [impersonating, setImpersonating] = useState(false);
|
|
|
|
useEffect(() => { loadTenant(); }, [tenantId]);
|
|
|
|
async function loadTenant() {
|
|
setLoading(true);
|
|
try {
|
|
const res = await api.get(`/tenants/${tenantId}`);
|
|
setTenant(res.data.data);
|
|
} catch {
|
|
// tenant not found
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
async function handleToggleActive() {
|
|
if (!tenant) return;
|
|
const action = tenant.isActive ? 'deactivate' : 'activate';
|
|
try {
|
|
await api.patch(`/tenants/${tenantId}/${action}`);
|
|
loadTenant();
|
|
} catch (err) {
|
|
console.error(`Failed to ${action} tenant:`, err);
|
|
}
|
|
}
|
|
|
|
async function handleImpersonate() {
|
|
setImpersonating(true);
|
|
try {
|
|
const adminId = (JSON.parse(localStorage.getItem('admin_user') || '{}')).id;
|
|
const adminName = 'Super Admin';
|
|
const res = await api.post(`/impersonate/${tenantId}`, { adminId, adminName });
|
|
const { accessToken, impersonatedUser, tenant: t } = res.data.data;
|
|
// Open tenant app with impersonation token
|
|
const url = new URL('http://localhost:3000/impersonate');
|
|
url.searchParams.set('token', accessToken);
|
|
url.searchParams.set('user', JSON.stringify(impersonatedUser));
|
|
url.searchParams.set('tenant', JSON.stringify(t));
|
|
window.open(url.toString(), '_blank');
|
|
} catch (err) {
|
|
console.error('Failed to impersonate:', err);
|
|
} finally {
|
|
setImpersonating(false);
|
|
}
|
|
}
|
|
|
|
if (loading) return <div className="p-8 text-center text-surface-400">Loading...</div>;
|
|
if (!tenant) return <div className="p-8 text-center text-surface-400">Tenant not found</div>;
|
|
|
|
const statCards = [
|
|
{ label: 'Users', value: tenant.users.length },
|
|
{ label: 'Clients', value: tenant._count.clients },
|
|
{ label: 'Subscriptions', value: tenant._count.subscriptions },
|
|
{ label: 'Invoices', value: tenant._count.invoices },
|
|
{ label: 'Payments', value: tenant._count.payments },
|
|
{ label: 'Tickets', value: tenant._count.tickets },
|
|
{ label: 'Revenue', value: `₱${Number(tenant.totalRevenue).toLocaleString()}` },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<button
|
|
onClick={() => router.back()}
|
|
className="text-sm text-surface-500 hover:text-surface-700"
|
|
>
|
|
← Back to tenants
|
|
</button>
|
|
|
|
{/* Header */}
|
|
<div className="bg-white rounded-xl border border-surface-200 p-6">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-xl font-semibold text-surface-900">{tenant.name}</h1>
|
|
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
|
|
tenant.isActive ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
|
}`}>
|
|
{tenant.isActive ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-surface-500 mt-1">{tenant.slug}</p>
|
|
<p className="text-xs text-surface-400 mt-2">
|
|
Created {new Date(tenant.createdAt).toLocaleDateString()} · Updated {new Date(tenant.updatedAt).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handleToggleActive}
|
|
className={`px-3 py-2 text-sm rounded-lg ${
|
|
tenant.isActive
|
|
? 'bg-red-50 text-red-700 hover:bg-red-100'
|
|
: 'bg-green-50 text-green-700 hover:bg-green-100'
|
|
}`}
|
|
>
|
|
{tenant.isActive ? 'Deactivate' : 'Activate'}
|
|
</button>
|
|
<button
|
|
onClick={handleImpersonate}
|
|
disabled={impersonating || !tenant.isActive}
|
|
className="px-3 py-2 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50"
|
|
>
|
|
{impersonating ? 'Opening...' : 'Impersonate'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-7 gap-3">
|
|
{statCards.map((card) => (
|
|
<div key={card.label} className="bg-white rounded-xl border border-surface-200 p-4">
|
|
<p className="text-xs text-surface-500">{card.label}</p>
|
|
<p className="text-lg font-bold text-surface-900 mt-1">{card.value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Users */}
|
|
<div className="bg-white rounded-xl border border-surface-200 p-5">
|
|
<h3 className="text-base font-semibold text-surface-900 mb-4">Users ({tenant.users.length})</h3>
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-surface-200 bg-surface-50">
|
|
<th className="text-left px-4 py-2 font-medium text-surface-600">Name</th>
|
|
<th className="text-left px-4 py-2 font-medium text-surface-600">Email</th>
|
|
<th className="text-left px-4 py-2 font-medium text-surface-600">Roles</th>
|
|
<th className="text-center px-4 py-2 font-medium text-surface-600">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{tenant.users.map((u) => (
|
|
<tr key={u.id} className="border-b border-surface-100 hover:bg-surface-50">
|
|
<td className="px-4 py-2 font-medium">{u.firstName} {u.lastName}</td>
|
|
<td className="px-4 py-2 text-surface-500">{u.email}</td>
|
|
<td className="px-4 py-2">
|
|
<div className="flex flex-wrap gap-1">
|
|
{u.roles.map((r, i) => (
|
|
<span key={i} className="text-xs px-1.5 py-0.5 bg-surface-100 rounded">{r.role}</span>
|
|
))}
|
|
{u.tenantRoles.map((tr, i) => (
|
|
<span key={i} className="text-xs px-1.5 py-0.5 bg-primary-50 text-primary-700 rounded">
|
|
{tr.tenantRole.name}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-2 text-center">
|
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
u.isActive ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
|
|
}`}>
|
|
{u.isActive ? 'Active' : 'Inactive'}
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{/* Settings */}
|
|
<div className="bg-white rounded-xl border border-surface-200 p-5">
|
|
<h3 className="text-base font-semibold text-surface-900 mb-4">Settings</h3>
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
{tenant.settings && Object.entries(tenant.settings).map(([key, value]) => (
|
|
<div key={key}>
|
|
<span className="text-surface-500 capitalize">{key.replace(/([A-Z])/g, ' $1')}:</span>
|
|
<span className="ml-2 text-surface-900 font-medium">{String(value)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|