feat: add superadmin tenant module

This commit is contained in:
2026-06-16 22:30:29 +05:30
parent 6c97f89b30
commit 6f4399a048
18 changed files with 1146 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
'use client';
import { useMemo } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { Edit, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { usePermissions } from '@/hooks/usePermissions';
import type { Tenant } from '@/types';
function formatDate(value?: string | null) {
if (!value) return '-';
return new Intl.DateTimeFormat('en-IN', {
day: '2-digit',
month: 'short',
year: 'numeric',
}).format(new Date(value));
}
interface UseTenantColumnsParams {
onEdit: (tenant: Tenant) => void;
onDelete: (tenant: Tenant) => void;
pendingDeleteId?: number;
}
export function useTenantColumns({
onEdit,
onDelete,
pendingDeleteId,
}: UseTenantColumnsParams): ColumnDef<Tenant>[] {
const { hasPermission } = usePermissions();
const canEdit = hasPermission(PERMISSIONS.TENANT.UPDATE);
const canDelete = hasPermission(PERMISSIONS.TENANT.DELETE);
return useMemo(() => {
const columns: ColumnDef<Tenant>[] = [
{
accessorKey: 'name',
header: 'Tenant',
cell: ({ row }) => (
<div>
<p className="font-medium">{row.original.name}</p>
<p className="text-xs text-muted-foreground">{row.original.slug}</p>
</div>
),
},
{
accessorKey: 'domain',
header: 'Domain',
cell: ({ row }) => row.original.domain || '-',
},
{
accessorKey: 'admin_email',
header: 'Admin Email',
cell: ({ row }) => row.original.admin_email || '-',
},
{
accessorKey: 'plan_id',
header: 'Plan',
cell: ({ row }) => row.original.plan_id || '-',
},
{
accessorKey: 'created_at',
header: 'Created',
cell: ({ row }) => formatDate(row.original.created_at),
},
{
accessorKey: 'is_active',
header: 'Status',
cell: ({ row }) => (
<Badge variant={row.original.is_active ? 'default' : 'secondary'}>
{row.original.is_active ? 'Active' : 'Inactive'}
</Badge>
),
},
];
if (!canEdit && !canDelete) return columns;
columns.push({
id: 'actions',
header: () => <div className="text-right">Actions</div>,
cell: ({ row }) => {
const tenant = row.original;
return (
<div className="flex justify-end gap-2">
{canEdit ? (
<Button variant="outline" size="sm" onClick={() => onEdit(tenant)}>
<Edit className="size-4" />
</Button>
) : null}
{canDelete ? (
<Button
variant="outline"
size="sm"
disabled={pendingDeleteId === tenant.id}
onClick={() => onDelete(tenant)}
>
<Trash2 className="size-4" />
</Button>
) : null}
</div>
);
},
});
return columns;
}, [canDelete, canEdit, onDelete, onEdit, pendingDeleteId]);
}

View File

@@ -0,0 +1,49 @@
'use client';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { TenantStatusFilter } from '../hooks/useTenantFilters';
interface TenantFiltersProps {
searchTerm: string;
statusFilter: TenantStatusFilter;
onSearchChange: (value: string) => void;
onStatusChange: (value: TenantStatusFilter) => void;
}
export function TenantFilters({
searchTerm,
statusFilter,
onSearchChange,
onStatusChange,
}: TenantFiltersProps) {
return (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<Input
value={searchTerm}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Search tenants"
className="md:max-w-sm"
/>
<Select
value={statusFilter}
onValueChange={(value) => onStatusChange(value as TenantStatusFilter)}
>
<SelectTrigger className="md:w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All status</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
);
}

View File

@@ -0,0 +1,233 @@
'use client';
import type { ComponentProps } from 'react';
import type { UseFormRegister } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { Client, Plan } from '@/types';
import type { TenantFormValues } from '../hooks/useTenantForm';
interface TenantSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tenantId?: number;
isEditMode: boolean;
register: UseFormRegister<TenantFormValues>;
onSubmit: ComponentProps<'form'>['onSubmit'];
clientId: string;
planId: string;
onClientChange: (clientId: string) => void;
onPlanChange: (planId: string) => void;
onNameChange: (name: string) => void;
clients: Client[];
plans: Plan[];
isLookupsLoading: boolean;
isSaving: boolean;
adminEmail?: string;
}
export function TenantSheet({
open,
onOpenChange,
tenantId,
isEditMode,
register,
onSubmit,
clientId,
planId,
onClientChange,
onPlanChange,
onNameChange,
clients,
plans,
isLookupsLoading,
isSaving,
adminEmail,
}: TenantSheetProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[calc(100vh-2rem)] overflow-y-auto sm:max-w-3xl">
<DialogHeader>
<DialogTitle>{tenantId ? 'Edit Tenant' : 'Create Tenant'}</DialogTitle>
<DialogDescription>
Bind a client and subscription plan, then invite the tenant administrator.
</DialogDescription>
</DialogHeader>
<form onSubmit={onSubmit} className="flex flex-1 flex-col gap-5 px-4">
<div className="space-y-1">
<p className="text-sm font-medium">Basic Information</p>
<p className="text-xs text-muted-foreground">Tenant identity and subscription binding.</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-name">Tenant Name</Label>
<Input
id="tenant-name"
placeholder="Acme Corp"
{...register('name', {
required: true,
onChange: (event) => onNameChange(event.target.value),
})}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug">Slug</Label>
<Input
id="tenant-slug"
placeholder="acme-corp"
{...register('slug', { required: true })}
required
readOnly={isEditMode}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Client</Label>
<Select value={clientId} onValueChange={onClientChange} disabled={isLookupsLoading}>
<SelectTrigger>
<SelectValue placeholder={isLookupsLoading ? 'Loading clients...' : 'Select client'} />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={String(client.id)}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Subscription Plan</Label>
<Select value={planId} onValueChange={onPlanChange} disabled={isLookupsLoading}>
<SelectTrigger>
<SelectValue placeholder={isLookupsLoading ? 'Loading plans...' : 'Select plan'} />
</SelectTrigger>
<SelectContent>
{plans.map((plan) => (
<SelectItem key={plan.id} value={String(plan.id)}>
{plan.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="tenant-domain">Domain</Label>
<Input id="tenant-domain" placeholder="acme.example.com" {...register('domain')} />
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="tenant-description">Description</Label>
<textarea
id="tenant-description"
placeholder="North zone operations"
{...register('description')}
className="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
/>
</div>
</div>
{!isEditMode ? (
<>
<div className="space-y-1">
<p className="text-sm font-medium">Tenant Administrator</p>
<p className="text-xs text-muted-foreground">
Invitation details for the primary tenant admin account.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="admin-first-name">First Name</Label>
<Input
id="admin-first-name"
placeholder="Jane"
{...register('admin_first_name', { required: !isEditMode })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="admin-last-name">Last Name</Label>
<Input
id="admin-last-name"
placeholder="Doe"
{...register('admin_last_name', { required: !isEditMode })}
required
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="admin-email">Admin Email</Label>
<Input
id="admin-email"
type="email"
placeholder="jane@acme.com"
{...register('admin_email', { required: !isEditMode })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="admin-phone">Phone Number</Label>
<Input
id="admin-phone"
placeholder="+91-9000011111"
{...register('admin_phone_number')}
/>
</div>
</div>
</>
) : adminEmail ? (
<div className="rounded-md border bg-muted/40 p-4 text-sm text-muted-foreground">
Admin invitation details cannot be changed after tenant creation.
<p className="mt-1 text-foreground">
Current admin email: <span className="font-medium">{adminEmail}</span>
</p>
</div>
) : null}
<DialogFooter className="px-0">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSaving}
>
Cancel
</Button>
<Button type="submit" disabled={isSaving || isLookupsLoading}>
{isSaving ? <Loader2 className="mr-2 size-4 animate-spin" /> : null}
{tenantId ? 'Update Tenant' : 'Create Tenant'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import type { ReactNode } from 'react';
import type { ColumnDef } from '@tanstack/react-table';
import { DataTable } from '@/components/data-table';
import type { Tenant } from '@/types';
interface TenantTableProps {
columns: ColumnDef<Tenant>[];
tenants: Tenant[];
isLoading: boolean;
toolbar: ReactNode;
skip: number;
limit: number;
total: number;
onPageChange: (skip: number) => void;
}
export function TenantTable({
columns,
tenants,
isLoading,
toolbar,
skip,
limit,
total,
onPageChange,
}: TenantTableProps) {
return (
<DataTable
title="Tenants"
columns={columns}
data={tenants}
isLoading={isLoading}
toolbar={toolbar}
emptyTitle="No tenants found."
pagination={{
skip,
limit,
totalItems: total,
onPageChange,
onLimitChange: () => undefined,
}}
/>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import { useCallback, useState } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
export type TenantStatusFilter = 'all' | 'active' | 'inactive';
export function useTenantFilters() {
const [skip, setSkip] = useState(0);
const [limit] = useState(10);
const [searchTerm, setSearchTermValue] = useState('');
const [statusFilter, setStatusFilterValue] = useState<TenantStatusFilter>('all');
const debouncedSearchTerm = useDebounce(searchTerm.trim(), 400);
const setSearchTerm = useCallback((value: string) => {
setSearchTermValue(value);
setSkip(0);
}, []);
const setStatusFilter = useCallback((value: TenantStatusFilter) => {
setStatusFilterValue(value);
setSkip(0);
}, []);
return {
skip,
setSkip,
limit,
searchTerm,
debouncedSearchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
};
}

View File

@@ -0,0 +1,157 @@
'use client';
import { useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import type { Tenant } from '@/types';
import { useSaveTenantMutation } from './useTenantMutations';
export interface TenantFormValues {
id?: number;
name: string;
slug: string;
client_id: string;
plan_id: string;
domain: string;
description: string;
admin_first_name: string;
admin_last_name: string;
admin_email: string;
admin_phone_number: string;
}
const defaultValues: TenantFormValues = {
name: '',
slug: '',
client_id: '',
plan_id: '',
domain: '',
description: '',
admin_first_name: '',
admin_last_name: '',
admin_email: '',
admin_phone_number: '',
};
function createSlug(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function splitContactName(contactName: string) {
const parts = contactName.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return { firstName: '', lastName: '' };
if (parts.length === 1) return { firstName: parts[0], lastName: '' };
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
}
export function useTenantForm({ onSaved }: { onSaved: () => void }) {
const {
register,
handleSubmit: submitForm,
reset,
control,
setValue,
formState: { isSubmitting },
} = useForm<TenantFormValues>({
defaultValues,
});
const saveMutation = useSaveTenantMutation({
onSaved: () => {
reset(defaultValues);
onSaved();
},
});
const tenantId = useWatch({ control, name: 'id' });
const clientId = useWatch({ control, name: 'client_id' }) || '';
const planId = useWatch({ control, name: 'plan_id' }) || '';
const adminEmail = useWatch({ control, name: 'admin_email' }) || '';
const isEditMode = Boolean(tenantId);
const handleSubmit = submitForm(
(values) => {
if (!values.client_id || !values.plan_id) {
toast.error('Select a client and plan');
return;
}
if (!isEditMode) {
if (!values.admin_first_name.trim() || !values.admin_last_name.trim() || !values.admin_email.trim()) {
toast.error('Complete all required admin fields');
return;
}
}
saveMutation.mutate(values);
},
() => toast.error('Complete all required tenant fields'),
);
const openCreate = useCallback(() => {
reset(defaultValues);
}, [reset]);
const openEdit = useCallback(
(tenant: Tenant) => {
reset({
id: tenant.id,
name: tenant.name || '',
slug: tenant.slug || '',
client_id: String(tenant.client_id ?? ''),
plan_id: String(tenant.plan_id ?? ''),
domain: tenant.domain || '',
description: tenant.description || '',
admin_first_name: '',
admin_last_name: '',
admin_email: tenant.admin_email || '',
admin_phone_number: '',
});
},
[reset],
);
const applyClientAdminDefaults = useCallback(
(contactName: string, contactEmail: string, contactPhone: string) => {
if (isEditMode) return;
const { firstName, lastName } = splitContactName(contactName);
setValue('admin_first_name', firstName, { shouldDirty: true });
setValue('admin_last_name', lastName, { shouldDirty: true });
setValue('admin_email', contactEmail, { shouldDirty: true });
setValue('admin_phone_number', contactPhone, { shouldDirty: true });
},
[isEditMode, setValue],
);
const syncSlugFromName = useCallback(
(name: string) => {
if (isEditMode) return;
setValue('slug', createSlug(name), { shouldDirty: true });
},
[isEditMode, setValue],
);
return {
register,
handleSubmit,
reset,
control,
setValue,
openCreate,
openEdit,
tenantId,
clientId,
planId,
adminEmail,
isEditMode,
applyClientAdminDefaults,
syncSlugFromName,
isSaving: isSubmitting || saveMutation.isPending,
};
}

View File

@@ -0,0 +1,77 @@
'use client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { tenantService } from '@/services/api';
import { tenantKeys } from '../queries/tenantKeys';
import type { TenantFormValues } from './useTenantForm';
function createSlug(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function optionalValue(value: string) {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
export function useSaveTenantMutation({ onSaved }: { onSaved: () => void }) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (values: TenantFormValues) => {
const basePayload = {
name: values.name.trim(),
slug: values.slug.trim() || createSlug(values.name),
client_id: Number(values.client_id),
plan_id: Number(values.plan_id),
domain: optionalValue(values.domain),
description: optionalValue(values.description),
};
if (values.id) {
return tenantService.updateTenant({
id: values.id,
...basePayload,
});
}
return tenantService.createTenant({
...basePayload,
admin: {
first_name: values.admin_first_name.trim(),
last_name: values.admin_last_name.trim(),
email: values.admin_email.trim(),
phone_number: optionalValue(values.admin_phone_number),
},
});
},
onSuccess: (_data, values) => {
toast.success(values.id ? 'Tenant updated' : 'Tenant created');
queryClient.invalidateQueries({ queryKey: tenantKeys.lists() });
onSaved();
},
onError: (_error, values) => {
toast.error(values.id ? 'Failed to update tenant' : 'Failed to create tenant');
},
});
}
export function useDeleteTenantMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (tenantId: number) => tenantService.deleteTenant(tenantId),
onSuccess: () => {
toast.success('Tenant deleted');
queryClient.invalidateQueries({ queryKey: tenantKeys.lists() });
},
onError: () => toast.error('Failed to delete tenant'),
});
}

View File

@@ -0,0 +1,82 @@
'use client';
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { clientService, planService, tenantService } from '@/services/api';
import type { TenantListParams } from '@/types';
import { tenantKeys, tenantLookupKeys } from '../queries/tenantKeys';
import type { TenantStatusFilter } from './useTenantFilters';
interface UseTenantsQueryParams {
skip: number;
limit: number;
searchTerm: string;
statusFilter: TenantStatusFilter;
}
const clientLookupParams = {
skip: 0,
limit: 100,
is_active: true,
sort_by: 'name',
sort_order: 'asc',
} as const;
const planLookupParams = {
skip: 0,
limit: 100,
is_active: true,
sort_by: 'name',
sort_order: 'asc',
} as const;
function buildTenantListParams({
skip,
limit,
searchTerm,
statusFilter,
}: UseTenantsQueryParams): TenantListParams {
return {
skip,
limit,
search_term: searchTerm || undefined,
is_active: statusFilter === 'all' ? undefined : statusFilter === 'active',
sort_by: 'created_at',
sort_order: 'desc',
};
}
export function useTenantsQuery(params: UseTenantsQueryParams) {
const listParams = useMemo(() => buildTenantListParams(params), [params]);
return useQuery({
queryKey: tenantKeys.list(listParams),
queryFn: () => tenantService.getTenants(listParams),
});
}
export function useClientLookupQuery(enabled: boolean) {
return useQuery({
queryKey: tenantLookupKeys.clients,
queryFn: () => clientService.getClients(clientLookupParams),
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnMount: false,
refetchOnReconnect: false,
});
}
export function usePlanLookupQuery(enabled: boolean) {
return useQuery({
queryKey: tenantLookupKeys.plans,
queryFn: () => planService.getPlans(planLookupParams),
enabled,
staleTime: 5 * 60 * 1000,
gcTime: 30 * 60 * 1000,
refetchOnMount: false,
refetchOnReconnect: false,
});
}

View File

@@ -0,0 +1,191 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import { Globe, Plus } from 'lucide-react';
import { PageHeader } from '@/components/page-header';
import { PoweredBy } from '@/components/powered-by';
import { Button } from '@/components/ui/button';
import { PERMISSIONS } from '@/constants/permissions';
import { PermissionGuard } from '@/guards';
import { clientService } from '@/services/api';
import type { Tenant } from '@/types';
import { useTenantColumns } from './components/TenantColumns';
import { TenantFilters } from './components/TenantFilters';
import { TenantSheet } from './components/TenantSheet';
import { TenantTable } from './components/TenantTable';
import { useTenantFilters } from './hooks/useTenantFilters';
import { useTenantForm } from './hooks/useTenantForm';
import { useDeleteTenantMutation } from './hooks/useTenantMutations';
import {
useClientLookupQuery,
usePlanLookupQuery,
useTenantsQuery,
} from './hooks/useTenantQueries';
export default function TenantsPage() {
const [isSheetOpen, setIsSheetOpen] = useState(false);
const {
skip,
setSkip,
limit,
searchTerm,
debouncedSearchTerm,
setSearchTerm,
statusFilter,
setStatusFilter,
} = useTenantFilters();
const tenantsQuery = useTenantsQuery({
skip,
limit,
searchTerm: debouncedSearchTerm,
statusFilter,
});
const clientsLookupQuery = useClientLookupQuery(isSheetOpen);
const plansLookupQuery = usePlanLookupQuery(isSheetOpen);
const tenantForm = useTenantForm({
onSaved: () => setIsSheetOpen(false),
});
const {
register,
handleSubmit,
openCreate: prepareCreateTenant,
openEdit: prepareEditTenant,
tenantId,
clientId,
planId,
adminEmail,
isEditMode,
applyClientAdminDefaults,
syncSlugFromName,
setValue,
isSaving,
} = tenantForm;
const deleteMutation = useDeleteTenantMutation();
const handleClientChange = useCallback(
(value: string) => {
setValue('client_id', value, { shouldDirty: true, shouldValidate: true });
if (!value || isEditMode) return;
clientService.getClientById(Number(value)).then((client) => {
applyClientAdminDefaults(
client.contact_name,
client.contact_email,
client.contact_phone_number,
);
});
},
[applyClientAdminDefaults, isEditMode, setValue],
);
const handlePlanChange = useCallback(
(value: string) => {
setValue('plan_id', value, { shouldDirty: true, shouldValidate: true });
},
[setValue],
);
const openCreate = useCallback(() => {
prepareCreateTenant();
setIsSheetOpen(true);
}, [prepareCreateTenant]);
const openEdit = useCallback(
(tenant: Tenant) => {
prepareEditTenant(tenant);
setIsSheetOpen(true);
},
[prepareEditTenant],
);
const handleDelete = useCallback(
(tenant: Tenant) => {
const confirmed = window.confirm(`Delete tenant "${tenant.name}"?`);
if (!confirmed) return;
deleteMutation.mutate(tenant.id);
},
[deleteMutation],
);
const columns = useTenantColumns({
onEdit: openEdit,
onDelete: handleDelete,
pendingDeleteId: deleteMutation.isPending ? deleteMutation.variables : undefined,
});
const tenants = tenantsQuery.data?.items ?? [];
const total = tenantsQuery.data?.total ?? 0;
const clients = clientsLookupQuery.data?.items ?? [];
const plans = plansLookupQuery.data?.items ?? [];
const isLookupsLoading = clientsLookupQuery.isLoading || plansLookupQuery.isLoading;
const toolbar = useMemo(
() => (
<TenantFilters
searchTerm={searchTerm}
statusFilter={statusFilter}
onSearchChange={setSearchTerm}
onStatusChange={setStatusFilter}
/>
),
[searchTerm, setSearchTerm, setStatusFilter, statusFilter],
);
return (
<>
<main className="relative z-10 space-y-5">
<PageHeader
title="Tenant Management"
description="Manage tenants, domains, and administrator invitations"
icon={Globe}
actions={
<PermissionGuard permissions={PERMISSIONS.TENANT.CREATE}>
<Button onClick={openCreate}>
<Plus className="mr-2 size-4" />
Add Tenant
</Button>
</PermissionGuard>
}
/>
<TenantTable
columns={columns}
tenants={tenants}
isLoading={tenantsQuery.isLoading}
toolbar={toolbar}
skip={skip}
limit={limit}
total={total}
onPageChange={setSkip}
/>
<PoweredBy />
</main>
<TenantSheet
open={isSheetOpen}
onOpenChange={setIsSheetOpen}
tenantId={tenantId}
isEditMode={isEditMode}
register={register}
onSubmit={handleSubmit}
clientId={clientId}
planId={planId}
onClientChange={handleClientChange}
onPlanChange={handlePlanChange}
onNameChange={syncSlugFromName}
clients={clients}
plans={plans}
isLookupsLoading={isLookupsLoading}
isSaving={isSaving}
adminEmail={adminEmail}
/>
</>
);
}

View File

@@ -0,0 +1,12 @@
import type { TenantListParams } from '@/types';
export const tenantKeys = {
all: ['tenants'] as const,
lists: () => [...tenantKeys.all, 'list'] as const,
list: (params: TenantListParams) => [...tenantKeys.lists(), params] as const,
};
export const tenantLookupKeys = {
clients: ['tenant-lookup', 'clients'] as const,
plans: ['tenant-lookup', 'plans'] as const,
};