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'),
|
||||
registrationNo: z.string().min(4, 'Registration number is mandatory'),
|
||||
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),
|
||||
status: z.coerce.number().optional(),
|
||||
});
|
||||
@@ -422,6 +424,8 @@ export const GovernancePage: React.FC = () => {
|
||||
name: '',
|
||||
registrationNo: '',
|
||||
email: '',
|
||||
idNumber: '',
|
||||
organisationUser: '',
|
||||
type: OrganisationType.Enterprise,
|
||||
status: ActivityStatus.Active,
|
||||
},
|
||||
@@ -456,11 +460,20 @@ export const GovernancePage: React.FC = () => {
|
||||
name: data.name,
|
||||
registrationNo: data.registrationNo,
|
||||
email: data.email,
|
||||
idNumber: data.idNumber,
|
||||
organisationUser: data.organisationUser,
|
||||
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: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['organisations'] });
|
||||
@@ -537,10 +550,14 @@ export const GovernancePage: React.FC = () => {
|
||||
|
||||
const openCreateOrg = () => {
|
||||
setEditingOrg(null);
|
||||
const defaultUserId = usersDirectory[0]?.id || sub || currentUserId;
|
||||
const defaultUser = usersDirectory.find((u) => u.id === defaultUserId);
|
||||
orgForm.reset({
|
||||
name: '',
|
||||
registrationNo: '',
|
||||
email: '',
|
||||
idNumber: defaultUser?.idNumber || '',
|
||||
organisationUser: defaultUserId,
|
||||
type: OrganisationType.Enterprise,
|
||||
status: ActivityStatus.Active,
|
||||
});
|
||||
@@ -549,10 +566,14 @@ export const GovernancePage: React.FC = () => {
|
||||
|
||||
const openEditOrg = (org: Organisation) => {
|
||||
setEditingOrg(org);
|
||||
const linkedUserId = org.userId || org.createdBy || usersDirectory[0]?.id || currentUserId;
|
||||
const matchedUser = usersDirectory.find((u) => u.id === linkedUserId);
|
||||
orgForm.reset({
|
||||
name: org.name || '',
|
||||
registrationNo: org.registrationNo || '',
|
||||
email: org.email || '',
|
||||
idNumber: matchedUser?.idNumber || (org as any).idNumber || '',
|
||||
organisationUser: linkedUserId,
|
||||
type: org.type || OrganisationType.Enterprise,
|
||||
status: org.status ?? ActivityStatus.Active,
|
||||
});
|
||||
@@ -1412,6 +1433,65 @@ export const GovernancePage: React.FC = () => {
|
||||
</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>
|
||||
<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-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -441,6 +441,7 @@ export const SettingsPage: React.FC = () => {
|
||||
const userExportColumns: ExportColumn<User>[] = [
|
||||
{ header: 'User Email', key: 'email', format: (val, u) => val || getUserEmail(u.id) || '—' },
|
||||
{ header: 'User Identifier', key: 'id' },
|
||||
{ header: 'Id Number', key: 'idNumber', format: (val) => val || '—' },
|
||||
{ header: 'Role', key: 'role', format: (val) => getUserRoleBadge(val).label },
|
||||
{ header: 'Status', key: 'status', format: (val) => getActivityStatusBadge(val).label },
|
||||
{ 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">
|
||||
User Identifier
|
||||
</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">
|
||||
Role
|
||||
</SortableTh>
|
||||
@@ -785,13 +789,13 @@ export const SettingsPage: React.FC = () => {
|
||||
<tbody className="divide-y divide-slate-800/50">
|
||||
{loadingUsers ? (
|
||||
<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...
|
||||
</td>
|
||||
</tr>
|
||||
) : sortedUsers.length === 0 ? (
|
||||
<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.
|
||||
</td>
|
||||
</tr>
|
||||
@@ -806,6 +810,9 @@ export const SettingsPage: React.FC = () => {
|
||||
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
|
||||
{u.id}
|
||||
</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">
|
||||
<UserRoleBadge role={u.role} />
|
||||
</td>
|
||||
@@ -2113,6 +2120,12 @@ export const SettingsPage: React.FC = () => {
|
||||
{userFreshDetail?.id || selectedUserForDetail.id}
|
||||
</span>
|
||||
</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>
|
||||
<span className="text-slate-400 font-semibold block text-[11px] uppercase mb-1">System Role:</span>
|
||||
<UserRoleBadge role={userFreshDetail?.role ?? selectedUserForDetail.role} />
|
||||
|
||||
@@ -13,21 +13,19 @@ import {
|
||||
Building2,
|
||||
Layers,
|
||||
Info,
|
||||
Copy,
|
||||
Check,
|
||||
Eye,
|
||||
AlertTriangle,
|
||||
Lock,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Mail,
|
||||
Shield,
|
||||
User as UserIcon,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
getTenants,
|
||||
createTenant,
|
||||
updateTenant,
|
||||
getOrganisations,
|
||||
getTenantOrganisations,
|
||||
} from '../services/api';
|
||||
import { Modal } from '../components/ui/Modal';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
@@ -36,9 +34,8 @@ import { ExportButton } from '../components/ui/ExportButton';
|
||||
import { ExportColumn } from '../utils/exportUtils';
|
||||
import { useTableSort, SortableTh } from '../utils/tableSort';
|
||||
import { ActivityStatusBadge } from '../components/ui/StatusBadge';
|
||||
import { Tenant, Organisation, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types';
|
||||
import { formatDate, getOrganisationTypeLabel } from '../lib/formatters';
|
||||
import { encodeHashIdSync } from '../services/toolsService';
|
||||
import { Tenant, TenantOrganisation, ActivityStatus, CreateTenantRequest, UpdateTenantRequest } from '../types';
|
||||
import { formatDate, getOrganisationTypeLabel, getUserEmail } from '../lib/formatters';
|
||||
import { useAuth } from '../auth/AuthProvider';
|
||||
|
||||
const createTenantSchema = z.object({
|
||||
@@ -53,14 +50,188 @@ const updateTenantSchema = z.object({
|
||||
type CreateTenantFormValues = z.infer<typeof createTenantSchema>;
|
||||
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 = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { success, error: toastError } = useToast();
|
||||
const { tenantId: currentTenantId, tenantName: currentTenantName, hashedTenantId: currentHashedTenantId } = useAuth();
|
||||
const { tenantId: currentTenantId, tenantName: currentTenantName } = useAuth();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [expandedTenants, setExpandedTenants] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Modals
|
||||
@@ -81,18 +252,6 @@ export const TenantManagementPage: React.FC = () => {
|
||||
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
|
||||
const createForm = useForm<CreateTenantFormValues>({
|
||||
resolver: zodResolver(createTenantSchema),
|
||||
@@ -152,12 +311,6 @@ export const TenantManagementPage: React.FC = () => {
|
||||
setIsDetailModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCopyHashId = (text: string, idKey: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
setCopiedId(idKey);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
const toggleTenantExpand = (id: string | number) => {
|
||||
const key = String(id);
|
||||
setExpandedTenants((prev) => ({
|
||||
@@ -182,7 +335,6 @@ export const TenantManagementPage: React.FC = () => {
|
||||
!searchTerm.trim() ||
|
||||
(t.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
String(t.id).includes(searchTerm) ||
|
||||
encodeHashIdSync(t.id).toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
(t.createdBy || '').toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus =
|
||||
@@ -204,10 +356,9 @@ export const TenantManagementPage: React.FC = () => {
|
||||
const inactiveCount = tenants.filter((t) => Number(t.status) === 2).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>[] = [
|
||||
{ header: 'Tenant ID', key: 'id' },
|
||||
{ header: 'Partition HashID', key: 'id', format: (val) => encodeHashIdSync(val) },
|
||||
{ header: 'Tenant Name', key: 'name', format: (val) => val || '—' },
|
||||
{ header: 'Operational Status', key: 'status', format: (val) => getStatusString(val) },
|
||||
{ header: 'Created By', key: 'createdBy', format: (val) => val || '—' },
|
||||
@@ -217,51 +368,63 @@ export const TenantManagementPage: React.FC = () => {
|
||||
|
||||
const handleRefreshAll = () => {
|
||||
refetchTenants();
|
||||
refetchOrgs();
|
||||
queryClient.invalidateQueries({ queryKey: ['tenant-organisations'] });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-12">
|
||||
{/* Top Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-slate-800/80 pb-5">
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5 mb-1">
|
||||
<div className="p-2 rounded-xl bg-blue-500/10 border border-blue-500/20 text-blue-400">
|
||||
<Database className="w-5 h-5" />
|
||||
<div className="space-y-6 max-w-7xl mx-auto pb-12">
|
||||
{/* Header Banner - Conforming to standard page design */}
|
||||
<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 className="flex items-center gap-2.5 flex-wrap">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
|
||||
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">
|
||||
Directory
|
||||
</span>
|
||||
<HelpTooltip
|
||||
title="Multi-Tenancy & Partition Governance"
|
||||
content="Govern database partition boundaries, manage statutory authorities, and control operational tenancy lifecycle status."
|
||||
placement="right"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs sm:text-sm text-slate-400 font-normal mt-0.5">
|
||||
Manage multi-tenant isolated partitions, statutory boundaries, and enterprise organizational tenancy.
|
||||
</p>
|
||||
</div>
|
||||
<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/20">
|
||||
Multi-Tenancy
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 max-w-2xl">
|
||||
Control tenant database partition boundaries, onboard statutory authorities, and govern tenant lifecycle status.
|
||||
Expand any tenant row to inspect the scoped organisations governed under that partition.
|
||||
</p>
|
||||
</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
|
||||
onClick={handleRefreshAll}
|
||||
disabled={fetchingTenants || fetchingOrgs}
|
||||
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"
|
||||
title="Refresh tenants directory and organisations"
|
||||
disabled={fetchingTenants}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
<ExportButton
|
||||
title="PFM Multi-Tenant Directory Register"
|
||||
subtitle="Official directory of registered tenant partitions"
|
||||
filename={`pfm-tenants-${new Date().toISOString().split('T')[0]}`}
|
||||
data={sortedData}
|
||||
columns={exportColumns}
|
||||
filename="PFM_Tenants_Directory"
|
||||
label="Export Tenants"
|
||||
/>
|
||||
|
||||
<button
|
||||
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" />
|
||||
<span>New Tenant</span>
|
||||
@@ -272,7 +435,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
{/* Summary KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* 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>
|
||||
<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>
|
||||
@@ -284,7 +447,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
@@ -296,7 +459,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
<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>
|
||||
@@ -308,7 +471,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<p className="text-xs font-medium text-blue-300 flex items-center gap-1.5">
|
||||
<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'}>
|
||||
{currentTenantName || `Tenant #${currentTenantId || 1}`}
|
||||
</p>
|
||||
<p className="text-[11px] font-mono text-blue-400 mt-0.5 truncate">
|
||||
HashID: {currentHashedTenantId || '—'}
|
||||
<p className="text-[11px] text-blue-400 font-mono mt-0.5">
|
||||
Tenant ID: #{currentTenantId || 1}
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by Tenant Name, ID, HashID, or Creator..."
|
||||
placeholder="Search by Tenant Name, ID, or Creator..."
|
||||
value={searchTerm}
|
||||
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 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
|
||||
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'
|
||||
? 'bg-blue-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
@@ -358,7 +521,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
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'
|
||||
? 'bg-emerald-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
@@ -368,7 +531,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
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'
|
||||
? 'bg-amber-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
@@ -378,7 +541,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
</button>
|
||||
<button
|
||||
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'
|
||||
? 'bg-rose-600 text-white shadow-xs'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
@@ -391,16 +554,16 @@ export const TenantManagementPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<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">
|
||||
<span className="sr-only">Expand</span>
|
||||
</th>
|
||||
<SortableTh column="id" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4">
|
||||
Tenant ID & HashID
|
||||
Tenant ID
|
||||
</SortableTh>
|
||||
<SortableTh column="name" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} className="py-3.5 px-4">
|
||||
Tenant Organization Name
|
||||
@@ -441,19 +604,11 @@ export const TenantManagementPage: React.FC = () => {
|
||||
</tr>
|
||||
) : (
|
||||
sortedData.map((tenant) => {
|
||||
const hashId = encodeHashIdSync(tenant.id);
|
||||
const isCurrent = String(tenant.id) === String(currentTenantId);
|
||||
const s = Number(tenant.status ?? ActivityStatus.Active);
|
||||
const isActive = s === 1 || s === ActivityStatus.Active;
|
||||
const isExpanded = !!expandedTenants[String(tenant.id)];
|
||||
|
||||
// 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)
|
||||
);
|
||||
const creatorEmail = getUserEmail(tenant.createdBy) || tenant.createdBy || 'System Administrator';
|
||||
|
||||
return (
|
||||
<React.Fragment key={tenant.id}>
|
||||
@@ -477,28 +632,12 @@ export const TenantManagementPage: React.FC = () => {
|
||||
</button>
|
||||
</td>
|
||||
|
||||
{/* ID & HashID */}
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{/* Integer Tenant ID only (no hashid) */}
|
||||
<td className="py-3 px-4 font-mono">
|
||||
<span className="font-semibold text-white">#{tenant.id}</span>
|
||||
</td>
|
||||
|
||||
{/* Tenant Name & Sub-Organisations Count */}
|
||||
{/* Tenant Name */}
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="w-4 h-4 text-slate-400 shrink-0" />
|
||||
@@ -508,13 +647,6 @@ export const TenantManagementPage: React.FC = () => {
|
||||
Active Context
|
||||
</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>
|
||||
</td>
|
||||
|
||||
@@ -534,9 +666,11 @@ export const TenantManagementPage: React.FC = () => {
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Created By */}
|
||||
{/* Created By (Swapped ID for Email where available) */}
|
||||
<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>
|
||||
|
||||
{/* Created Date */}
|
||||
@@ -566,126 +700,11 @@ export const TenantManagementPage: React.FC = () => {
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Expanded Sub-List of Organisations */}
|
||||
{/* Expanded Sub-List of Organisations using getTenantOrganisations */}
|
||||
{isExpanded && (
|
||||
<tr className="bg-slate-950/70 border-b border-slate-800/80">
|
||||
<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">
|
||||
{/* 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>
|
||||
<TenantOrganisationsSubList tenantId={tenant.id} tenantName={tenant.name} />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
@@ -730,7 +749,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
type="text"
|
||||
placeholder="e.g. Technology Innovation Agency (TIA)"
|
||||
{...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 && (
|
||||
<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
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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" />}
|
||||
<span>{createMutation.isPending ? 'Provisioning...' : 'Create Tenant'}</span>
|
||||
@@ -782,7 +801,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
<input
|
||||
type="text"
|
||||
{...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 && (
|
||||
<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>
|
||||
<select
|
||||
{...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.Inactive}>Inactive (Tenant Lockout)</option>
|
||||
@@ -821,14 +840,14 @@ export const TenantManagementPage: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
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" />}
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
@@ -892,7 +891,9 @@ export const TenantManagementPage: React.FC = () => {
|
||||
|
||||
<div>
|
||||
<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>
|
||||
|
||||
{selectedTenant.updatedAt && (
|
||||
@@ -905,7 +906,9 @@ export const TenantManagementPage: React.FC = () => {
|
||||
{selectedTenant.updatedBy && (
|
||||
<div>
|
||||
<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>
|
||||
@@ -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">
|
||||
<p className="font-semibold text-white flex items-center gap-1.5">
|
||||
<ShieldCheck className="w-4 h-4 text-blue-400" />
|
||||
Partition Scope & Authentication
|
||||
Partition Scope & Authentication
|
||||
</p>
|
||||
<p className="text-[11px] text-slate-400 leading-relaxed">
|
||||
When users authenticate, their security token contains claims identifying their tenant partition.
|
||||
@@ -928,7 +931,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
setIsDetailModalOpen(false);
|
||||
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" />
|
||||
<span>Edit Tenant Status</span>
|
||||
@@ -936,7 +939,7 @@ export const TenantManagementPage: React.FC = () => {
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
ProblemDetails,
|
||||
ActivityStatus,
|
||||
Tenant,
|
||||
TenantOrganisation,
|
||||
CreateTenantRequest,
|
||||
UpdateTenantRequest,
|
||||
Beneficiary,
|
||||
@@ -180,6 +181,7 @@ export const normalizeOrganisation = (raw: any): Organisation => {
|
||||
name: raw.name ?? raw.Name ?? null,
|
||||
registrationNo: raw.registrationNo ?? raw.RegistrationNo ?? raw.regNo ?? raw.RegNo ?? 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,
|
||||
status,
|
||||
createdAt: raw.createdAt ?? raw.CreatedAt ?? new Date().toISOString(),
|
||||
@@ -214,6 +216,8 @@ export const createOrganisation = async (payload: CreateOrganisationRequest): Pr
|
||||
name: payload.name,
|
||||
registrationNo: payload.registrationNo,
|
||||
email: payload.email,
|
||||
idNumber: payload.idNumber,
|
||||
organisationUser: payload.organisationUser,
|
||||
type: payload.type !== undefined ? Number(payload.type) : 5,
|
||||
});
|
||||
return res.data;
|
||||
@@ -225,6 +229,8 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr
|
||||
name: payload.name,
|
||||
registrationNo: payload.registrationNo,
|
||||
email: payload.email,
|
||||
idNumber: payload.idNumber,
|
||||
organisationUser: payload.organisationUser,
|
||||
type: Number(payload.type),
|
||||
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
|
||||
// ==========================================
|
||||
|
||||
@@ -262,6 +262,7 @@ export interface User {
|
||||
status: ActivityStatus;
|
||||
lastLoginAt: string | null;
|
||||
role?: string | UserRole | null;
|
||||
idNumber?: string | null;
|
||||
name?: string;
|
||||
organisationId?: number | string;
|
||||
organisationName?: string;
|
||||
@@ -302,6 +303,7 @@ export interface Organisation {
|
||||
name: string | null;
|
||||
registrationNo: string | null;
|
||||
email: string | null;
|
||||
userId?: string | null;
|
||||
type: OrganisationType;
|
||||
status: ActivityStatus;
|
||||
createdAt: string;
|
||||
@@ -312,6 +314,8 @@ export interface CreateOrganisationRequest {
|
||||
name: string;
|
||||
registrationNo: string;
|
||||
email: string;
|
||||
idNumber: string;
|
||||
organisationUser: string;
|
||||
type: OrganisationType;
|
||||
}
|
||||
|
||||
@@ -320,10 +324,24 @@ export interface UpdateOrganisationRequest {
|
||||
name: string;
|
||||
registrationNo: string;
|
||||
email: string;
|
||||
idNumber: string;
|
||||
organisationUser: string;
|
||||
type: OrganisationType;
|
||||
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 {
|
||||
id: number | string;
|
||||
tenantId?: number | string;
|
||||
|
||||
Reference in New Issue
Block a user