From 681ed99fea2714de9e0afc7f08db9e765972b63e Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 4 Oct 2026 10:34:59 +0200 Subject: [PATCH] Fine tuned organisation onboarding and tenant management --- .../src/pages/GovernancePage.tsx | 90 ++- .../src/pages/SettingsPage.tsx | 17 +- .../src/pages/TenantManagementPage.tsx | 517 +++++++++--------- .../src/services/api.ts | 26 + PostFundManagement.ConsoleUI/src/types.ts | 18 + 5 files changed, 407 insertions(+), 261 deletions(-) diff --git a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx index ea18858..23435d3 100644 --- a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx @@ -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 = () => { +
+
+
+ + +
+ + {orgForm.formState.errors.organisationUser && ( +

{orgForm.formState.errors.organisationUser.message}

+ )} +
+ +
+
+ + +
+ + {orgForm.formState.errors.idNumber && ( +

{orgForm.formState.errors.idNumber.message}

+ )} +
+
+
@@ -2103,6 +2183,12 @@ export const GovernancePage: React.FC = () => { Entity Type: {getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}
+
+ Linked User: + + {getUserEmail(orgFreshDetail?.userId || selectedOrgForDetail?.userId) || orgFreshDetail?.userId || selectedOrgForDetail?.userId || '—'} + +
diff --git a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx index 0d2a8ee..f831952 100644 --- a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx @@ -441,6 +441,7 @@ export const SettingsPage: React.FC = () => { const userExportColumns: ExportColumn[] = [ { 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 = () => { User Identifier + + Id Number + Role @@ -785,13 +789,13 @@ export const SettingsPage: React.FC = () => { {loadingUsers ? ( - + Loading user directory... ) : sortedUsers.length === 0 ? ( - + No users found matching current filters. @@ -806,6 +810,9 @@ export const SettingsPage: React.FC = () => { {u.id} + + {u.idNumber || —} + @@ -2113,6 +2120,12 @@ export const SettingsPage: React.FC = () => { {userFreshDetail?.id || selectedUserForDetail.id} +
+ Id Number: + + {userFreshDetail?.idNumber || selectedUserForDetail.idNumber || '—'} + +
System Role: diff --git a/PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx b/PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx index 8d7afac..23fded3 100644 --- a/PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/TenantManagementPage.tsx @@ -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; type UpdateTenantFormValues = z.infer; +// 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({ + queryKey: ['tenant-organisations', tenantId], + queryFn: () => getTenantOrganisations(tenantId), + staleTime: 30000, + }); + + return ( +
+ {/* Sub-list Header */} +
+
+
+ +
+
+
+

+ Scoped Organisations for {tenantName || `Tenant #${tenantId}`} +

+ + {tenantOrganisations.length} Legal {tenantOrganisations.length === 1 ? 'Entity' : 'Entities'} + +
+

+ Legal entities and holding structures mapped under Tenant ID #{tenantId} partition boundary (Read-Only). +

+
+
+ +
+ +
+ Organisation management is handled in Governance & Structure +
+
+
+ + {/* Organisations Sub-Table */} + {isLoading ? ( +
+ + Loading partition organisations from server... +
+ ) : isError ? ( +
+

Unable to retrieve organisations for Tenant #{tenantId}.

+ +
+ ) : tenantOrganisations.length === 0 ? ( +
+ +

No organisations assigned to Tenant #{tenantId}

+

+ New legal entities provisioned with Tenant ID #{tenantId} will automatically appear in this partition view. +

+
+ ) : ( +
+ + + + + + + + + + + + + + + {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 ( + + {/* Integer Org ID only (no hashid) */} + + + {/* Organisation Name */} + + + {/* Registration No */} + + + {/* Entity Type */} + + + {/* Contact Email */} + + + {/* Linked User (swapped raw ID for email) */} + + + {/* Status */} + + + {/* Created Date */} + + + ); + })} + +
Org IDOrganisation NameRegistration No.Entity TypeContact EmailLinked User EmailStatusCreated Date
+ #{orgId} + + {orgName} + + {regNo} + + + {orgType !== undefined ? getOrganisationTypeLabel(orgType) : 'Organisation'} + + + {orgEmail && orgEmail !== '—' ? ( +
+ + {orgEmail} +
+ ) : ( + — + )} +
+
+ + + {userEmailDisplay} + +
+
+ + + {formatDate(createdAt)} +
+
+ )} +
+ ); +}; + 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('all'); - const [copiedId, setCopiedId] = useState(null); const [expandedTenants, setExpandedTenants] = useState>({}); // 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({ - queryKey: ['organisations'], - queryFn: () => getOrganisations(), - staleTime: 60000, - }); - // Forms const createForm = useForm({ 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[] = [ { 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 ( -
- {/* Top Header */} -
-
-
-
- +
+ {/* Header Banner - Conforming to standard page design */} +
+
+
+
+
+ +
+
+
+

+ Tenant Management +

+ + Directory + + +
+

+ Manage multi-tenant isolated partitions, statutory boundaries, and enterprise organizational tenancy. +

-

Tenant Management

- - Multi-Tenancy -
-

- 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. -

-
+
{/* Tenants Table */} -
+
- + - Tenant ID & HashID + Tenant ID Tenant Organization Name @@ -441,19 +604,11 @@ export const TenantManagementPage: React.FC = () => { ) : ( 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 ( @@ -477,28 +632,12 @@ export const TenantManagementPage: React.FC = () => { - {/* ID & HashID */} - - {/* Tenant Name & Sub-Organisations Count */} + {/* Tenant Name */} @@ -534,9 +666,11 @@ export const TenantManagementPage: React.FC = () => { - {/* Created By */} + {/* Created By (Swapped ID for Email where available) */} {/* Created Date */} @@ -566,126 +700,11 @@ export const TenantManagementPage: React.FC = () => { - {/* Expanded Sub-List of Organisations */} + {/* Expanded Sub-List of Organisations using getTenantOrganisations */} {isExpanded && ( )} @@ -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 && (

{createForm.formState.errors.name.message}

@@ -741,14 +760,14 @@ export const TenantManagementPage: React.FC = () => {
Expand
-
- #{tenant.id} - - {hashId} - - -
+ {/* Integer Tenant ID only (no hashid) */} +
+ #{tenant.id}
@@ -508,13 +647,6 @@ export const TenantManagementPage: React.FC = () => { Active Context )} -
- {tenant.createdBy || 'System Administrator'} + + {creatorEmail} +
-
- {/* Sub-list Header */} -
-
-
- -
-
-
-

- Scoped Organisations for {tenant.name || `Tenant #${tenant.id}`} -

- - {tenantOrganisations.length} Legal {tenantOrganisations.length === 1 ? 'Entity' : 'Entities'} - -
-

- Legal entities and holding structures mapped under Tenant ID #{tenant.id} partition boundary (Read-Only). -

-
-
- -
- Organisation management is handled in Governance & Structure -
-
- - {/* Organisations Sub-Table */} - {loadingOrgs ? ( -
- - Loading organisations... -
- ) : tenantOrganisations.length === 0 ? ( -
- -

No organisations assigned to this tenant

-

- New organisations provisioned with Tenant ID #{tenant.id} will automatically appear in this partition view. -

-
- ) : ( -
- - - - - - - - - - - - - - {tenantOrganisations.map((org) => { - const orgHashId = encodeHashIdSync(org.id); - return ( - - {/* ID & HashID */} - - - {/* Name */} - - - {/* Registration No */} - - - {/* Type */} - - - {/* Email */} - - - {/* Status */} - - - {/* Created Date */} - - - ); - })} - -
Org ID & HashIDOrganisation NameRegistration No.Entity TypeOfficial ContactStatusCreated Date
-
- #{org.id} - - {orgHashId} - -
-
- {org.name || 'Unnamed Organisation'} - - {org.registrationNo || '—'} - - - {getOrganisationTypeLabel(org.type)} - - - {org.email ? ( -
- - {org.email} -
- ) : ( - — - )} -
- - - {formatDate(org.createdAt)} -
-
- )} -
+