import React, { useState } from 'react'; 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, Layers, ChevronRight, Search, } from 'lucide-react'; import { getOrganisations, createOrganisation, updateOrganisation, getAllPortfolios, createPortfolio, updatePortfolio, getAllProgrammes, createProgramme, updateProgramme, } from '../services/api'; import { Modal } from '../components/ui/Modal'; import { useToast } from '../components/ui/Toast'; import { useAuth } from '../auth/AuthProvider'; import { Organisation, Portfolio, Programme, OrganisationType, ActivityStatus, ApprovalStatus, CreateOrganisationRequest, CreatePortfolioRequest, CreateProgrammeRequest, } from '../types'; import { getOrganisationTypeLabel, getActivityStatusBadge, getApprovalStatusBadge, formatDate, getUserEmail, 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'), 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'), ownerId: z.string().min(1, 'Owner ID 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(), ownerId: z.string().min(1, 'Owner ID 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 } = useAuth(); const currentUserId = sub || user?.id || ''; const { success, error: toastError } = useToast(); const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes'>('organisations'); const [searchTerm, setSearchTerm] = useState(''); // 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); // Queries const { data: organisations = [], isLoading: loadingOrgs } = useQuery({ queryKey: ['organisations'], queryFn: () => getOrganisations(), }); const { data: portfolios = [], isLoading: loadingPortfolios } = useQuery({ queryKey: ['portfolios'], queryFn: () => getAllPortfolios(), }); const { data: programmes = [], isLoading: loadingProgrammes } = useQuery({ queryKey: ['programmes'], queryFn: () => getAllProgrammes(), }); // Forms const orgForm = useForm({ resolver: zodResolver(organisationSchema) as any, defaultValues: { name: '', registrationNo: '', email: '', type: OrganisationType.Enterprise, status: ActivityStatus.Active, }, }); const portfolioForm = useForm({ resolver: zodResolver(portfolioSchema) as any, defaultValues: { name: '', description: '', organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101, ownerId: currentUserId, }, }); const programmeForm = useForm({ resolver: zodResolver(programmeSchema) as any, defaultValues: { name: '', portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201, status: ApprovalStatus.Approved, ownerId: currentUserId, }, }); // Mutations const orgMutation = useMutation({ mutationFn: (data: OrganisationFormValues) => { if (editingOrg) { return updateOrganisation({ organisationId: editingOrg.id, name: data.name, registrationNo: data.registrationNo, email: data.email, type: Number(data.type), status: data.status !== undefined ? Number(data.status) : undefined, }); } return createOrganisation(data as CreateOrganisationRequest); }, onSuccess: () => { 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', String(err)); }, }); const portfolioMutation = useMutation({ mutationFn: (data: PortfolioFormValues) => { if (editingPortfolio) { return updatePortfolio({ portfolioId: editingPortfolio.id, name: data.name, description: data.description, organisationId: Number(data.organisationId), ownerId: data.ownerId, }); } return createPortfolio(data as CreatePortfolioRequest); }, onSuccess: () => { 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', String(err)); }, }); const programmeMutation = useMutation({ mutationFn: (data: ProgrammeFormValues) => { if (editingProgramme) { return updateProgramme({ programmeId: editingProgramme.id, name: data.name, portfolioId: Number(data.portfolioId), status: data.status !== undefined ? Number(data.status) : undefined, ownerId: data.ownerId, }); } return createProgramme(data as CreateProgrammeRequest); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['programmes'] }); success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully'); setIsProgrammeModalOpen(false); setEditingProgramme(null); programmeForm.reset(); }, onError: (err: unknown) => { toastError('Failed to save programme', String(err)); }, }); const openCreateOrg = () => { setEditingOrg(null); orgForm.reset({ name: '', registrationNo: '', email: '', type: OrganisationType.Enterprise, status: ActivityStatus.Active, }); setIsOrgModalOpen(true); }; const openEditOrg = (org: Organisation) => { setEditingOrg(org); orgForm.reset({ name: org.name || '', registrationNo: org.registrationNo || '', email: org.email || '', type: org.type || OrganisationType.Enterprise, status: org.status ?? ActivityStatus.Active, }); setIsOrgModalOpen(true); }; const openCreatePortfolio = () => { setEditingPortfolio(null); portfolioForm.reset({ name: '', description: '', organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101, ownerId: currentUserId, }); setIsPortfolioModalOpen(true); }; const openEditPortfolio = (p: Portfolio) => { setEditingPortfolio(p); portfolioForm.reset({ name: p.name || '', description: p.description || '', organisationId: Number(p.organisationId) || 101, ownerId: p.ownedBy || currentUserId, }); setIsPortfolioModalOpen(true); }; const openCreateProgramme = () => { setEditingProgramme(null); programmeForm.reset({ name: '', portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201, status: ApprovalStatus.Approved, ownerId: currentUserId, }); setIsProgrammeModalOpen(true); }; const openEditProgramme = (pr: Programme) => { setEditingProgramme(pr); programmeForm.reset({ name: pr.name || '', portfolioId: Number(pr.portfolioId) || 201, status: pr.status ?? ApprovalStatus.Approved, ownerId: pr.ownedBy || currentUserId, }); 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()) ); return (
{/* Header */}

Governance & Structure

Directory

Maintain Master Organisation Registry, Funder Portfolios, and Strategic Programmes hierarchy.

{/* Tab Switcher & CTA */}
{activeTab === 'organisations' && ( )} {activeTab === 'portfolios' && ( )} {activeTab === 'programmes' && ( )}
{/* Tabs */}
{/* Search Bar */} {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' && (
{loadingOrgs ? ( ) : filteredOrgs.length === 0 ? ( ) : ( filteredOrgs.map((org) => { const statusBadge = getActivityStatusBadge(org.status); return ( ); }) )}
Organisation Name Registration No Entity Type Contact Email Status Actions
Loading registered organisations...
No organisations found matching query.
{org.name} ID: {org.id} {org.registrationNo || '—'} {getOrganisationTypeLabel(org.type)} {org.email} {statusBadge.label}
)} {/* Tab 2: Portfolios List */} {activeTab === 'portfolios' && (
{loadingPortfolios ? (

Loading portfolios...

) : ( portfolios.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; return (
Portfolio #{p.id} {progCount} Programmes

{p.name}

{p.description}

{org ? org.name : `Org #${p.organisationId}`} {formatDate(p.createdAt)}
Owner: {getUserEmail(p.ownedBy)}
); }) )}
)} {/* Tab 3: Programmes Hierarchy */} {activeTab === 'programmes' && (
{loadingProgrammes ? ( ) : ( programmes.map((pr) => { const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId)); const statusBadge = getApprovalStatusBadge(pr.status); return ( ); }) )}
Programme Name Parent Portfolio Governance Status Creation Date Owner Email Actions
Loading programmes...
{pr.name} ID: {pr.id} {port ? port.name : `Portfolio #${pr.portfolioId}`} {statusBadge.label} {formatDate(pr.createdAt)} {getUserEmail(pr.ownedBy)}
)} {/* 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)" >
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 && (
)}
{/* Modal: Create / Edit Portfolio */} { setIsPortfolioModalOpen(false); setEditingPortfolio(null); }} title={editingPortfolio ? 'Edit Funder Portfolio' : 'Create Funder Portfolio'} subtitle="Strategic allocation bucket grouping multiple programmatic initiatives" >
portfolioMutation.mutate(data))} className="space-y-4">
{portfolioForm.formState.errors.name && (

{portfolioForm.formState.errors.name.message}

)}