import React, { useState, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import { Building2, Plus, FolderKanban, Edit2, Mail, Shield, ShieldCheck, Layers, ChevronRight, Search, Coins, Eye, RefreshCw, ArrowRight, FileText, Scale, Calendar, UserPlus, ChevronDown, ChevronUp, CheckCircle, CheckCircle2, Loader2, Lock, } from 'lucide-react'; import { getOrganisations, getOrganisation, createOrganisation, updateOrganisation, getAllPortfolios, getPortfolios, getOrganisationPortfolios, getPortfolio, createPortfolio, updatePortfolio, getAllProgrammes, getProgrammes, getProgramme, createProgramme, updateProgramme, getInstruments, getInstructions, createInstrument, updateInstrument, getRules, createRule, updateRule, getPortfolioProgrammes, getPortfolioValue, getProgrammeValue, getUsers, getUser, createUser, assignUserRole, getSystemRoles, getCurrentUserTenantId, getTenantUsers, notifyNewUserCreated, } from '../services/api'; import { Modal } from '../components/ui/Modal'; import { HelpTooltip } from '../components/ui/HelpTooltip'; import { useToast } from '../components/ui/Toast'; import { ExportButton } from '../components/ui/ExportButton'; import { ExportColumn } from '../utils/exportUtils'; import { useAuth } from '../auth/AuthProvider'; import { useRbac } from '../auth/rbac'; import { bumpMutationEpoch } from '../services/httpClient'; import { useTableSort, SortableTh } from '../utils/tableSort'; import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection'; import { RulesSection } from '../components/governance/RulesSection'; import { Organisation, TenantOrganisation, TenantUser, Portfolio, Programme, Instrument, Instruction, Rule, User, OrganisationType, ActivityStatus, ApprovalStatus, Role, CreateUserRequest, CreateOrganisationRequest, CreatePortfolioRequest, CreateProgrammeRequest, } from '../types'; import { getOrganisationTypeLabel, getActivityStatusBadge, getOrganisationStatusBadge, getApprovalStatusBadge, formatDate, formatZAR, getUserEmail, registerUserEmail, getUserIdByEmail, ORGANISATION_TYPE_OPTIONS, ACTIVITY_STATUS_OPTIONS, APPROVAL_STATUS_OPTIONS, } from '../lib/formatters'; // Validation Schemas matching API constraints 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'), organisationUser: z.string().min(1, 'Please select a linked executive or manager user'), type: z.coerce.number().min(1).max(27), status: z.coerce.number().optional(), }); const portfolioSchema = z.object({ name: z.string().min(3, 'Portfolio name is required'), description: z.string().min(5, 'Description is required'), organisationId: z.coerce.number().min(1, 'Select a parent organisation'), ownerEmail: z.string().min(1, 'Owner email is required'), }); const programmeSchema = z.object({ name: z.string().min(3, 'Programme name is required'), portfolioId: z.coerce.number().min(1, 'Select a parent portfolio'), status: z.coerce.number().optional(), ownerEmail: z.string().min(1, 'Owner email is required'), }); type OrganisationFormValues = z.infer; type PortfolioFormValues = z.infer; type ProgrammeFormValues = z.infer; export const GovernancePage: React.FC = () => { const queryClient = useQueryClient(); const { sub, user, claims, roles, tenantId } = useAuth(); const { canAdd, canApprove, canExport, canApproveRecord, isMaker, isUnrestricted } = useRbac(); const currentUserId = sub || user?.id || ''; const { success, error: toastError } = useToast(); const effectiveUserEmail = user?.email || (claims?.email as string) || (claims?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) || (claims?.upn as string) || (sub ? getUserEmail(sub) : '') || ''; useEffect(() => { if (effectiveUserEmail) { if (sub) registerUserEmail(sub, effectiveUserEmail); if (user?.id) registerUserEmail(user.id, effectiveUserEmail); } }, [sub, user, effectiveUserEmail]); const [searchParams, setSearchParams] = useSearchParams(); const tabParam = searchParams.get('tab'); const getValidTab = (val: string | null): 'organisations' | 'portfolios' | 'programmes' | 'instruments' | 'instructions' | 'rules' => { if (val === 'portfolios' || val === 'programmes' || val === 'organisations' || val === 'instruments' || val === 'instructions' || val === 'rules') { return val === 'instructions' ? 'instruments' : val; } return 'organisations'; }; const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes' | 'instruments' | 'instructions' | 'rules'>(() => getValidTab(tabParam)); useEffect(() => { if (tabParam && (tabParam === 'organisations' || tabParam === 'portfolios' || tabParam === 'programmes' || tabParam === 'instruments' || tabParam === 'instructions' || tabParam === 'rules')) { setActiveTab(tabParam === 'instructions' ? 'instruments' : tabParam); } }, [tabParam]); const handleTabChange = (tab: 'organisations' | 'portfolios' | 'programmes' | 'instruments' | 'instructions' | 'rules') => { const canonicalTab = tab === 'instructions' ? 'instruments' : tab; setActiveTab(canonicalTab); setSearchParams((prev) => { const next = new URLSearchParams(prev); next.set('tab', canonicalTab); return next; }); }; const [preselectedProgForInstruction, setPreselectedProgForInstruction] = useState(null); const [preselectedProgForRule, setPreselectedProgForRule] = useState(null); const [isInstructionCreateTriggered, setIsInstructionCreateTriggered] = useState(false); const [isRuleCreateTriggered, setIsRuleCreateTriggered] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const [highlightedPortfolioId, setHighlightedPortfolioId] = useState(null); const handleNavigateToPortfolioTab = (portfolioId?: number | string) => { setIsOrgDetailModalOpen(false); setSelectedOrgForDetail(null); handleTabChange('portfolios'); if (portfolioId != null) { setHighlightedPortfolioId(portfolioId); setTimeout(() => { const el = document.getElementById(`portfolio-card-${portfolioId}`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, 150); setTimeout(() => { setHighlightedPortfolioId(null); }, 3500); } }; // Modals & Editing State const [isOrgModalOpen, setIsOrgModalOpen] = useState(false); const [editingOrg, setEditingOrg] = useState(null); const [isPortfolioModalOpen, setIsPortfolioModalOpen] = useState(false); const [editingPortfolio, setEditingPortfolio] = useState(null); const [isProgrammeModalOpen, setIsProgrammeModalOpen] = useState(false); const [editingProgramme, setEditingProgramme] = useState(null); // Inspection states const [selectedProgrammeForInstruments, setSelectedProgrammeForInstruments] = useState(null); const [isInstrumentsModalOpen, setIsInstrumentsModalOpen] = useState(false); const [selectedProgrammeForRules, setSelectedProgrammeForRules] = useState(null); const [isRulesModalOpen, setIsRulesModalOpen] = useState(false); const [selectedPortfolioForProgrammes, setSelectedPortfolioForProgrammes] = useState(null); const [isPortfolioProgrammesModalOpen, setIsPortfolioProgrammesModalOpen] = useState(false); const [selectedOrgForDetail, setSelectedOrgForDetail] = useState(null); const [isOrgDetailModalOpen, setIsOrgDetailModalOpen] = useState(false); const [selectedPortfolioForDetail, setSelectedPortfolioForDetail] = useState(null); const [isPortfolioDetailModalOpen, setIsPortfolioDetailModalOpen] = useState(false); const [selectedProgrammeForDetail, setSelectedProgrammeForDetail] = useState(null); const [isProgrammeDetailModalOpen, setIsProgrammeDetailModalOpen] = useState(false); // Queries const { data: usersDirectory = [] } = useQuery({ queryKey: ['users-directory'], queryFn: () => getUsers(1, 200), }); const { data: systemRoles = [] } = useQuery({ queryKey: ['system-roles'], queryFn: getSystemRoles, enabled: isOrgModalOpen, }); const assignableRolesForOrg = React.useMemo(() => { return systemRoles.filter( (r) => (r.name || r.Name || '').toLowerCase() !== 'admin' && (r.id || r.Id) !== '1' ); }, [systemRoles]); const executiveAndManagerUsers = React.useMemo(() => { return usersDirectory.filter((u) => { const role = String(u.role || '').toLowerCase(); return role === 'executive' || role === 'manager'; }); }, [usersDirectory]); const isAdmin = Boolean(isUnrestricted) || (Array.isArray(roles) && roles.some((r) => r.toLowerCase().trim() === 'admin')) || (claims?.role && String(claims.role).toLowerCase().includes('admin')); const orgTenantId = editingOrg?.tenantId || tenantId || getCurrentUserTenantId() || 1; const { data: tenantUsersList = [] } = useQuery({ queryKey: ['tenant-users', orgTenantId], queryFn: () => getTenantUsers(orgTenantId), enabled: isOrgModalOpen && !!editingOrg && !isAdmin, }); useEffect(() => { if (tenantUsersList.length > 0) { tenantUsersList.forEach((tu) => { if (tu.userId && tu.user?.email) { registerUserEmail(tu.userId, tu.user.email); } }); } }, [tenantUsersList]); const modalExecutiveUsers = React.useMemo(() => { const list: User[] = []; const seenIds = new Set(); const addCandidate = (u?: User | null) => { if (!u || !u.id) return; const key = String(u.id).toLowerCase(); if (seenIds.has(key)) return; seenIds.add(key); list.push(u); }; // 1. Always include the organisation's linked user so the select option exists and selects immediately const linkedUserId = editingOrg?.userId || editingOrg?.UserId || editingOrg?.organisationUser || editingOrg?.OrganisationUser || ''; if (linkedUserId) { const resolvedEmail = getUserEmail(linkedUserId); const matched = usersDirectory.find( (u) => u.id && String(u.id).toLowerCase() === String(linkedUserId).toLowerCase() ); addCandidate({ id: linkedUserId, email: matched?.email || (resolvedEmail !== '—' && resolvedEmail ? resolvedEmail : linkedUserId), role: matched?.role || 'Executive', status: ActivityStatus.Active, lastLoginAt: null, }); } // 2. Load Executive role users: Admin sees all executives across tenants; normal tenant user focuses on tenantusers if (isAdmin) { usersDirectory .filter((u) => String(u.role || '').toLowerCase() === 'executive') .forEach(addCandidate); } else { tenantUsersList .filter((tu) => String(tu.user?.role || '').toLowerCase() === 'executive') .forEach((tu) => { if (tu.user) { addCandidate(tu.user); } else if (tu.userId) { addCandidate({ id: tu.userId, email: getUserEmail(tu.userId), role: 'Executive', status: ActivityStatus.Active, lastLoginAt: null, }); } }); } return list; }, [isAdmin, usersDirectory, tenantUsersList, editingOrg?.id, editingOrg?.userId]); // Quick Add Executive User Subform state const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false); const [quickUserEmail, setQuickUserEmail] = useState(''); const [quickUserName, setQuickUserName] = useState(''); const [quickUserPhone, setQuickUserPhone] = useState(''); const [quickUserIdNumber, setQuickUserIdNumber] = useState(''); const [quickUserRoleId, setQuickUserRoleId] = useState(''); const [isSubmittingQuickUser, setIsSubmittingQuickUser] = useState(false); const [quickUserError, setQuickUserError] = useState(null); React.useEffect(() => { if (!quickUserRoleId && assignableRolesForOrg.length > 0) { const execRole = assignableRolesForOrg.find((r) => (r.name || r.Name || '').toLowerCase() === 'executive'); if (execRole) setQuickUserRoleId(execRole.id || execRole.Id || ''); else setQuickUserRoleId(assignableRolesForOrg[0].id || assignableRolesForOrg[0].Id || ''); } }, [assignableRolesForOrg, quickUserRoleId]); const handleQuickAddUser = async () => { setQuickUserError(null); if (!quickUserEmail || !quickUserEmail.includes('@')) { setQuickUserError('Please provide a valid email address.'); return; } if (!quickUserIdNumber || quickUserIdNumber.trim().length < 4) { setQuickUserError('Government issued ID number is required (min 4 characters).'); return; } setIsSubmittingQuickUser(true); try { const tenantId = getCurrentUserTenantId() || 1; const rawEmail = quickUserEmail.trim().toLowerCase(); const sanitizedUserName = quickUserName.trim() ? quickUserName.trim().toLowerCase().replace(/\s+/g, '.').replace(/[^a-z0-9_.\-@+]/g, '') : rawEmail.split('@')[0]; const payload: CreateUserRequest = { email: rawEmail, userName: sanitizedUserName || rawEmail, emailConfirmed: true, phoneNumber: quickUserPhone.trim() || null, phoneNumberConfirmed: false, LockoutEnabled: false, twoFactorEnabled: false, accessFailedCount: 0, }; await createUser(tenantId, quickUserIdNumber.trim(), payload); // Invalidate queries and fetch fresh user list immediately await queryClient.invalidateQueries({ queryKey: ['users-directory'] }); await queryClient.invalidateQueries({ queryKey: ['users'] }); const freshUsers = await getUsers(1, 200); const createdUser = freshUsers.find( (u) => u.email && u.email.toLowerCase() === quickUserEmail.trim().toLowerCase() ); if (createdUser && quickUserRoleId) { try { await assignUserRole(createdUser.id, quickUserRoleId); await queryClient.invalidateQueries({ queryKey: ['users-directory'] }); await queryClient.invalidateQueries({ queryKey: ['users'] }); } catch { // Handled gracefully } orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true }); } else if (createdUser) { orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true }); } // RULE 3: Notify created Executive/Manager user via InApp and Email (directs to Console) if (createdUser) { await notifyNewUserCreated({ userId: createdUser.id, email: quickUserEmail, isPartner: false, roleName: 'Executive', tenantId, }).catch(console.warn); queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] }); queryClient.invalidateQueries({ queryKey: ['notifications'] }); } success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`); setQuickUserEmail(''); setQuickUserName(''); setQuickUserPhone(''); setQuickUserIdNumber(''); setIsQuickAddUserOpen(false); } catch (err: unknown) { const errObj = err as { response?: { data?: { detail?: string } }; message?: string }; const errorDetail = errObj?.response?.data?.detail || errObj?.message || 'Failed to create user.'; setQuickUserError(errorDetail); toastError('Failed to create user', err); } finally { setIsSubmittingQuickUser(false); } }; const { data: organisations = [], isLoading: loadingOrgs, isFetching: isFetchingOrgs, refetch: refetchOrgs, } = useQuery({ queryKey: ['organisations'], queryFn: () => getOrganisations(), }); const { data: portfolios = [], isLoading: loadingPortfolios, isFetching: isFetchingPortfolios, refetch: refetchPortfolios, } = useQuery({ queryKey: ['portfolios'], queryFn: () => getAllPortfolios(), }); const { data: programmes = [], isLoading: loadingProgrammes, isFetching: isFetchingProgrammes, refetch: refetchProgrammes, } = useQuery({ queryKey: ['programmes'], queryFn: () => getAllProgrammes(), }); const { data: programmeInstruments = [], isLoading: loadingInstruments } = useQuery({ queryKey: ['instruments', selectedProgrammeForInstruments?.id], queryFn: () => (selectedProgrammeForInstruments ? getInstructions(selectedProgrammeForInstruments.id) : Promise.resolve([])), enabled: !!selectedProgrammeForInstruments && isInstrumentsModalOpen, }); const { data: programmeRulesList = [], isLoading: loadingProgrammeRules } = useQuery({ queryKey: ['programme-rules', selectedProgrammeForRules?.id], queryFn: () => (selectedProgrammeForRules ? getRules({ page: 1, pageSize: 200 }, selectedProgrammeForRules.id) : Promise.resolve([])), enabled: !!selectedProgrammeForRules && isRulesModalOpen, }); const { data: programmeDetailRules = [], isLoading: loadingDetailRules } = useQuery({ queryKey: ['programme-detail-rules', selectedProgrammeForDetail?.id], queryFn: () => (selectedProgrammeForDetail ? getRules({ page: 1, pageSize: 200 }, selectedProgrammeForDetail.id) : Promise.resolve([])), enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen, }); const { data: programmeDetailInstructions = [], isLoading: loadingDetailInstructions } = useQuery({ queryKey: ['programme-detail-instructions', selectedProgrammeForDetail?.id], queryFn: () => (selectedProgrammeForDetail ? getInstructions(selectedProgrammeForDetail.id) : Promise.resolve([])), enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen, }); const { data: portfolioProgrammesList = [], isLoading: loadingPortfolioProgrammes } = useQuery({ queryKey: ['portfolioProgrammes', selectedPortfolioForProgrammes?.id], queryFn: () => (selectedPortfolioForProgrammes ? getPortfolioProgrammes(selectedPortfolioForProgrammes.id) : Promise.resolve([])), enabled: !!selectedPortfolioForProgrammes && isPortfolioProgrammesModalOpen, }); const { data: portfolioDirectProgrammes = [] } = useQuery({ queryKey: ['portfolio-direct-programmes', selectedPortfolioForProgrammes?.id], queryFn: () => (selectedPortfolioForProgrammes ? getProgrammes(selectedPortfolioForProgrammes.id) : Promise.resolve([])), enabled: !!selectedPortfolioForProgrammes && isPortfolioProgrammesModalOpen, }); const { data: orgFreshDetail, isLoading: loadingOrgFreshDetail } = useQuery({ queryKey: ['organisation', selectedOrgForDetail?.id], queryFn: () => (selectedOrgForDetail ? getOrganisation(selectedOrgForDetail.id) : null), enabled: !!selectedOrgForDetail && isOrgDetailModalOpen, }); const { data: orgPortfolios = [], isLoading: loadingOrgPortfolios } = useQuery({ queryKey: ['organisation-portfolios', selectedOrgForDetail?.id], queryFn: () => (selectedOrgForDetail ? getOrganisationPortfolios(selectedOrgForDetail.id) : Promise.resolve([])), enabled: !!selectedOrgForDetail && isOrgDetailModalOpen, }); const { data: portfolioFreshDetail, isLoading: loadingPortfolioFreshDetail } = useQuery({ queryKey: ['portfolio', selectedPortfolioForDetail?.id], queryFn: () => (selectedPortfolioForDetail ? getPortfolio(selectedPortfolioForDetail.id) : null), enabled: !!selectedPortfolioForDetail && isPortfolioDetailModalOpen, }); const { data: portfolioValue = 0, isLoading: loadingPortfolioValue } = useQuery({ queryKey: ['portfolio-value', selectedPortfolioForDetail?.id], queryFn: () => (selectedPortfolioForDetail ? getPortfolioValue(selectedPortfolioForDetail.id) : Promise.resolve(0)), enabled: !!selectedPortfolioForDetail && isPortfolioDetailModalOpen, }); const { data: programmeFreshDetail, isLoading: loadingProgrammeFreshDetail } = useQuery({ queryKey: ['programme', selectedProgrammeForDetail?.id], queryFn: () => (selectedProgrammeForDetail ? getProgramme(selectedProgrammeForDetail.id) : null), enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen, }); const { data: programmeValue = 0, isLoading: loadingProgrammeValue } = useQuery({ queryKey: ['programme-value', selectedProgrammeForDetail?.id], queryFn: () => (selectedProgrammeForDetail ? getProgrammeValue(selectedProgrammeForDetail.id) : Promise.resolve(0)), enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen, }); // Owner resolution helpers const resolveOwnerId = (emailOrId: string, fallbackOwnerId?: string | number | null): string => { const trimmed = (emailOrId || '').trim(); if (!trimmed) { return fallbackOwnerId ? String(fallbackOwnerId) : currentUserId; } // If already a valid UUID if (/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(trimmed)) { return trimmed; } const cleanEmail = trimmed.toLowerCase(); // 1. Current logged-in user match if (effectiveUserEmail && cleanEmail === effectiveUserEmail.toLowerCase()) { return sub || user?.id || (fallbackOwnerId ? String(fallbackOwnerId) : currentUserId); } // 2. Reverse cache lookup const cachedId = getUserIdByEmail(cleanEmail); if (cachedId) return cachedId; // 3. Search loaded users directory const matchedUser = usersDirectory.find( (u) => u.email && u.email.trim().toLowerCase() === cleanEmail ); if (matchedUser?.id) return String(matchedUser.id); // 4. Fallback ID match check if (fallbackOwnerId) { const origEmail = getUserEmail(fallbackOwnerId); if (origEmail && origEmail.toLowerCase() === cleanEmail) { return String(fallbackOwnerId); } } // 5. Default fallback return fallbackOwnerId ? String(fallbackOwnerId) : currentUserId; }; const resolveOwnerEmailForDisplay = async (rawOwnerId?: string | number | null): Promise => { if (!rawOwnerId) { return effectiveUserEmail; } const raw = String(rawOwnerId).trim(); if (raw.includes('@')) { return raw; } // Check if it's the current user if ( (sub && raw.toLowerCase() === sub.toLowerCase()) || (user?.id && raw.toLowerCase() === user.id.toLowerCase()) ) { return effectiveUserEmail; } // Check cached email const cached = getUserEmail(raw); if (cached && cached.includes('@')) { return cached; } // Check users directory const found = usersDirectory.find((u) => String(u.id).toLowerCase() === raw.toLowerCase()); if (found?.email) { return found.email; } // If it's a UUID, fetch user canonical record from API if (/^[0-9a-fA-F-]{36}$/.test(raw)) { try { const u = await getUser(raw); if (u?.email) { registerUserEmail(u.id, u.email); return u.email; } } catch { // Fetch failed } } return effectiveUserEmail; }; // Forms const orgForm = useForm({ resolver: zodResolver(organisationSchema), defaultValues: { name: '', registrationNo: '', email: '', organisationUser: '', type: OrganisationType.Enterprise, status: ActivityStatus.Active, }, }); const portfolioForm = useForm({ resolver: zodResolver(portfolioSchema), defaultValues: { name: '', description: '', organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101, ownerEmail: effectiveUserEmail, }, }); const programmeForm = useForm({ resolver: zodResolver(programmeSchema), defaultValues: { name: '', portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201, status: ApprovalStatus.Pending, ownerEmail: effectiveUserEmail, }, }); // Mutations const orgMutation = useMutation({ mutationFn: (data: OrganisationFormValues) => { if (editingOrg) { return updateOrganisation({ organisationId: editingOrg.id, name: data.name, registrationNo: data.registrationNo, email: data.email, organisationUser: data.organisationUser, userId: data.organisationUser, type: Number(data.type), status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active, }); } return createOrganisation({ name: data.name, registrationNo: data.registrationNo, email: data.email, organisationUser: data.organisationUser, type: Number(data.type), }); }, onSuccess: () => { bumpMutationEpoch(); queryClient.invalidateQueries({ queryKey: ['organisations'] }); success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully'); setIsOrgModalOpen(false); setEditingOrg(null); orgForm.reset(); }, onError: (err: unknown) => { toastError('Failed to save organisation', err); }, }); const portfolioMutation = useMutation({ mutationFn: (data: PortfolioFormValues) => { const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingPortfolio?.ownedBy); if (editingPortfolio) { return updatePortfolio({ portfolioId: editingPortfolio.id, name: data.name, description: data.description, organisationId: Number(data.organisationId), ownerId: resolvedOwnerId, }); } return createPortfolio({ name: data.name, description: data.description, organisationId: Number(data.organisationId), ownerId: resolvedOwnerId, }); }, onSuccess: () => { bumpMutationEpoch(); queryClient.invalidateQueries({ queryKey: ['portfolios'] }); success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully'); setIsPortfolioModalOpen(false); setEditingPortfolio(null); portfolioForm.reset(); }, onError: (err: unknown) => { toastError('Failed to save portfolio', err); }, }); const approveProgrammeMutation = useMutation({ mutationFn: async (pr: Programme) => { if (isMaker(pr.createdBy)) { throw new Error('Maker-Checker policy: Record creator cannot approve their own programme.'); } return updateProgramme({ programmeId: pr.id, name: pr.name || '', portfolioId: Number(pr.portfolioId), status: ApprovalStatus.Approved, ownerId: pr.ownedBy, }); }, onSuccess: () => { bumpMutationEpoch(); queryClient.invalidateQueries({ queryKey: ['programmes'] }); queryClient.invalidateQueries({ queryKey: ['programme'] }); success('Programme Approved', 'Programme governance status successfully updated to Approved.'); }, onError: (err: unknown) => { toastError('Programme Approval Failed', err); }, }); const programmeMutation = useMutation({ mutationFn: (data: ProgrammeFormValues) => { const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy); if (editingProgramme) { const targetStatus = (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus; if (targetStatus === ApprovalStatus.Approved && isMaker(editingProgramme.createdBy)) { throw new Error('Maker-Checker policy violation: Record creators cannot approve their own programmes.'); } return updateProgramme({ programmeId: editingProgramme.id, name: data.name, portfolioId: Number(data.portfolioId), status: targetStatus, ownerId: resolvedOwnerId, }); } return createProgramme({ name: data.name, portfolioId: Number(data.portfolioId), ownerId: resolvedOwnerId, }); }, onSuccess: () => { bumpMutationEpoch(); queryClient.invalidateQueries({ queryKey: ['programmes'] }); queryClient.invalidateQueries({ queryKey: ['programme'] }); success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully'); setIsProgrammeModalOpen(false); setEditingProgramme(null); programmeForm.reset(); }, onError: (err: unknown) => { toastError('Failed to save programme', err); }, }); const openCreateOrg = () => { setEditingOrg(null); const defaultUser = executiveAndManagerUsers[0]; orgForm.reset({ name: '', registrationNo: '', email: '', organisationUser: defaultUser?.id || '', type: OrganisationType.Enterprise, status: ActivityStatus.Active, }); setIsQuickAddUserOpen(false); setQuickUserError(null); setIsOrgModalOpen(true); }; const openEditOrg = (org: Organisation) => { setEditingOrg(org); const targetUserId = org.userId || org.UserId || org.organisationUser || org.OrganisationUser || org.createdBy || ''; if (targetUserId) { const email = getUserEmail(targetUserId); if (email && email !== '—') { registerUserEmail(targetUserId, email); } } const matchedUser = targetUserId ? usersDirectory.find( (u) => (u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase()) || (u.email && getUserEmail(targetUserId) && u.email.toLowerCase() === getUserEmail(targetUserId).toLowerCase()) ) : null; const selectedUserId = matchedUser?.id || targetUserId || ''; orgForm.reset({ name: org.name || '', registrationNo: org.registrationNo || '', email: org.email || '', organisationUser: selectedUserId, type: org.type || OrganisationType.Enterprise, status: org.status ?? ActivityStatus.Active, }); setIsQuickAddUserOpen(false); setQuickUserError(null); setIsOrgModalOpen(true); }; const openCreatePortfolio = () => { setEditingPortfolio(null); portfolioForm.reset({ name: '', description: '', organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101, ownerEmail: effectiveUserEmail, }); setIsPortfolioModalOpen(true); }; const openEditPortfolio = async (p: Portfolio) => { setEditingPortfolio(p); const email = await resolveOwnerEmailForDisplay(p.ownedBy); portfolioForm.reset({ name: p.name || '', description: p.description || '', organisationId: Number(p.organisationId) || 101, ownerEmail: email, }); setIsPortfolioModalOpen(true); }; const openCreateProgramme = () => { setEditingProgramme(null); programmeForm.reset({ name: '', portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201, status: ApprovalStatus.Pending, ownerEmail: effectiveUserEmail, }); setIsProgrammeModalOpen(true); }; const openEditProgramme = async (pr: Programme) => { setEditingProgramme(pr); const email = await resolveOwnerEmailForDisplay(pr.ownedBy); programmeForm.reset({ name: pr.name || '', portfolioId: Number(pr.portfolioId) || 201, status: pr.status ?? ApprovalStatus.Approved, ownerEmail: email, }); setIsProgrammeModalOpen(true); }; // Filtered lists const filteredOrgs = organisations.filter( (o) => o.name?.toLowerCase().includes(searchTerm.toLowerCase()) || o.registrationNo?.toLowerCase().includes(searchTerm.toLowerCase()) || o.email?.toLowerCase().includes(searchTerm.toLowerCase()) || String(o.id).includes(searchTerm) ); const filteredPortfolios = portfolios.filter((p) => { if (!searchTerm.trim()) return true; const q = searchTerm.toLowerCase(); const org = organisations.find((o) => String(o.id) === String(p.organisationId)); const orgName = org?.name?.toLowerCase() || ''; const name = (p.name || '').toLowerCase(); const desc = (p.description || '').toLowerCase(); const id = String(p.id); const owner = getUserEmail(p.ownedBy).toLowerCase(); return name.includes(q) || desc.includes(q) || id.includes(q) || orgName.includes(q) || owner.includes(q); }); const filteredProgrammes = programmes.filter((pr) => { if (!searchTerm.trim()) return true; const q = searchTerm.toLowerCase(); const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId)); const portName = port?.name?.toLowerCase() || ''; const name = (pr.name || '').toLowerCase(); const id = String(pr.id); const owner = getUserEmail(pr.ownedBy).toLowerCase(); return name.includes(q) || id.includes(q) || portName.includes(q) || owner.includes(q); }); // Table Sorting Hooks const { sortedData: sortedOrgs, sortColumn: orgSortCol, sortDirection: orgSortDir, handleSort: handleOrgSort, } = useTableSort(filteredOrgs, null, null, { name: (o) => o.name, registrationNo: (o) => o.registrationNo, type: (o) => getOrganisationTypeLabel(o.type), email: (o) => o.email, status: (o) => getOrganisationStatusBadge(o).label, }); const { sortedData: sortedProgrammes, sortColumn: progSortCol, sortDirection: progSortDir, handleSort: handleProgSort, } = useTableSort(filteredProgrammes, null, null, { name: (p) => p.name, portfolioId: (p) => { const port = portfolios.find((po) => String(po.id) === String(p.portfolioId)); return port ? port.name : `Portfolio #${p.portfolioId}`; }, status: (p) => getApprovalStatusBadge(p.status).label, createdAt: (p) => p.createdAt, ownedBy: (p) => getUserEmail(p.ownedBy), createdBy: (p) => getUserEmail(p.createdBy), }); // Export Columns Definitions const orgExportColumns: ExportColumn[] = [ { header: 'Organisation ID', key: 'id' }, { header: 'Legal Entity Name', key: 'name' }, { header: 'Registration / CIPC No', key: 'registrationNo', format: (val) => val || '—' }, { header: 'Entity Classification', key: 'type', format: (val) => getOrganisationTypeLabel(val) }, { header: 'Official Email', key: 'email', format: (val) => val || '—' }, { header: 'Status', key: 'status', format: (val) => getOrganisationStatusBadge({ status: val }).label }, { header: 'Registration Date', key: 'createdAt', format: (val) => formatDate(val) }, ]; const portfolioExportColumns: ExportColumn[] = [ { header: 'Portfolio ID', key: 'id' }, { header: 'Portfolio Name', key: 'name' }, { header: 'Organisation', key: 'organisationId', format: (val) => { const org = organisations.find((o) => String(o.id) === String(val)); return org ? org.name : `Org #${val}`; }, }, { header: 'Programmes Count', key: 'id', format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length, }, { header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) }, { header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) }, { header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) }, { header: 'Description', key: 'description', format: (val) => val || '—' }, ]; const programmeExportColumns: ExportColumn[] = [ { header: 'Programme ID', key: 'id' }, { header: 'Programme Name', key: 'name' }, { header: 'Parent Portfolio', key: 'portfolioId', format: (val) => { const port = portfolios.find((p) => String(p.id) === String(val)); return port && port.name ? port.name : `Portfolio #${val}`; }, }, { header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label }, { header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) }, { header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) }, { header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) }, ]; return (
{/* Header Banner */}

Governance & Structure

Directory

Maintain Master Organisation Registry, Funder Portfolios, Strategic Programmes, Funding Instruments, and Programme Rules.

{/* Tab Actions */}
{activeTab === 'organisations' && canAdd('governance') && ( )} {activeTab === 'portfolios' && canAdd('governance') && ( )} {activeTab === 'programmes' && canAdd('governance') && ( )} {(activeTab === 'instruments' || activeTab === 'instructions') && canAdd('governance') && ( )} {activeTab === 'rules' && canAdd('governance') && ( )}
{/* Tabs */}
{/* Search Bar & Actions for Organisations */} {activeTab === 'organisations' && (
setSearchTerm(e.target.value)} className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs" />
)} {/* Tab 1: Organisations Table */} {activeTab === 'organisations' && (
Organisation Name Registration No Entity Type Contact Email Status {loadingOrgs ? ( ) : sortedOrgs.length === 0 ? ( ) : ( sortedOrgs.map((org) => { const statusBadge = getOrganisationStatusBadge(org); return ( ); }) )}
Actions
Loading registered organisations...
No organisations found matching query.
{org.name} ID: {org.id} {org.registrationNo || '—'} {getOrganisationTypeLabel(org.type)} {org.email} {statusBadge.label}
)} {/* Search Bar & Actions for Portfolios */} {activeTab === 'portfolios' && (
setSearchTerm(e.target.value)} className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs" />
)} {/* Tab 2: Portfolios List */} {activeTab === 'portfolios' && (
{loadingPortfolios ? (

Loading portfolios...

) : filteredPortfolios.length === 0 ? (

No portfolios found matching query

Try refining your search keywords or create a new portfolio.

) : ( filteredPortfolios.map((p) => { const org = organisations.find((o) => String(o.id) === String(p.organisationId)); const progCount = programmes.filter((pr) => String(pr.portfolioId) === String(p.id)).length; const isHighlighted = String(highlightedPortfolioId) === String(p.id); return (
Portfolio #{p.id} {progCount} Programmes

{p.name}

{p.description}

{org && org.name ? org.name : `Org #${p.organisationId}`} {formatDate(p.createdAt)}
Owner: {getUserEmail(p.ownedBy)}
); }) )}
)} {/* Search Bar & Actions for Programmes */} {activeTab === 'programmes' && (
setSearchTerm(e.target.value)} className="w-full pl-9 pr-4 py-2.5 text-xs bg-[#090d16] border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-slate-200 placeholder:text-slate-500 transition shadow-xs" />
)} {/* Tab 3: Programmes Hierarchy */} {activeTab === 'programmes' && (
Programme Name Parent Portfolio Governance Status Creation Date Owner Email Created By {loadingProgrammes ? ( ) : sortedProgrammes.length === 0 ? ( ) : ( sortedProgrammes.map((pr) => { const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId)); const statusBadge = getApprovalStatusBadge(pr.status); const isApproved = Number(pr.status) === ApprovalStatus.Approved; return ( ); }) )}
Actions
Loading programmes...
No programmes found matching query.
{pr.name} ID: {pr.id} {port ? port.name : `Portfolio #${pr.portfolioId}`} {statusBadge.label} {formatDate(pr.createdAt)} {getUserEmail(pr.ownedBy)} {getUserEmail(pr.createdBy)}
{isApproved ? ( Approved ) : ( <> {canApproveRecord('governance', pr.createdBy) && ( )} {canApprove('governance') && isMaker(pr.createdBy) && ( Maker-Checker: Record creator cannot approve )} )}
)} {/* Tab 4: Funding Instruments */} {(activeTab === 'instruments' || activeTab === 'instructions') && ( setPreselectedProgForInstruction(null)} isCreateModalOpenExternal={isInstructionCreateTriggered} onCloseCreateModalExternal={() => setIsInstructionCreateTriggered(false)} /> )} {/* Tab 5: Programme Rules */} {activeTab === 'rules' && ( setPreselectedProgForRule(null)} isCreateModalOpenExternal={isRuleCreateTriggered} onCloseCreateModalExternal={() => setIsRuleCreateTriggered(false)} /> )} {/* Modal: Register / Edit Organisation */} setIsOrgModalOpen(false)} title={editingOrg ? 'Edit Organisation Profile' : 'Register New Organisation'} subtitle="Mandatory master data profile for Recipient or Funder entities (POPIA compliant)" helpText="Maintains formal company or institutional credentials for funding allocation, audited legal standing, and regulatory reporting." >
orgMutation.mutate(data))} className="space-y-4">
{orgForm.formState.errors.name && (

{orgForm.formState.errors.name.message}

)}
{orgForm.formState.errors.registrationNo && (

{orgForm.formState.errors.registrationNo.message}

)}
{orgForm.formState.errors.email && (

{orgForm.formState.errors.email.message}

)}
{!editingOrg && ( )}
{orgForm.formState.errors.organisationUser && (

{orgForm.formState.errors.organisationUser.message}

)} {executiveAndManagerUsers.length === 0 && !isQuickAddUserOpen && (

No Executive or Manager user found. Create one below to continue.

)}
{/* Inline Collapsible Subform to Create Executive User */} {isQuickAddUserOpen && !editingOrg && (
Quick Create Executive / Manager User Mapped to tenant automatically
{quickUserError && (
{quickUserError}
)}
setQuickUserEmail(e.target.value)} placeholder="executive@organisation.org.za" className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500" />
setQuickUserName(e.target.value)} placeholder="e.g. Tony Ngcobo" className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500" />

Will be normalized to valid login username (e.g. tony.ngcobo)

setQuickUserPhone(e.target.value)} placeholder="+27 82 123 4567" className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500" />
setQuickUserIdNumber(e.target.value)} placeholder="13-digit ID or Passport" className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white font-mono focus:outline-none focus:ring-1 focus:ring-blue-500" />
)} {editingOrg && (
)}
{/* Modal: Create / Edit Portfolio */} { setIsPortfolioModalOpen(false); setEditingPortfolio(null); }} title={editingPortfolio ? 'Edit Funder Portfolio' : 'Create Funder Portfolio'} subtitle="Strategic allocation bucket grouping multiple programmatic initiatives" helpText="Portfolios consolidate thematic grant priorities under an authoritative parent entity (e.g. Science & Innovation Department) for high-level fiscal tracking." >
portfolioMutation.mutate(data))} className="space-y-4">
{portfolioForm.formState.errors.name && (

{portfolioForm.formState.errors.name.message}

)}
{usersDirectory.filter((u) => u.email).map((u) => ( ))} {portfolioForm.formState.errors.ownerEmail && (

{portfolioForm.formState.errors.ownerEmail.message}

)}