initial: standalone repo from monorepo split
This commit is contained in:
217
src/app/(admin)/tenants/[id]/page.tsx
Normal file
217
src/app/(admin)/tenants/[id]/page.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user