Fine tuned organisation onboarding and tenant management #130
@@ -94,6 +94,8 @@ const organisationSchema = z.object({
|
|||||||
name: z.string().min(3, 'Name must be at least 3 characters'),
|
name: z.string().min(3, 'Name must be at least 3 characters'),
|
||||||
registrationNo: z.string().min(4, 'Registration number is mandatory'),
|
registrationNo: z.string().min(4, 'Registration number is mandatory'),
|
||||||
email: z.string().email('Enter a valid email address'),
|
email: z.string().email('Enter a valid email address'),
|
||||||
|
idNumber: z.string().min(4, 'Government issued ID number is required'),
|
||||||
|
organisationUser: z.string().min(1, 'Please select a linked user'),
|
||||||
type: z.coerce.number().min(1).max(27),
|
type: z.coerce.number().min(1).max(27),
|
||||||
status: z.coerce.number().optional(),
|
status: z.coerce.number().optional(),
|
||||||
});
|
});
|
||||||
@@ -422,6 +424,8 @@ export const GovernancePage: React.FC = () => {
|
|||||||
name: '',
|
name: '',
|
||||||
registrationNo: '',
|
registrationNo: '',
|
||||||
email: '',
|
email: '',
|
||||||
|
idNumber: '',
|
||||||
|
organisationUser: '',
|
||||||
type: OrganisationType.Enterprise,
|
type: OrganisationType.Enterprise,
|
||||||
status: ActivityStatus.Active,
|
status: ActivityStatus.Active,
|
||||||
},
|
},
|
||||||
@@ -456,11 +460,20 @@ export const GovernancePage: React.FC = () => {
|
|||||||
name: data.name,
|
name: data.name,
|
||||||
registrationNo: data.registrationNo,
|
registrationNo: data.registrationNo,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
|
idNumber: data.idNumber,
|
||||||
|
organisationUser: data.organisationUser,
|
||||||
type: Number(data.type),
|
type: Number(data.type),
|
||||||
status: data.status !== undefined ? Number(data.status) : undefined,
|
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return createOrganisation(data as CreateOrganisationRequest);
|
return createOrganisation({
|
||||||
|
name: data.name,
|
||||||
|
registrationNo: data.registrationNo,
|
||||||
|
email: data.email,
|
||||||
|
idNumber: data.idNumber,
|
||||||
|
organisationUser: data.organisationUser,
|
||||||
|
type: Number(data.type),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['organisations'] });
|
queryClient.invalidateQueries({ queryKey: ['organisations'] });
|
||||||
@@ -537,10 +550,14 @@ export const GovernancePage: React.FC = () => {
|
|||||||
|
|
||||||
const openCreateOrg = () => {
|
const openCreateOrg = () => {
|
||||||
setEditingOrg(null);
|
setEditingOrg(null);
|
||||||
|
const defaultUserId = usersDirectory[0]?.id || sub || currentUserId;
|
||||||
|
const defaultUser = usersDirectory.find((u) => u.id === defaultUserId);
|
||||||
orgForm.reset({
|
orgForm.reset({
|
||||||
name: '',
|
name: '',
|
||||||
registrationNo: '',
|
registrationNo: '',
|
||||||
email: '',
|
email: '',
|
||||||
|
idNumber: defaultUser?.idNumber || '',
|
||||||
|
organisationUser: defaultUserId,
|
||||||
type: OrganisationType.Enterprise,
|
type: OrganisationType.Enterprise,
|
||||||
status: ActivityStatus.Active,
|
status: ActivityStatus.Active,
|
||||||
});
|
});
|
||||||
@@ -549,10 +566,14 @@ export const GovernancePage: React.FC = () => {
|
|||||||
|
|
||||||
const openEditOrg = (org: Organisation) => {
|
const openEditOrg = (org: Organisation) => {
|
||||||
setEditingOrg(org);
|
setEditingOrg(org);
|
||||||
|
const linkedUserId = org.userId || org.createdBy || usersDirectory[0]?.id || currentUserId;
|
||||||
|
const matchedUser = usersDirectory.find((u) => u.id === linkedUserId);
|
||||||
orgForm.reset({
|
orgForm.reset({
|
||||||
name: org.name || '',
|
name: org.name || '',
|
||||||
registrationNo: org.registrationNo || '',
|
registrationNo: org.registrationNo || '',
|
||||||
email: org.email || '',
|
email: org.email || '',
|
||||||
|
idNumber: matchedUser?.idNumber || (org as any).idNumber || '',
|
||||||
|
organisationUser: linkedUserId,
|
||||||
type: org.type || OrganisationType.Enterprise,
|
type: org.type || OrganisationType.Enterprise,
|
||||||
status: org.status ?? ActivityStatus.Active,
|
status: org.status ?? ActivityStatus.Active,
|
||||||
});
|
});
|
||||||
@@ -1412,6 +1433,65 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
|
Linked User Account *
|
||||||
|
</label>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Designated Organisation User"
|
||||||
|
content="Select the designated user account associated with this organisation profile."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
{...orgForm.register('organisationUser', {
|
||||||
|
onChange: (e) => {
|
||||||
|
const selected = usersDirectory.find((u) => u.id === e.target.value);
|
||||||
|
if (selected?.idNumber && !orgForm.getValues('idNumber')) {
|
||||||
|
orgForm.setValue('idNumber', selected.idNumber);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
|
||||||
|
>
|
||||||
|
{usersDirectory.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.email || u.id} {u.role ? `(${u.role})` : ''} {u.idNumber ? `• ID: ${u.idNumber}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{orgForm.formState.errors.organisationUser && (
|
||||||
|
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.organisationUser.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1 mb-1">
|
||||||
|
<label className="block text-xs font-semibold text-slate-300">
|
||||||
|
Government Issued ID Number *
|
||||||
|
</label>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Government Issued ID"
|
||||||
|
content="National identification document number or statutory passport ID of the linked user."
|
||||||
|
size="sm"
|
||||||
|
placement="top"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
{...orgForm.register('idNumber')}
|
||||||
|
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 font-mono text-white bg-[#090d16]"
|
||||||
|
placeholder="e.g. 8401015800085 or Passport Number"
|
||||||
|
/>
|
||||||
|
{orgForm.formState.errors.idNumber && (
|
||||||
|
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.idNumber.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-1 mb-1">
|
<div className="flex items-center gap-1 mb-1">
|
||||||
@@ -2103,6 +2183,12 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Entity Type:</span>
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Entity Type:</span>
|
||||||
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
|
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Linked User:</span>
|
||||||
|
<span className="text-blue-400 font-mono text-[11px] truncate">
|
||||||
|
{getUserEmail(orgFreshDetail?.userId || selectedOrgForDetail?.userId) || orgFreshDetail?.userId || selectedOrgForDetail?.userId || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -441,6 +441,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
const userExportColumns: ExportColumn<User>[] = [
|
const userExportColumns: ExportColumn<User>[] = [
|
||||||
{ header: 'User Email', key: 'email', format: (val, u) => val || getUserEmail(u.id) || '—' },
|
{ header: 'User Email', key: 'email', format: (val, u) => val || getUserEmail(u.id) || '—' },
|
||||||
{ header: 'User Identifier', key: 'id' },
|
{ header: 'User Identifier', key: 'id' },
|
||||||
|
{ header: 'Id Number', key: 'idNumber', format: (val) => val || '—' },
|
||||||
{ header: 'Role', key: 'role', format: (val) => getUserRoleBadge(val).label },
|
{ header: 'Role', key: 'role', format: (val) => getUserRoleBadge(val).label },
|
||||||
{ header: 'Status', key: 'status', format: (val) => getActivityStatusBadge(val).label },
|
{ header: 'Status', key: 'status', format: (val) => getActivityStatusBadge(val).label },
|
||||||
{ header: 'Last Login', key: 'lastLoginAt', format: (val) => (val ? formatDate(val) : 'Never') },
|
{ header: 'Last Login', key: 'lastLoginAt', format: (val) => (val ? formatDate(val) : 'Never') },
|
||||||
@@ -770,6 +771,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<SortableTh column="id" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
|
<SortableTh column="id" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
|
||||||
User Identifier
|
User Identifier
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
|
<SortableTh column="idNumber" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
|
||||||
|
Id Number
|
||||||
|
</SortableTh>
|
||||||
<SortableTh column="role" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
|
<SortableTh column="role" currentColumn={userSortCol} direction={userSortDir} onSort={handleUserSort} className="py-3.5 px-4">
|
||||||
Role
|
Role
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
@@ -785,13 +789,13 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<tbody className="divide-y divide-slate-800/50">
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
{loadingUsers ? (
|
{loadingUsers ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||||
Loading user directory...
|
Loading user directory...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : sortedUsers.length === 0 ? (
|
) : sortedUsers.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-8 text-center text-slate-500">
|
<td colSpan={7} className="py-8 text-center text-slate-500">
|
||||||
No users found matching current filters.
|
No users found matching current filters.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -806,6 +810,9 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
|
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
|
||||||
{u.id}
|
{u.id}
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-3.5 px-4 font-mono text-slate-300 text-[11px]">
|
||||||
|
{u.idNumber || <span className="text-slate-600">—</span>}
|
||||||
|
</td>
|
||||||
<td className="py-3.5 px-4">
|
<td className="py-3.5 px-4">
|
||||||
<UserRoleBadge role={u.role} />
|
<UserRoleBadge role={u.role} />
|
||||||
</td>
|
</td>
|
||||||
@@ -2113,6 +2120,12 @@ export const SettingsPage: React.FC = () => {
|
|||||||
{userFreshDetail?.id || selectedUserForDetail.id}
|
{userFreshDetail?.id || selectedUserForDetail.id}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Id Number:</span>
|
||||||
|
<span className="font-mono text-slate-200 text-xs">
|
||||||
|
{userFreshDetail?.idNumber || selectedUserForDetail.idNumber || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-1">System Role:</span>
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-1">System Role:</span>
|
||||||
<UserRoleBadge role={userFreshDetail?.role ?? selectedUserForDetail.role} />
|
<UserRoleBadge role={userFreshDetail?.role ?? selectedUserForDetail.role} />
|
||||||
|
|||||||
@@ -13,21 +13,19 @@ import {
|
|||||||
Building2,
|
Building2,
|
||||||
Layers,
|
Layers,
|
||||||
Info,
|
Info,
|
||||||
Copy,
|
|
||||||
Check,
|
|
||||||
Eye,
|
Eye,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Lock,
|
Lock,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Mail,
|
Mail,
|
||||||
Shield,
|
User as UserIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getTenants,
|
getTenants,
|
||||||
createTenant,
|
createTenant,
|
||||||
updateTenant,
|
updateTenant,
|
||||||
getOrganisations,
|
getTenantOrganisations,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import { Modal } from '../components/ui/Modal';
|
import { Modal } from '../components/ui/Modal';
|
||||||
import { useToast } from '../components/ui/Toast';
|
import { useToast } from '../components/ui/Toast';
|
||||||
@@ -36,9 +34,8 @@ import { ExportButton } from '../components/ui/ExportButton';
|
|||||||
import { ExportColumn } from '../utils/exportUtils';
|
import { ExportColumn } from '../utils/exportUtils';
|
||||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||||
import { ActivityStatusBadge } from '../components/ui/StatusBadge';
|
import { ActivityStatusBadge } from '../components/ui/StatusBadge';
|
||||||
import { Tenant, Organisation, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types';
|
import { Tenant, TenantOrganisation, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types';
|
||||||
import { formatDate, getOrganisationTypeLabel } from '../lib/formatters';
|
import { formatDate, getOrganisationTypeLabel, getUserEmail } from '../lib/formatters';
|
||||||
import { encodeHashIdSync } from '../services/toolsService';
|
|
||||||
import { useAuth } from '../auth/AuthProvider';
|
import { useAuth } from '../auth/AuthProvider';
|
||||||
|
|
||||||
const createTenantSchema = z.object({
|
const createTenantSchema = z.object({
|
||||||
@@ -53,14 +50,188 @@ const updateTenantSchema = z.object({
|
|||||||
type CreateTenantFormValues = z.infer<typeof createTenantSchema>;
|
type CreateTenantFormValues = z.infer<typeof createTenantSchema>;
|
||||||
type UpdateTenantFormValues = z.infer<typeof updateTenantSchema>;
|
type UpdateTenantFormValues = z.infer<typeof updateTenantSchema>;
|
||||||
|
|
||||||
|
// Sub-component for rendering tenant organisations in expanded row
|
||||||
|
const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantName?: string | null }> = ({
|
||||||
|
tenantId,
|
||||||
|
tenantName,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
data: tenantOrganisations = [],
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
refetch,
|
||||||
|
} = useQuery<TenantOrganisation[]>({
|
||||||
|
queryKey: ['tenant-organisations', tenantId],
|
||||||
|
queryFn: () => getTenantOrganisations(tenantId),
|
||||||
|
staleTime: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="py-4 px-6 border-l-4 border-blue-500 bg-linear-to-r from-blue-950/20 via-slate-900/40 to-slate-950/60 space-y-3">
|
||||||
|
{/* Sub-list Header */}
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
||||||
|
<Building2 className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h4 className="text-xs font-bold text-white tracking-tight">
|
||||||
|
Scoped Organisations for {tenantName || `Tenant #${tenantId}`}
|
||||||
|
</h4>
|
||||||
|
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/20 px-2 py-0.5 rounded-full border border-blue-500/30">
|
||||||
|
{tenantOrganisations.length} Legal {tenantOrganisations.length === 1 ? 'Entity' : 'Entities'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400">
|
||||||
|
Legal entities and holding structures mapped under Tenant ID #{tenantId} partition boundary (Read-Only).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="p-1 text-slate-400 hover:text-white rounded hover:bg-slate-800 transition cursor-pointer"
|
||||||
|
title="Refresh organisations list"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-3.5 h-3.5 ${isLoading ? 'animate-spin text-blue-400' : ''}`} />
|
||||||
|
</button>
|
||||||
|
<div className="text-[11px] text-slate-500 italic">
|
||||||
|
Organisation management is handled in Governance & Structure
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Organisations Sub-Table */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-6 flex items-center justify-center gap-2 text-slate-400 text-xs">
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin text-blue-400" />
|
||||||
|
<span>Loading partition organisations from server...</span>
|
||||||
|
</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="py-4 px-4 rounded-lg bg-rose-950/30 border border-rose-900/40 text-center">
|
||||||
|
<p className="text-xs text-rose-300">Unable to retrieve organisations for Tenant #{tenantId}.</p>
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
className="mt-1 text-[11px] text-blue-400 hover:underline cursor-pointer"
|
||||||
|
>
|
||||||
|
Retry Query
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : tenantOrganisations.length === 0 ? (
|
||||||
|
<div className="py-6 px-4 rounded-lg bg-slate-900/80 border border-slate-800 text-center">
|
||||||
|
<Building2 className="w-5 h-5 text-slate-600 mx-auto mb-1.5" />
|
||||||
|
<p className="text-xs font-medium text-slate-300">No organisations assigned to Tenant #{tenantId}</p>
|
||||||
|
<p className="text-[11px] text-slate-500 mt-0.5">
|
||||||
|
New legal entities provisioned with Tenant ID #{tenantId} will automatically appear in this partition view.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-slate-800/80 overflow-hidden bg-slate-900/90 shadow-xs">
|
||||||
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-950/90 border-b border-slate-800/80 text-slate-400 font-medium">
|
||||||
|
<th className="py-2.5 px-3 font-semibold">Org ID</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold">Organisation Name</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold">Registration No.</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold">Entity Type</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold">Contact Email</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold">Linked User Email</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold">Status</th>
|
||||||
|
<th className="py-2.5 px-3 font-semibold text-right">Created Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/50">
|
||||||
|
{tenantOrganisations.map((tOrg) => {
|
||||||
|
const org = tOrg.organisation;
|
||||||
|
const orgId = org?.id || tOrg.organisationId || tOrg.id;
|
||||||
|
const orgName = org?.name || 'Unnamed Organisation';
|
||||||
|
const regNo = org?.registrationNo || '—';
|
||||||
|
const orgType = org?.type;
|
||||||
|
const orgEmail = org?.email || '—';
|
||||||
|
const userEmailDisplay =
|
||||||
|
tOrg.creator?.email ||
|
||||||
|
getUserEmail(org?.userId) ||
|
||||||
|
getUserEmail(tOrg.createdBy) ||
|
||||||
|
org?.userId ||
|
||||||
|
'—';
|
||||||
|
const status = org?.status ?? tOrg.status;
|
||||||
|
const createdAt = tOrg.createdAt || org?.createdAt;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={String(tOrg.id) + '-' + String(orgId)} className="hover:bg-slate-800/30 transition">
|
||||||
|
{/* Integer Org ID only (no hashid) */}
|
||||||
|
<td className="py-2.5 px-3 font-mono">
|
||||||
|
<span className="text-slate-300 font-semibold">#{orgId}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Organisation Name */}
|
||||||
|
<td className="py-2.5 px-3">
|
||||||
|
<span className="font-medium text-white">{orgName}</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Registration No */}
|
||||||
|
<td className="py-2.5 px-3 text-slate-400 font-mono text-[11px]">
|
||||||
|
{regNo}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Entity Type */}
|
||||||
|
<td className="py-2.5 px-3 text-slate-300">
|
||||||
|
<span className="bg-slate-800/80 text-slate-300 px-2 py-0.5 rounded border border-slate-700/60 text-[11px]">
|
||||||
|
{orgType !== undefined ? getOrganisationTypeLabel(orgType) : 'Organisation'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Contact Email */}
|
||||||
|
<td className="py-2.5 px-3 text-slate-300">
|
||||||
|
{orgEmail && orgEmail !== '—' ? (
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-slate-300">
|
||||||
|
<Mail className="w-3 h-3 text-slate-500 shrink-0" />
|
||||||
|
<span className="truncate max-w-[160px]">{orgEmail}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-500">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Linked User (swapped raw ID for email) */}
|
||||||
|
<td className="py-2.5 px-3 text-slate-300">
|
||||||
|
<div className="flex items-center gap-1.5 text-[11px] text-blue-300 font-mono">
|
||||||
|
<UserIcon className="w-3 h-3 text-blue-400 shrink-0" />
|
||||||
|
<span className="truncate max-w-[180px]" title={userEmailDisplay}>
|
||||||
|
{userEmailDisplay}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<td className="py-2.5 px-3">
|
||||||
|
<ActivityStatusBadge status={status} />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
{/* Created Date */}
|
||||||
|
<td className="py-2.5 px-3 text-slate-400 text-right">
|
||||||
|
{formatDate(createdAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const TenantManagementPage: React.FC = () => {
|
export const TenantManagementPage: React.FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { success, error: toastError } = useToast();
|
const { success, error: toastError } = useToast();
|
||||||
const { tenantId: currentTenantId, tenantName: currentTenantName, hashedTenantId: currentHashedTenantId } = useAuth();
|
const { tenantId: currentTenantId, tenantName: currentTenantName } = useAuth();
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
|
||||||
const [expandedTenants, setExpandedTenants] = useState<Record<string, boolean>>({});
|
const [expandedTenants, setExpandedTenants] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
@@ -81,18 +252,6 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Query Organisations to populate tenant sub-lists
|
|
||||||
const {
|
|
||||||
data: organisations = [],
|
|
||||||
isLoading: loadingOrgs,
|
|
||||||
isFetching: fetchingOrgs,
|
|
||||||
refetch: refetchOrgs,
|
|
||||||
} = useQuery<Organisation[]>({
|
|
||||||
queryKey: ['organisations'],
|
|
||||||
queryFn: () => getOrganisations(),
|
|
||||||
staleTime: 60000,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
const createForm = useForm<CreateTenantFormValues>({
|
const createForm = useForm<CreateTenantFormValues>({
|
||||||
resolver: zodResolver(createTenantSchema),
|
resolver: zodResolver(createTenantSchema),
|
||||||
@@ -152,12 +311,6 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
setIsDetailModalOpen(true);
|
setIsDetailModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyHashId = (text: string, idKey: string) => {
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
setCopiedId(idKey);
|
|
||||||
setTimeout(() => setCopiedId(null), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleTenantExpand = (id: string | number) => {
|
const toggleTenantExpand = (id: string | number) => {
|
||||||
const key = String(id);
|
const key = String(id);
|
||||||
setExpandedTenants((prev) => ({
|
setExpandedTenants((prev) => ({
|
||||||
@@ -182,7 +335,6 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
!searchTerm.trim() ||
|
!searchTerm.trim() ||
|
||||||
(t.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
(t.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
String(t.id).includes(searchTerm) ||
|
String(t.id).includes(searchTerm) ||
|
||||||
encodeHashIdSync(t.id).toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
(t.createdBy || '').toLowerCase().includes(searchTerm.toLowerCase());
|
(t.createdBy || '').toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
const matchesStatus =
|
const matchesStatus =
|
||||||
@@ -204,10 +356,9 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
const inactiveCount = tenants.filter((t) => Number(t.status) === 2).length;
|
const inactiveCount = tenants.filter((t) => Number(t.status) === 2).length;
|
||||||
const disabledCount = tenants.filter((t) => Number(t.status) === 3).length;
|
const disabledCount = tenants.filter((t) => Number(t.status) === 3).length;
|
||||||
|
|
||||||
// Export columns
|
// Export columns (using integer ID only, no hashid)
|
||||||
const exportColumns: ExportColumn<Tenant>[] = [
|
const exportColumns: ExportColumn<Tenant>[] = [
|
||||||
{ header: 'Tenant ID', key: 'id' },
|
{ header: 'Tenant ID', key: 'id' },
|
||||||
{ header: 'Partition HashID', key: 'id', format: (val) => encodeHashIdSync(val) },
|
|
||||||
{ header: 'Tenant Name', key: 'name', format: (val) => val || '—' },
|
{ header: 'Tenant Name', key: 'name', format: (val) => val || '—' },
|
||||||
{ header: 'Operational Status', key: 'status', format: (val) => getStatusString(val) },
|
{ header: 'Operational Status', key: 'status', format: (val) => getStatusString(val) },
|
||||||
{ header: 'Created By', key: 'createdBy', format: (val) => val || '—' },
|
{ header: 'Created By', key: 'createdBy', format: (val) => val || '—' },
|
||||||
@@ -217,51 +368,63 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
|
|
||||||
const handleRefreshAll = () => {
|
const handleRefreshAll = () => {
|
||||||
refetchTenants();
|
refetchTenants();
|
||||||
refetchOrgs();
|
queryClient.invalidateQueries({ queryKey: ['tenant-organisations'] });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 pb-12">
|
<div className="space-y-6 max-w-7xl mx-auto pb-12">
|
||||||
{/* Top Header */}
|
{/* Header Banner - Conforming to standard page design */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800/80 pb-5">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
|
||||||
|
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="space-y-2 relative z-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="h-12 w-12 rounded-2xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-lg shadow-blue-500/10">
|
||||||
|
<Database className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2.5 mb-1">
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
<div className="p-2 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-400">
|
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
||||||
<Database className="w-5 h-5" />
|
Tenant Management
|
||||||
</div>
|
</h1>
|
||||||
<h1 className="text-xl font-bold text-white tracking-tight">Tenant Management</h1>
|
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
|
||||||
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/20">
|
Directory
|
||||||
Multi-Tenancy
|
|
||||||
</span>
|
</span>
|
||||||
|
<HelpTooltip
|
||||||
|
title="Multi-Tenancy & Partition Governance"
|
||||||
|
content="Govern database partition boundaries, manage statutory authorities, and control operational tenancy lifecycle status."
|
||||||
|
placement="right"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 max-w-2xl">
|
<p className="text-xs sm:text-sm text-slate-400 font-normal mt-0.5">
|
||||||
Control tenant database partition boundaries, onboard statutory authorities, and govern tenant lifecycle status.
|
Manage multi-tenant isolated partitions, statutory boundaries, and enterprise organizational tenancy.
|
||||||
Expand any tenant row to inspect the scoped organisations governed under that partition.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 self-start sm:self-auto">
|
<div className="flex items-center gap-2.5 relative z-10 self-start md:self-center flex-wrap">
|
||||||
<button
|
<button
|
||||||
onClick={handleRefreshAll}
|
onClick={handleRefreshAll}
|
||||||
disabled={fetchingTenants || fetchingOrgs}
|
disabled={fetchingTenants}
|
||||||
className="flex items-center gap-1.5 px-3 py-2 bg-slate-900/80 hover:bg-slate-800 text-slate-300 text-xs font-medium rounded-lg border border-slate-800 transition shadow-xs disabled:opacity-50 cursor-pointer"
|
className="inline-flex items-center justify-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-slate-300 bg-[#0d121f] hover:bg-slate-800 border border-slate-700/80 rounded-xl transition cursor-pointer shadow-xs disabled:opacity-50"
|
||||||
title="Refresh tenants directory and organisations"
|
title="Refresh tenants directory"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3.5 h-3.5 ${fetchingTenants || fetchingOrgs ? 'animate-spin text-blue-400' : ''}`} />
|
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${fetchingTenants ? 'animate-spin' : ''}`} />
|
||||||
<span>Refresh</span>
|
<span>Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ExportButton
|
<ExportButton
|
||||||
title="PFM Multi-Tenant Directory Register"
|
title="PFM Multi-Tenant Directory Register"
|
||||||
|
subtitle="Official directory of registered tenant partitions"
|
||||||
|
filename={`pfm-tenants-${new Date().toISOString().split('T')[0]}`}
|
||||||
data={sortedData}
|
data={sortedData}
|
||||||
columns={exportColumns}
|
columns={exportColumns}
|
||||||
filename="PFM_Tenants_Directory"
|
|
||||||
label="Export Tenants"
|
label="Export Tenants"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleOpenCreate}
|
onClick={handleOpenCreate}
|
||||||
className="flex items-center gap-1.5 px-3.5 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-lg shadow-sm shadow-blue-600/30 transition cursor-pointer"
|
className="inline-flex items-center justify-center gap-1.5 px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-md shadow-blue-500/20 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
<span>New Tenant</span>
|
<span>New Tenant</span>
|
||||||
@@ -272,7 +435,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
{/* Summary KPI Cards */}
|
{/* Summary KPI Cards */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{/* Total Tenants */}
|
{/* Total Tenants */}
|
||||||
<div className="p-4 rounded-xl bg-slate-900/60 border border-slate-800 shadow-sm flex items-center justify-between">
|
<div className="p-4 rounded-2xl bg-[#0f1523]/80 border border-slate-800/80 shadow-md flex items-center justify-between backdrop-blur-md">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-slate-400">Total Partitions</p>
|
<p className="text-xs font-medium text-slate-400">Total Partitions</p>
|
||||||
<p className="text-2xl font-bold text-white mt-1">{loadingTenants ? '—' : totalCount}</p>
|
<p className="text-2xl font-bold text-white mt-1">{loadingTenants ? '—' : totalCount}</p>
|
||||||
@@ -284,7 +447,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Active Tenants */}
|
{/* Active Tenants */}
|
||||||
<div className="p-4 rounded-xl bg-slate-900/60 border border-slate-800 shadow-sm flex items-center justify-between">
|
<div className="p-4 rounded-2xl bg-[#0f1523]/80 border border-slate-800/80 shadow-md flex items-center justify-between backdrop-blur-md">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-slate-400">Active Tenants</p>
|
<p className="text-xs font-medium text-slate-400">Active Tenants</p>
|
||||||
<p className="text-2xl font-bold text-emerald-400 mt-1">{loadingTenants ? '—' : activeCount}</p>
|
<p className="text-2xl font-bold text-emerald-400 mt-1">{loadingTenants ? '—' : activeCount}</p>
|
||||||
@@ -296,7 +459,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inactive / Disabled Tenants */}
|
{/* Inactive / Disabled Tenants */}
|
||||||
<div className="p-4 rounded-xl bg-slate-900/60 border border-slate-800 shadow-sm flex items-center justify-between">
|
<div className="p-4 rounded-2xl bg-[#0f1523]/80 border border-slate-800/80 shadow-md flex items-center justify-between backdrop-blur-md">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-slate-400">Locked / Inactive</p>
|
<p className="text-xs font-medium text-slate-400">Locked / Inactive</p>
|
||||||
<p className="text-2xl font-bold text-rose-400 mt-1">{loadingTenants ? '—' : inactiveCount + disabledCount}</p>
|
<p className="text-2xl font-bold text-rose-400 mt-1">{loadingTenants ? '—' : inactiveCount + disabledCount}</p>
|
||||||
@@ -308,7 +471,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Current Active Tenant */}
|
{/* Current Active Tenant */}
|
||||||
<div className="p-4 rounded-xl bg-blue-950/20 border border-blue-800/40 shadow-sm flex items-center justify-between">
|
<div className="p-4 rounded-2xl bg-blue-950/20 border border-blue-800/40 shadow-md flex items-center justify-between backdrop-blur-md">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-medium text-blue-300 flex items-center gap-1.5">
|
<p className="text-xs font-medium text-blue-300 flex items-center gap-1.5">
|
||||||
<span>Your Active Partition</span>
|
<span>Your Active Partition</span>
|
||||||
@@ -321,8 +484,8 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
<p className="text-sm font-bold text-white mt-1 truncate" title={currentTenantName || 'Default Tenant'}>
|
<p className="text-sm font-bold text-white mt-1 truncate" title={currentTenantName || 'Default Tenant'}>
|
||||||
{currentTenantName || `Tenant #${currentTenantId || 1}`}
|
{currentTenantName || `Tenant #${currentTenantId || 1}`}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] font-mono text-blue-400 mt-0.5 truncate">
|
<p className="text-[11px] text-blue-400 font-mono mt-0.5">
|
||||||
HashID: {currentHashedTenantId || '—'}
|
Tenant ID: #{currentTenantId || 1}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 rounded-xl bg-blue-900/40 text-blue-400 border border-blue-700/50 shrink-0">
|
<div className="p-3 rounded-xl bg-blue-900/40 text-blue-400 border border-blue-700/50 shrink-0">
|
||||||
@@ -332,23 +495,23 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search & Filter Bar */}
|
{/* Search & Filter Bar */}
|
||||||
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-slate-900/80 p-3 rounded-xl border border-slate-800/80">
|
<div className="flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3 bg-[#0f1523]/80 p-3 rounded-2xl border border-slate-800/80 backdrop-blur-md">
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search by Tenant Name, ID, HashID, or Creator..."
|
placeholder="Search by Tenant Name, ID, or Creator..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="w-full pl-9 pr-4 py-2 bg-slate-950/80 border border-slate-800 rounded-lg text-xs text-slate-200 placeholder-slate-500 focus:outline-hidden focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
className="w-full pl-9 pr-4 py-2 bg-slate-950/80 border border-slate-800 rounded-xl text-xs text-slate-200 placeholder-slate-500 focus:outline-hidden focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<div className="flex items-center bg-slate-950/80 border border-slate-800 rounded-lg p-1 text-xs">
|
<div className="flex items-center bg-slate-950/80 border border-slate-800 rounded-xl p-1 text-xs">
|
||||||
<button
|
<button
|
||||||
onClick={() => setStatusFilter('all')}
|
onClick={() => setStatusFilter('all')}
|
||||||
className={`px-3 py-1 rounded-md transition font-medium cursor-pointer ${
|
className={`px-3 py-1 rounded-lg transition font-medium cursor-pointer ${
|
||||||
statusFilter === 'all'
|
statusFilter === 'all'
|
||||||
? 'bg-blue-600 text-white shadow-xs'
|
? 'bg-blue-600 text-white shadow-xs'
|
||||||
: 'text-slate-400 hover:text-slate-200'
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
@@ -358,7 +521,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setStatusFilter('active')}
|
onClick={() => setStatusFilter('active')}
|
||||||
className={`px-3 py-1 rounded-md transition font-medium cursor-pointer ${
|
className={`px-3 py-1 rounded-lg transition font-medium cursor-pointer ${
|
||||||
statusFilter === 'active'
|
statusFilter === 'active'
|
||||||
? 'bg-emerald-600 text-white shadow-xs'
|
? 'bg-emerald-600 text-white shadow-xs'
|
||||||
: 'text-slate-400 hover:text-slate-200'
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
@@ -368,7 +531,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setStatusFilter('inactive')}
|
onClick={() => setStatusFilter('inactive')}
|
||||||
className={`px-3 py-1 rounded-md transition font-medium cursor-pointer ${
|
className={`px-3 py-1 rounded-lg transition font-medium cursor-pointer ${
|
||||||
statusFilter === 'inactive'
|
statusFilter === 'inactive'
|
||||||
? 'bg-amber-600 text-white shadow-xs'
|
? 'bg-amber-600 text-white shadow-xs'
|
||||||
: 'text-slate-400 hover:text-slate-200'
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
@@ -378,7 +541,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setStatusFilter('disabled')}
|
onClick={() => setStatusFilter('disabled')}
|
||||||
className={`px-3 py-1 rounded-md transition font-medium cursor-pointer ${
|
className={`px-3 py-1 rounded-lg transition font-medium cursor-pointer ${
|
||||||
statusFilter === 'disabled'
|
statusFilter === 'disabled'
|
||||||
? 'bg-rose-600 text-white shadow-xs'
|
? 'bg-rose-600 text-white shadow-xs'
|
||||||
: 'text-slate-400 hover:text-slate-200'
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
@@ -391,16 +554,16 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tenants Table */}
|
{/* Tenants Table */}
|
||||||
<div className="bg-slate-900/60 rounded-xl border border-slate-800 overflow-hidden shadow-sm">
|
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 overflow-hidden shadow-xl backdrop-blur-md">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-xs border-collapse">
|
<table className="w-full text-left text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-950/80 border-b border-slate-800 text-slate-400 font-semibold uppercase tracking-wider">
|
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 font-semibold uppercase tracking-wider">
|
||||||
<th className="py-3.5 px-3 w-10 text-center">
|
<th className="py-3.5 px-3 w-10 text-center">
|
||||||
<span className="sr-only">Expand</span>
|
<span className="sr-only">Expand</span>
|
||||||
</th>
|
</th>
|
||||||
<SortableTh column="id" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4">
|
<SortableTh column="id" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4">
|
||||||
Tenant ID & HashID
|
Tenant ID
|
||||||
</SortableTh>
|
</SortableTh>
|
||||||
<SortableTh column="name" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4">
|
<SortableTh column="name" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4">
|
||||||
Tenant Organization Name
|
Tenant Organization Name
|
||||||
@@ -441,19 +604,11 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
sortedData.map((tenant) => {
|
sortedData.map((tenant) => {
|
||||||
const hashId = encodeHashIdSync(tenant.id);
|
|
||||||
const isCurrent = String(tenant.id) === String(currentTenantId);
|
const isCurrent = String(tenant.id) === String(currentTenantId);
|
||||||
const s = Number(tenant.status ?? ActivityStatus.Active);
|
const s = Number(tenant.status ?? ActivityStatus.Active);
|
||||||
const isActive = s === 1 || s === ActivityStatus.Active;
|
const isActive = s === 1 || s === ActivityStatus.Active;
|
||||||
const isExpanded = !!expandedTenants[String(tenant.id)];
|
const isExpanded = !!expandedTenants[String(tenant.id)];
|
||||||
|
const creatorEmail = getUserEmail(tenant.createdBy) || tenant.createdBy || 'System Administrator';
|
||||||
// Filter organisations matching this tenant
|
|
||||||
const tenantOrganisations = organisations.filter(
|
|
||||||
(org) =>
|
|
||||||
(org.tenantId !== undefined && org.tenantId !== null && String(org.tenantId) === String(tenant.id)) ||
|
|
||||||
((org as any).tenant_id !== undefined && (org as any).tenant_id !== null && String((org as any).tenant_id) === String(tenant.id)) ||
|
|
||||||
String(org.id) === String(tenant.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={tenant.id}>
|
<React.Fragment key={tenant.id}>
|
||||||
@@ -477,28 +632,12 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* ID & HashID */}
|
{/* Integer Tenant ID only (no hashid) */}
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4 font-mono">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-semibold text-white">#{tenant.id}</span>
|
<span className="font-semibold text-white">#{tenant.id}</span>
|
||||||
<span className="text-[10px] font-mono text-blue-400 bg-blue-950/60 px-1.5 py-0.5 rounded border border-blue-800/50">
|
|
||||||
{hashId}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopyHashId(hashId, String(tenant.id))}
|
|
||||||
className="text-slate-500 hover:text-slate-300 transition cursor-pointer"
|
|
||||||
title="Copy HashID"
|
|
||||||
>
|
|
||||||
{copiedId === String(tenant.id) ? (
|
|
||||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Tenant Name & Sub-Organisations Count */}
|
{/* Tenant Name */}
|
||||||
<td className="py-3 px-4">
|
<td className="py-3 px-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Building2 className="w-4 h-4 text-slate-400 shrink-0" />
|
<Building2 className="w-4 h-4 text-slate-400 shrink-0" />
|
||||||
@@ -508,13 +647,6 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
Active Context
|
Active Context
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={() => toggleTenantExpand(tenant.id)}
|
|
||||||
className="text-[10px] font-medium text-slate-400 hover:text-blue-300 bg-slate-800/80 hover:bg-slate-700/80 px-2 py-0.5 rounded-full border border-slate-700/60 transition cursor-pointer flex items-center gap-1"
|
|
||||||
title="Click to view scoped organisations"
|
|
||||||
>
|
|
||||||
<span>{tenantOrganisations.length} Org{tenantOrganisations.length === 1 ? '' : 's'}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -534,9 +666,11 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Created By */}
|
{/* Created By (Swapped ID for Email where available) */}
|
||||||
<td className="py-3 px-4 text-slate-400 font-mono text-[11px]">
|
<td className="py-3 px-4 text-slate-400 font-mono text-[11px]">
|
||||||
{tenant.createdBy || 'System Administrator'}
|
<span title={String(tenant.createdBy || '')}>
|
||||||
|
{creatorEmail}
|
||||||
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
{/* Created Date */}
|
{/* Created Date */}
|
||||||
@@ -566,126 +700,11 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
||||||
{/* Expanded Sub-List of Organisations */}
|
{/* Expanded Sub-List of Organisations using getTenantOrganisations */}
|
||||||
{isExpanded && (
|
{isExpanded && (
|
||||||
<tr className="bg-slate-950/70 border-b border-slate-800/80">
|
<tr className="bg-slate-950/70 border-b border-slate-800/80">
|
||||||
<td colSpan={7} className="p-0">
|
<td colSpan={7} className="p-0">
|
||||||
<div className="py-4 px-6 border-l-4 border-blue-500 bg-linear-to-r from-blue-950/20 via-slate-900/40 to-slate-950/60 space-y-3">
|
<TenantOrganisationsSubList tenantId={tenant.id} tenantName={tenant.name} />
|
||||||
{/* Sub-list Header */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20">
|
|
||||||
<Building2 className="w-4 h-4" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<h4 className="text-xs font-bold text-white tracking-tight">
|
|
||||||
Scoped Organisations for {tenant.name || `Tenant #${tenant.id}`}
|
|
||||||
</h4>
|
|
||||||
<span className="text-[10px] font-semibold text-blue-300 bg-blue-500/20 px-2 py-0.5 rounded-full border border-blue-500/30">
|
|
||||||
{tenantOrganisations.length} Legal {tenantOrganisations.length === 1 ? 'Entity' : 'Entities'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-slate-400">
|
|
||||||
Legal entities and holding structures mapped under Tenant ID #{tenant.id} partition boundary (Read-Only).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-[11px] text-slate-500 italic">
|
|
||||||
Organisation management is handled in Governance & Structure
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Organisations Sub-Table */}
|
|
||||||
{loadingOrgs ? (
|
|
||||||
<div className="py-6 flex items-center justify-center gap-2 text-slate-400 text-xs">
|
|
||||||
<RefreshCw className="w-4 h-4 animate-spin text-blue-400" />
|
|
||||||
<span>Loading organisations...</span>
|
|
||||||
</div>
|
|
||||||
) : tenantOrganisations.length === 0 ? (
|
|
||||||
<div className="py-6 px-4 rounded-lg bg-slate-900/80 border border-slate-800 text-center">
|
|
||||||
<Building2 className="w-5 h-5 text-slate-600 mx-auto mb-1.5" />
|
|
||||||
<p className="text-xs font-medium text-slate-300">No organisations assigned to this tenant</p>
|
|
||||||
<p className="text-[11px] text-slate-500 mt-0.5">
|
|
||||||
New organisations provisioned with Tenant ID #{tenant.id} will automatically appear in this partition view.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded-lg border border-slate-800/80 overflow-hidden bg-slate-900/90 shadow-xs">
|
|
||||||
<table className="w-full text-left text-xs border-collapse">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-slate-950/90 border-b border-slate-800/80 text-slate-400 font-medium">
|
|
||||||
<th className="py-2.5 px-3 font-semibold">Org ID & HashID</th>
|
|
||||||
<th className="py-2.5 px-3 font-semibold">Organisation Name</th>
|
|
||||||
<th className="py-2.5 px-3 font-semibold">Registration No.</th>
|
|
||||||
<th className="py-2.5 px-3 font-semibold">Entity Type</th>
|
|
||||||
<th className="py-2.5 px-3 font-semibold">Official Contact</th>
|
|
||||||
<th className="py-2.5 px-3 font-semibold">Status</th>
|
|
||||||
<th className="py-2.5 px-3 font-semibold text-right">Created Date</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-800/50">
|
|
||||||
{tenantOrganisations.map((org) => {
|
|
||||||
const orgHashId = encodeHashIdSync(org.id);
|
|
||||||
return (
|
|
||||||
<tr key={org.id} className="hover:bg-slate-800/30 transition">
|
|
||||||
{/* ID & HashID */}
|
|
||||||
<td className="py-2.5 px-3 font-mono">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
<span className="text-slate-300 font-semibold">#{org.id}</span>
|
|
||||||
<span className="text-[10px] text-blue-400 bg-blue-950/50 px-1.5 py-0.5 rounded border border-blue-800/40">
|
|
||||||
{orgHashId}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Name */}
|
|
||||||
<td className="py-2.5 px-3">
|
|
||||||
<span className="font-medium text-white">{org.name || 'Unnamed Organisation'}</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Registration No */}
|
|
||||||
<td className="py-2.5 px-3 text-slate-400 font-mono text-[11px]">
|
|
||||||
{org.registrationNo || '—'}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Type */}
|
|
||||||
<td className="py-2.5 px-3 text-slate-300">
|
|
||||||
<span className="bg-slate-800/80 text-slate-300 px-2 py-0.5 rounded border border-slate-700/60 text-[11px]">
|
|
||||||
{getOrganisationTypeLabel(org.type)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Email */}
|
|
||||||
<td className="py-2.5 px-3 text-slate-300">
|
|
||||||
{org.email ? (
|
|
||||||
<div className="flex items-center gap-1.5 text-[11px] text-slate-300">
|
|
||||||
<Mail className="w-3 h-3 text-slate-500 shrink-0" />
|
|
||||||
<span className="truncate max-w-[180px]">{org.email}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-slate-500">—</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<td className="py-2.5 px-3">
|
|
||||||
<ActivityStatusBadge status={org.status} />
|
|
||||||
</td>
|
|
||||||
|
|
||||||
{/* Created Date */}
|
|
||||||
<td className="py-2.5 px-3 text-slate-400 text-right">
|
|
||||||
{formatDate(org.createdAt)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
@@ -730,7 +749,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. Technology Innovation Agency (TIA)"
|
placeholder="e.g. Technology Innovation Agency (TIA)"
|
||||||
{...createForm.register('name')}
|
{...createForm.register('name')}
|
||||||
className="w-full px-3.5 py-2.5 bg-slate-950/80 border border-slate-800 rounded-lg text-xs text-white placeholder-slate-500 focus:outline-hidden focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
className="w-full px-3.5 py-2.5 bg-slate-950/80 border border-slate-800 rounded-xl text-xs text-white placeholder-slate-500 focus:outline-hidden focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
{createForm.formState.errors.name && (
|
{createForm.formState.errors.name && (
|
||||||
<p className="text-[11px] text-rose-400 mt-1">{createForm.formState.errors.name.message}</p>
|
<p className="text-[11px] text-rose-400 mt-1">{createForm.formState.errors.name.message}</p>
|
||||||
@@ -741,14 +760,14 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsCreateModalOpen(false)}
|
onClick={() => setIsCreateModalOpen(false)}
|
||||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-lg transition cursor-pointer"
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-xl transition cursor-pointer"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={createMutation.isPending}
|
disabled={createMutation.isPending}
|
||||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-lg shadow-sm transition disabled:opacity-50 cursor-pointer"
|
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
{createMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
{createMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
||||||
<span>{createMutation.isPending ? 'Provisioning...' : 'Create Tenant'}</span>
|
<span>{createMutation.isPending ? 'Provisioning...' : 'Create Tenant'}</span>
|
||||||
@@ -782,7 +801,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
{...updateForm.register('name')}
|
{...updateForm.register('name')}
|
||||||
className="w-full px-3.5 py-2.5 bg-slate-950/80 border border-slate-800 rounded-lg text-xs text-white placeholder-slate-500 focus:outline-hidden focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
className="w-full px-3.5 py-2.5 bg-slate-950/80 border border-slate-800 rounded-xl text-xs text-white placeholder-slate-500 focus:outline-hidden focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
{updateForm.formState.errors.name && (
|
{updateForm.formState.errors.name && (
|
||||||
<p className="text-[11px] text-rose-400 mt-1">{updateForm.formState.errors.name.message}</p>
|
<p className="text-[11px] text-rose-400 mt-1">{updateForm.formState.errors.name.message}</p>
|
||||||
@@ -795,7 +814,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
{...updateForm.register('status')}
|
{...updateForm.register('status')}
|
||||||
className="w-full px-3.5 py-2.5 bg-slate-950/80 border border-slate-800 rounded-lg text-xs text-white focus:outline-hidden focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
className="w-full px-3.5 py-2.5 bg-slate-950/80 border border-slate-800 rounded-xl text-xs text-white focus:outline-hidden focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value={ActivityStatus.Active}>Active (Users Permitted)</option>
|
<option value={ActivityStatus.Active}>Active (Users Permitted)</option>
|
||||||
<option value={ActivityStatus.Inactive}>Inactive (Tenant Lockout)</option>
|
<option value={ActivityStatus.Inactive}>Inactive (Tenant Lockout)</option>
|
||||||
@@ -821,14 +840,14 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsUpdateModalOpen(false)}
|
onClick={() => setIsUpdateModalOpen(false)}
|
||||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-lg transition cursor-pointer"
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-xl transition cursor-pointer"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={updateMutation.isPending}
|
disabled={updateMutation.isPending}
|
||||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-lg shadow-sm transition disabled:opacity-50 cursor-pointer"
|
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl shadow-md transition disabled:opacity-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
{updateMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
{updateMutation.isPending && <RefreshCw className="w-3.5 h-3.5 animate-spin" />}
|
||||||
<span>{updateMutation.isPending ? 'Saving...' : 'Update Tenant'}</span>
|
<span>{updateMutation.isPending ? 'Saving...' : 'Update Tenant'}</span>
|
||||||
@@ -853,26 +872,6 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
<p className="text-sm font-bold text-white mt-0.5">#{selectedTenant.id}</p>
|
<p className="text-sm font-bold text-white mt-0.5">#{selectedTenant.id}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Partition HashID</p>
|
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
|
||||||
<span className="font-mono text-xs text-blue-400 bg-blue-950/60 px-2 py-0.5 rounded border border-blue-800/50">
|
|
||||||
{encodeHashIdSync(selectedTenant.id)}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => handleCopyHashId(encodeHashIdSync(selectedTenant.id), `detail-${selectedTenant.id}`)}
|
|
||||||
className="text-slate-400 hover:text-white transition cursor-pointer"
|
|
||||||
title="Copy HashID"
|
|
||||||
>
|
|
||||||
{copiedId === `detail-${selectedTenant.id}` ? (
|
|
||||||
<Check className="w-3.5 h-3.5 text-emerald-400" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-3.5 h-3.5" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Name</p>
|
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Tenant Name</p>
|
||||||
<p className="text-sm font-semibold text-slate-200 mt-0.5">{selectedTenant.name || '—'}</p>
|
<p className="text-sm font-semibold text-slate-200 mt-0.5">{selectedTenant.name || '—'}</p>
|
||||||
@@ -892,7 +891,9 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Created By</p>
|
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Created By</p>
|
||||||
<p className="text-xs font-mono text-slate-300 mt-0.5">{selectedTenant.createdBy || 'System Admin'}</p>
|
<p className="text-xs font-mono text-slate-300 mt-0.5">
|
||||||
|
{getUserEmail(selectedTenant.createdBy) || selectedTenant.createdBy || 'System Admin'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedTenant.updatedAt && (
|
{selectedTenant.updatedAt && (
|
||||||
@@ -905,7 +906,9 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
{selectedTenant.updatedBy && (
|
{selectedTenant.updatedBy && (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Updated By</p>
|
<p className="text-[11px] text-slate-500 uppercase tracking-wider">Updated By</p>
|
||||||
<p className="text-xs font-mono text-slate-300 mt-0.5">{selectedTenant.updatedBy}</p>
|
<p className="text-xs font-mono text-slate-300 mt-0.5">
|
||||||
|
{getUserEmail(selectedTenant.updatedBy) || selectedTenant.updatedBy}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -913,7 +916,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
<div className="p-3.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs text-slate-300 space-y-2">
|
<div className="p-3.5 rounded-xl bg-slate-900/80 border border-slate-800 text-xs text-slate-300 space-y-2">
|
||||||
<p className="font-semibold text-white flex items-center gap-1.5">
|
<p className="font-semibold text-white flex items-center gap-1.5">
|
||||||
<ShieldCheck className="w-4 h-4 text-blue-400" />
|
<ShieldCheck className="w-4 h-4 text-blue-400" />
|
||||||
Partition Scope & Authentication
|
Partition Scope & Authentication
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||||||
When users authenticate, their security token contains claims identifying their tenant partition.
|
When users authenticate, their security token contains claims identifying their tenant partition.
|
||||||
@@ -928,7 +931,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
setIsDetailModalOpen(false);
|
setIsDetailModalOpen(false);
|
||||||
handleOpenUpdate(selectedTenant);
|
handleOpenUpdate(selectedTenant);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1.5 px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-semibold rounded-lg transition cursor-pointer"
|
className="flex items-center gap-1.5 px-4 py-2 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white text-xs font-semibold rounded-xl transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-3.5 h-3.5" />
|
<Edit2 className="w-3.5 h-3.5" />
|
||||||
<span>Edit Tenant Status</span>
|
<span>Edit Tenant Status</span>
|
||||||
@@ -936,7 +939,7 @@ export const TenantManagementPage: React.FC = () => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsDetailModalOpen(false)}
|
onClick={() => setIsDetailModalOpen(false)}
|
||||||
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-lg transition cursor-pointer"
|
className="px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-medium rounded-xl transition cursor-pointer"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
ProblemDetails,
|
ProblemDetails,
|
||||||
ActivityStatus,
|
ActivityStatus,
|
||||||
Tenant,
|
Tenant,
|
||||||
|
TenantOrganisation,
|
||||||
CreateTenantRequest,
|
CreateTenantRequest,
|
||||||
UpdateTenantRequest,
|
UpdateTenantRequest,
|
||||||
Beneficiary,
|
Beneficiary,
|
||||||
@@ -180,6 +181,7 @@ export const normalizeOrganisation = (raw: any): Organisation => {
|
|||||||
name: raw.name ?? raw.Name ?? null,
|
name: raw.name ?? raw.Name ?? null,
|
||||||
registrationNo: raw.registrationNo ?? raw.RegistrationNo ?? raw.regNo ?? raw.RegNo ?? null,
|
registrationNo: raw.registrationNo ?? raw.RegistrationNo ?? raw.regNo ?? raw.RegNo ?? null,
|
||||||
email: raw.email ?? raw.Email ?? null,
|
email: raw.email ?? raw.Email ?? null,
|
||||||
|
userId: raw.userId ?? raw.UserId ?? raw.organisationUser ?? raw.OrganisationUser ?? null,
|
||||||
type: raw.type ?? raw.Type ?? raw.organisationType ?? raw.OrganisationType ?? 5,
|
type: raw.type ?? raw.Type ?? raw.organisationType ?? raw.OrganisationType ?? 5,
|
||||||
status,
|
status,
|
||||||
createdAt: raw.createdAt ?? raw.CreatedAt ?? new Date().toISOString(),
|
createdAt: raw.createdAt ?? raw.CreatedAt ?? new Date().toISOString(),
|
||||||
@@ -214,6 +216,8 @@ export const createOrganisation = async (payload: CreateOrganisationRequest): Pr
|
|||||||
name: payload.name,
|
name: payload.name,
|
||||||
registrationNo: payload.registrationNo,
|
registrationNo: payload.registrationNo,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
|
idNumber: payload.idNumber,
|
||||||
|
organisationUser: payload.organisationUser,
|
||||||
type: payload.type !== undefined ? Number(payload.type) : 5,
|
type: payload.type !== undefined ? Number(payload.type) : 5,
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
@@ -225,6 +229,8 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr
|
|||||||
name: payload.name,
|
name: payload.name,
|
||||||
registrationNo: payload.registrationNo,
|
registrationNo: payload.registrationNo,
|
||||||
email: payload.email,
|
email: payload.email,
|
||||||
|
idNumber: payload.idNumber,
|
||||||
|
organisationUser: payload.organisationUser,
|
||||||
type: Number(payload.type),
|
type: Number(payload.type),
|
||||||
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
|
||||||
});
|
});
|
||||||
@@ -1527,6 +1533,26 @@ export const updateTenant = async (payload: UpdateTenantRequest): Promise<void>
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getTenantOrganisations = async (tenantId: string | number): Promise<TenantOrganisation[]> => {
|
||||||
|
const hashedTenantId = await toolsService.encodeId(tenantId);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get<TenantOrganisation[]>(
|
||||||
|
`/v1/api/governance/tenants/${encodeURIComponent(hashedTenantId)}/organisations`
|
||||||
|
);
|
||||||
|
return res.data || [];
|
||||||
|
} catch (err: any) {
|
||||||
|
if (String(tenantId) !== String(hashedTenantId)) {
|
||||||
|
try {
|
||||||
|
const fallbackRes = await apiClient.get<TenantOrganisation[]>(
|
||||||
|
`/v1/api/governance/tenants/${encodeURIComponent(String(tenantId))}/organisations`
|
||||||
|
);
|
||||||
|
return fallbackRes.data || [];
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 10. Projects: Beneficiaries & Outcomes APIs
|
// 10. Projects: Beneficiaries & Outcomes APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -262,6 +262,7 @@ export interface User {
|
|||||||
status: ActivityStatus;
|
status: ActivityStatus;
|
||||||
lastLoginAt: string | null;
|
lastLoginAt: string | null;
|
||||||
role?: string | UserRole | null;
|
role?: string | UserRole | null;
|
||||||
|
idNumber?: string | null;
|
||||||
name?: string;
|
name?: string;
|
||||||
organisationId?: number | string;
|
organisationId?: number | string;
|
||||||
organisationName?: string;
|
organisationName?: string;
|
||||||
@@ -302,6 +303,7 @@ export interface Organisation {
|
|||||||
name: string | null;
|
name: string | null;
|
||||||
registrationNo: string | null;
|
registrationNo: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
|
userId?: string | null;
|
||||||
type: OrganisationType;
|
type: OrganisationType;
|
||||||
status: ActivityStatus;
|
status: ActivityStatus;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -312,6 +314,8 @@ export interface CreateOrganisationRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
registrationNo: string;
|
registrationNo: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
idNumber: string;
|
||||||
|
organisationUser: string;
|
||||||
type: OrganisationType;
|
type: OrganisationType;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,10 +324,24 @@ export interface UpdateOrganisationRequest {
|
|||||||
name: string;
|
name: string;
|
||||||
registrationNo: string;
|
registrationNo: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
idNumber: string;
|
||||||
|
organisationUser: string;
|
||||||
type: OrganisationType;
|
type: OrganisationType;
|
||||||
status?: ActivityStatus;
|
status?: ActivityStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TenantOrganisation {
|
||||||
|
id: number | string;
|
||||||
|
tenantId: number | string;
|
||||||
|
organisationId: number | string;
|
||||||
|
status: ActivityStatus;
|
||||||
|
createdAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
tenant?: Tenant | null;
|
||||||
|
organisation?: Organisation | null;
|
||||||
|
creator?: User | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Portfolio {
|
export interface Portfolio {
|
||||||
id: number | string;
|
id: number | string;
|
||||||
tenantId?: number | string;
|
tenantId?: number | string;
|
||||||
|
|||||||
Reference in New Issue
Block a user