initial: standalone repo from monorepo split

This commit is contained in:
kevin-asprec
2026-04-13 09:36:43 +08:00
commit be0b7d50d2
39 changed files with 2952 additions and 0 deletions

View 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"
>
&larr; 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()} &middot; 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>
);
}