1455 lines
66 KiB
TypeScript
1455 lines
66 KiB
TypeScript
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,
|
|
Coins,
|
|
Eye,
|
|
RefreshCw,
|
|
} from 'lucide-react';
|
|
import {
|
|
getOrganisations,
|
|
getOrganisation,
|
|
createOrganisation,
|
|
updateOrganisation,
|
|
getAllPortfolios,
|
|
getPortfolios,
|
|
getPortfolio,
|
|
createPortfolio,
|
|
updatePortfolio,
|
|
getAllProgrammes,
|
|
getProgrammes,
|
|
getProgramme,
|
|
createProgramme,
|
|
updateProgramme,
|
|
getInstruments,
|
|
getPortfolioProgrammes,
|
|
} from '../services/api';
|
|
import { Modal } from '../components/ui/Modal';
|
|
import { HelpTooltip } from '../components/ui/HelpTooltip';
|
|
import { useToast } from '../components/ui/Toast';
|
|
import { useAuth } from '../auth/AuthProvider';
|
|
import {
|
|
Organisation,
|
|
Portfolio,
|
|
Programme,
|
|
Instrument,
|
|
OrganisationType,
|
|
ActivityStatus,
|
|
ApprovalStatus,
|
|
CreateOrganisationRequest,
|
|
CreatePortfolioRequest,
|
|
CreateProgrammeRequest,
|
|
} from '../types';
|
|
import {
|
|
getOrganisationTypeLabel,
|
|
getActivityStatusBadge,
|
|
getOrganisationStatusBadge,
|
|
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<typeof organisationSchema>;
|
|
type PortfolioFormValues = z.infer<typeof portfolioSchema>;
|
|
type ProgrammeFormValues = z.infer<typeof programmeSchema>;
|
|
|
|
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<Organisation | null>(null);
|
|
|
|
const [isPortfolioModalOpen, setIsPortfolioModalOpen] = useState(false);
|
|
const [editingPortfolio, setEditingPortfolio] = useState<Portfolio | null>(null);
|
|
|
|
const [isProgrammeModalOpen, setIsProgrammeModalOpen] = useState(false);
|
|
const [editingProgramme, setEditingProgramme] = useState<Programme | null>(null);
|
|
|
|
// Inspection states
|
|
const [selectedProgrammeForInstruments, setSelectedProgrammeForInstruments] = useState<Programme | null>(null);
|
|
const [isInstrumentsModalOpen, setIsInstrumentsModalOpen] = useState(false);
|
|
|
|
const [selectedPortfolioForProgrammes, setSelectedPortfolioForProgrammes] = useState<Portfolio | null>(null);
|
|
const [isPortfolioProgrammesModalOpen, setIsPortfolioProgrammesModalOpen] = useState(false);
|
|
|
|
const [selectedOrgForDetail, setSelectedOrgForDetail] = useState<Organisation | null>(null);
|
|
const [isOrgDetailModalOpen, setIsOrgDetailModalOpen] = useState(false);
|
|
|
|
const [selectedPortfolioForDetail, setSelectedPortfolioForDetail] = useState<Portfolio | null>(null);
|
|
const [isPortfolioDetailModalOpen, setIsPortfolioDetailModalOpen] = useState(false);
|
|
|
|
const [selectedProgrammeForDetail, setSelectedProgrammeForDetail] = useState<Programme | null>(null);
|
|
const [isProgrammeDetailModalOpen, setIsProgrammeDetailModalOpen] = useState(false);
|
|
|
|
// 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(),
|
|
});
|
|
|
|
const { data: programmeInstruments = [], isLoading: loadingInstruments } = useQuery({
|
|
queryKey: ['instruments', selectedProgrammeForInstruments?.id],
|
|
queryFn: () => (selectedProgrammeForInstruments ? getInstruments(selectedProgrammeForInstruments.id) : Promise.resolve([])),
|
|
enabled: !!selectedProgrammeForInstruments && isInstrumentsModalOpen,
|
|
});
|
|
|
|
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 ? getPortfolios(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: programmeFreshDetail, isLoading: loadingProgrammeFreshDetail } = useQuery({
|
|
queryKey: ['programme', selectedProgrammeForDetail?.id],
|
|
queryFn: () => (selectedProgrammeForDetail ? getProgramme(selectedProgrammeForDetail.id) : null),
|
|
enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen,
|
|
});
|
|
|
|
// Forms
|
|
const orgForm = useForm<OrganisationFormValues>({
|
|
resolver: zodResolver(organisationSchema) as any,
|
|
defaultValues: {
|
|
name: '',
|
|
registrationNo: '',
|
|
email: '',
|
|
type: OrganisationType.Enterprise,
|
|
status: ActivityStatus.Active,
|
|
},
|
|
});
|
|
|
|
const portfolioForm = useForm<PortfolioFormValues>({
|
|
resolver: zodResolver(portfolioSchema) as any,
|
|
defaultValues: {
|
|
name: '',
|
|
description: '',
|
|
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
|
|
ownerId: currentUserId,
|
|
},
|
|
});
|
|
|
|
const programmeForm = useForm<ProgrammeFormValues>({
|
|
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', 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', 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', 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 (
|
|
<div className="space-y-6 max-w-7xl mx-auto">
|
|
{/* Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0d121f]/90 p-5 rounded-2xl border border-slate-800/80 backdrop-blur-md shadow-xl">
|
|
<div>
|
|
<div className="flex items-center gap-2">
|
|
<h2 className="text-xl font-bold text-white tracking-tight">
|
|
Governance & Structure
|
|
</h2>
|
|
<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="Statutory Governance Architecture"
|
|
content="Maintain authoritative organizational profiles, funding portfolios, and strategic programmes. Ensures compliance with national grant mandates and statutory reporting standards."
|
|
placement="right"
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-slate-400 mt-1">
|
|
Maintain Master Organisation Registry, Funder Portfolios, and Strategic Programmes hierarchy.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Tab Switcher & CTA */}
|
|
<div className="flex items-center gap-2">
|
|
{activeTab === 'organisations' && (
|
|
<button
|
|
onClick={openCreateOrg}
|
|
className="inline-flex items-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" />
|
|
Register Organisation
|
|
</button>
|
|
)}
|
|
{activeTab === 'portfolios' && (
|
|
<button
|
|
onClick={openCreatePortfolio}
|
|
className="inline-flex items-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" />
|
|
Create Portfolio
|
|
</button>
|
|
)}
|
|
{activeTab === 'programmes' && (
|
|
<button
|
|
onClick={openCreateProgramme}
|
|
className="inline-flex items-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" />
|
|
Create Programme
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex border-b border-slate-800/80 bg-[#0d121f]/80 px-4 rounded-2xl backdrop-blur-sm">
|
|
<button
|
|
onClick={() => setActiveTab('organisations')}
|
|
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${
|
|
activeTab === 'organisations'
|
|
? 'border-blue-500 text-blue-400'
|
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
|
}`}
|
|
>
|
|
<Building2 className="w-4 h-4" />
|
|
<span>Organisations Directory ({organisations.length})</span>
|
|
<HelpTooltip
|
|
title="Organisations Master"
|
|
content="Official register of participating recipient and funder entities, verified against legal and regulatory standards."
|
|
size="sm"
|
|
placement="top"
|
|
/>
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('portfolios')}
|
|
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${
|
|
activeTab === 'portfolios'
|
|
? 'border-blue-500 text-blue-400'
|
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
|
}`}
|
|
>
|
|
<FolderKanban className="w-4 h-4" />
|
|
<span>Funder Portfolios ({portfolios.length})</span>
|
|
<HelpTooltip
|
|
title="Funder Portfolios"
|
|
content="High-level funding groupings designated for specific strategic objectives, themes, or departmental allocations."
|
|
size="sm"
|
|
placement="top"
|
|
/>
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('programmes')}
|
|
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${
|
|
activeTab === 'programmes'
|
|
? 'border-blue-500 text-blue-400'
|
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
|
}`}
|
|
>
|
|
<Layers className="w-4 h-4" />
|
|
<span>Programmes Hierarchy ({programmes.length})</span>
|
|
<HelpTooltip
|
|
title="Strategic Programmes"
|
|
content="Operational funding vehicles linked to portfolios that govern grant instruments, projects, and target metrics."
|
|
size="sm"
|
|
placement="top"
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search Bar & Actions */}
|
|
{activeTab === 'organisations' && (
|
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3">
|
|
<div className="relative max-w-md flex-1">
|
|
<Search className="w-4 h-4 text-slate-500 absolute left-3 top-3" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search by organisation name, reg no, or email..."
|
|
value={searchTerm}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
queryClient.invalidateQueries({ queryKey: ['organisations'] });
|
|
success('Refreshed', 'Organisation directory refreshed from database.');
|
|
}}
|
|
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"
|
|
title="Force refresh organisation directory from database"
|
|
>
|
|
<RefreshCw className="w-3.5 h-3.5 text-blue-400" />
|
|
Refresh Directory
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tab 1: Organisations Table */}
|
|
{activeTab === 'organisations' && (
|
|
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-xs border-collapse">
|
|
<thead>
|
|
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
|
|
<th className="py-3.5 px-5">Organisation Name</th>
|
|
<th className="py-3.5 px-4">Registration No</th>
|
|
<th className="py-3.5 px-4">Entity Type</th>
|
|
<th className="py-3.5 px-4">Contact Email</th>
|
|
<th className="py-3.5 px-4">Status</th>
|
|
<th className="py-3.5 px-5 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-800/60">
|
|
{loadingOrgs ? (
|
|
<tr>
|
|
<td colSpan={6} className="py-8 text-center text-slate-500">
|
|
Loading registered organisations...
|
|
</td>
|
|
</tr>
|
|
) : filteredOrgs.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="py-8 text-center text-slate-500">
|
|
No organisations found matching query.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredOrgs.map((org) => {
|
|
const statusBadge = getOrganisationStatusBadge(org);
|
|
return (
|
|
<tr key={org.id} className="hover:bg-slate-800/40 transition-colors">
|
|
<td className="py-3.5 px-5">
|
|
<span className="font-semibold text-white block text-xs">{org.name}</span>
|
|
<span className="text-[10px] text-slate-500 font-mono">ID: {org.id}</span>
|
|
</td>
|
|
<td className="py-3.5 px-4 font-mono text-slate-300">{org.registrationNo || '—'}</td>
|
|
<td className="py-3.5 px-4">
|
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-[11px] font-medium bg-slate-900/80 text-slate-300 border border-slate-700/80">
|
|
{getOrganisationTypeLabel(org.type)}
|
|
</span>
|
|
</td>
|
|
<td className="py-3.5 px-4 text-slate-300">
|
|
<span className="flex items-center gap-1.5 font-mono text-[11px]">
|
|
<Mail className="w-3.5 h-3.5 text-slate-500" />
|
|
{org.email}
|
|
</span>
|
|
</td>
|
|
<td className="py-3.5 px-4">
|
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
|
{statusBadge.label}
|
|
</span>
|
|
</td>
|
|
<td className="py-3.5 px-5 text-right">
|
|
<div className="flex items-center justify-end gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedOrgForDetail(org);
|
|
setIsOrgDetailModalOpen(true);
|
|
}}
|
|
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
|
|
title="Inspect Organisation (GET /v1/api/governance/organisations/{id})"
|
|
>
|
|
<Eye className="w-3 h-3" />
|
|
Inspect
|
|
</button>
|
|
<button
|
|
onClick={() => openEditOrg(org)}
|
|
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-3 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
|
|
>
|
|
<Edit2 className="w-3 h-3" />
|
|
Edit
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tab 2: Portfolios List */}
|
|
{activeTab === 'portfolios' && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
|
{loadingPortfolios ? (
|
|
<p className="text-xs text-slate-500 col-span-3">Loading portfolios...</p>
|
|
) : (
|
|
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 (
|
|
<div key={p.id} className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm flex flex-col justify-between hover:border-slate-700/80 transition-all">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] font-mono uppercase tracking-wider text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-md border border-blue-500/30">
|
|
Portfolio #{p.id}
|
|
</span>
|
|
<span className="text-[11px] text-slate-400 font-medium">{progCount} Programmes</span>
|
|
</div>
|
|
<h3 className="text-base font-bold text-white leading-snug">{p.name}</h3>
|
|
<p className="text-xs text-slate-400 leading-relaxed">{p.description}</p>
|
|
</div>
|
|
|
|
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2">
|
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
|
<span className="truncate max-w-[170px] font-medium text-slate-300">{org ? org.name : `Org #${p.organisationId}`}</span>
|
|
<span className="font-mono text-[11px] text-slate-500">{formatDate(p.createdAt)}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between pt-1 text-xs">
|
|
<span className="text-slate-400 font-medium truncate max-w-[190px]">
|
|
Owner: <span className="text-slate-200 font-mono text-[11px]">{getUserEmail(p.ownedBy)}</span>
|
|
</span>
|
|
<div className="flex items-center gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedPortfolioForDetail(p);
|
|
setIsPortfolioDetailModalOpen(true);
|
|
}}
|
|
className="inline-flex items-center gap-1 text-xs font-semibold text-cyan-400 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
|
title="Inspect Portfolio (GET /v1/api/governance/portfolios/{id})"
|
|
>
|
|
<Eye className="w-3 h-3" />
|
|
Inspect
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedPortfolioForProgrammes(p);
|
|
setIsPortfolioProgrammesModalOpen(true);
|
|
}}
|
|
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
|
>
|
|
<Layers className="w-3 h-3" />
|
|
Programmes
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => openEditPortfolio(p)}
|
|
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
|
>
|
|
<Edit2 className="w-3 h-3" />
|
|
Edit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Tab 3: Programmes Hierarchy */}
|
|
{activeTab === 'programmes' && (
|
|
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm overflow-hidden">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left text-xs border-collapse">
|
|
<thead>
|
|
<tr className="bg-[#0d121f]/95 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold text-[11px]">
|
|
<th className="py-3.5 px-5">Programme Name</th>
|
|
<th className="py-3.5 px-4">Parent Portfolio</th>
|
|
<th className="py-3.5 px-4">Governance Status</th>
|
|
<th className="py-3.5 px-4">Creation Date</th>
|
|
<th className="py-3.5 px-4">Owner Email</th>
|
|
<th className="py-3.5 px-5 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-800/60">
|
|
{loadingProgrammes ? (
|
|
<tr>
|
|
<td colSpan={6} className="py-8 text-center text-slate-500">
|
|
Loading programmes...
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
programmes.map((pr) => {
|
|
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
|
|
const statusBadge = getApprovalStatusBadge(pr.status);
|
|
return (
|
|
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
|
|
<td className="py-3.5 px-5">
|
|
<span className="font-semibold text-white block text-xs">{pr.name}</span>
|
|
<span className="text-[10px] text-slate-500 font-mono">ID: {pr.id}</span>
|
|
</td>
|
|
<td className="py-3.5 px-4 text-slate-300 font-medium">
|
|
{port ? port.name : `Portfolio #${pr.portfolioId}`}
|
|
</td>
|
|
<td className="py-3.5 px-4">
|
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
|
{statusBadge.label}
|
|
</span>
|
|
</td>
|
|
<td className="py-3.5 px-4 text-slate-400 font-mono text-[11px]">{formatDate(pr.createdAt)}</td>
|
|
<td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]">
|
|
{getUserEmail(pr.ownedBy)}
|
|
</td>
|
|
<td className="py-3.5 px-5 text-right">
|
|
<div className="flex items-center justify-end gap-1.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedProgrammeForDetail(pr);
|
|
setIsProgrammeDetailModalOpen(true);
|
|
}}
|
|
className="inline-flex items-center gap-1 text-xs font-semibold text-cyan-400 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
|
title="Inspect Programme (GET /v1/api/projects/programme/{id})"
|
|
>
|
|
<Eye className="w-3 h-3" />
|
|
Inspect
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedProgrammeForInstruments(pr);
|
|
setIsInstrumentsModalOpen(true);
|
|
}}
|
|
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
|
title="View Funding Instruments"
|
|
>
|
|
<Coins className="w-3 h-3" />
|
|
Instruments
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => openEditProgramme(pr)}
|
|
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
|
>
|
|
<Edit2 className="w-3 h-3" />
|
|
Edit
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal: Register / Edit Organisation */}
|
|
<Modal
|
|
isOpen={isOrgModalOpen}
|
|
onClose={() => 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."
|
|
>
|
|
<form onSubmit={orgForm.handleSubmit((data) => orgMutation.mutate(data))} className="space-y-4">
|
|
<div>
|
|
<div className="flex items-center gap-1 mb-1">
|
|
<label className="block text-xs font-semibold text-slate-300">
|
|
Legal Organisation Name *
|
|
</label>
|
|
<HelpTooltip
|
|
title="Registered Legal Entity"
|
|
content="Registered statutory name matching company incorporation or university charter."
|
|
size="sm"
|
|
placement="right"
|
|
/>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
{...orgForm.register('name')}
|
|
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 text-white bg-[#090d16]"
|
|
placeholder="e.g. AgriBio Southern Solutions"
|
|
/>
|
|
{orgForm.formState.errors.name && (
|
|
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.name.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid 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">
|
|
Registration / CIPC Number *
|
|
</label>
|
|
<HelpTooltip
|
|
title="Official Registration Number"
|
|
content="Companies and Intellectual Property Commission (CIPC) registration number or institutional tax ID."
|
|
size="sm"
|
|
placement="top"
|
|
/>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
{...orgForm.register('registrationNo')}
|
|
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="2020/554129/08"
|
|
/>
|
|
{orgForm.formState.errors.registrationNo && (
|
|
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.registrationNo.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center gap-1 mb-1">
|
|
<label className="block text-xs font-semibold text-slate-300">
|
|
Entity Classification *
|
|
</label>
|
|
<HelpTooltip
|
|
title="Institutional Type"
|
|
content="Classification determining applicable compliance rules, indirect cost rates, and audit tiers."
|
|
size="sm"
|
|
placement="top"
|
|
/>
|
|
</div>
|
|
<select
|
|
{...orgForm.register('type')}
|
|
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"
|
|
>
|
|
{ORGANISATION_TYPE_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</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">
|
|
Official Contact Email *
|
|
</label>
|
|
<HelpTooltip
|
|
title="Primary Notice Email"
|
|
content="Designated address for formal legal notices, grant awards, and compliance audit notifications."
|
|
size="sm"
|
|
placement="top"
|
|
/>
|
|
</div>
|
|
<input
|
|
type="email"
|
|
{...orgForm.register('email')}
|
|
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 text-white bg-[#090d16]"
|
|
placeholder="governance@organisation.org.za"
|
|
/>
|
|
{orgForm.formState.errors.email && (
|
|
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.email.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
{editingOrg && (
|
|
<div>
|
|
<div className="flex items-center gap-1 mb-1">
|
|
<label className="block text-xs font-semibold text-slate-300">
|
|
Operating Status *
|
|
</label>
|
|
<HelpTooltip
|
|
title="Lifecycle State"
|
|
content="Active organizations are eligible for new contracts and disbursement releases."
|
|
size="sm"
|
|
placement="top"
|
|
/>
|
|
</div>
|
|
<select
|
|
{...orgForm.register('status')}
|
|
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"
|
|
>
|
|
{ACTIVITY_STATUS_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsOrgModalOpen(false)}
|
|
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={orgMutation.isPending}
|
|
className="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 cursor-pointer"
|
|
>
|
|
{orgMutation.isPending ? 'Saving...' : editingOrg ? 'Update Organisation' : 'Register Organisation'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
{/* Modal: Create / Edit Portfolio */}
|
|
<Modal
|
|
isOpen={isPortfolioModalOpen}
|
|
onClose={() => {
|
|
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."
|
|
>
|
|
<form onSubmit={portfolioForm.handleSubmit((data) => portfolioMutation.mutate(data))} className="space-y-4">
|
|
<div>
|
|
<div className="flex items-center gap-1 mb-1">
|
|
<label className="block text-xs font-semibold text-slate-300">Portfolio Title *</label>
|
|
<HelpTooltip
|
|
title="Portfolio Nomenclature"
|
|
content="Clear strategic theme or national fund title (e.g. Energy Transition Fund, Biotechnology Thrust)."
|
|
size="sm"
|
|
placement="right"
|
|
/>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
{...portfolioForm.register('name')}
|
|
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 text-white bg-[#090d16]"
|
|
placeholder="e.g. National Green Hydrogen & Climate Fund"
|
|
/>
|
|
{portfolioForm.formState.errors.name && (
|
|
<p className="text-[10px] text-rose-500 mt-1">{portfolioForm.formState.errors.name.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center gap-1 mb-1">
|
|
<label className="block text-xs font-semibold text-slate-300">Parent Organisation *</label>
|
|
<HelpTooltip
|
|
title="Funding Custodian"
|
|
content="The governing department, ministry, or endowment holding statutory financial authority."
|
|
size="sm"
|
|
placement="right"
|
|
/>
|
|
</div>
|
|
<select
|
|
{...portfolioForm.register('organisationId')}
|
|
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"
|
|
>
|
|
{organisations.map((o) => (
|
|
<option key={o.id} value={o.id}>
|
|
{o.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center gap-1 mb-1">
|
|
<label className="block text-xs font-semibold text-slate-300">Owner Email *</label>
|
|
<HelpTooltip
|
|
title="Portfolio Director"
|
|
content="Designated chief director or portfolio lead responsible for executive decisions and allocations."
|
|
size="sm"
|
|
placement="right"
|
|
/>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
{...portfolioForm.register('ownerId')}
|
|
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="khwezim@gmail.com"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center gap-1 mb-1">
|
|
<label className="block text-xs font-semibold text-slate-300">Strategic Objective *</label>
|
|
<HelpTooltip
|
|
title="Statutory Mandate"
|
|
content="High-level policy goal, target impact, or socioeconomic transformation objectives."
|
|
size="sm"
|
|
placement="right"
|
|
/>
|
|
</div>
|
|
<textarea
|
|
rows={3}
|
|
{...portfolioForm.register('description')}
|
|
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 text-white bg-[#090d16]"
|
|
placeholder="Define the overarching funding mandate and target impacts..."
|
|
/>
|
|
{portfolioForm.formState.errors.description && (
|
|
<p className="text-[10px] text-rose-500 mt-1">{portfolioForm.formState.errors.description.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsPortfolioModalOpen(false);
|
|
setEditingPortfolio(null);
|
|
}}
|
|
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={portfolioMutation.isPending}
|
|
className="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 cursor-pointer"
|
|
>
|
|
{portfolioMutation.isPending ? 'Saving...' : editingPortfolio ? 'Update Portfolio' : 'Create Portfolio'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
{/* Modal: Create / Edit Programme */}
|
|
<Modal
|
|
isOpen={isProgrammeModalOpen}
|
|
onClose={() => {
|
|
setIsProgrammeModalOpen(false);
|
|
setEditingProgramme(null);
|
|
}}
|
|
title={editingProgramme ? 'Edit Strategic Programme' : 'Create Strategic Programme'}
|
|
subtitle="Programmatic operational entity funding distinct projects and awards"
|
|
helpText="Programmes operationalize portfolios into concrete funding instruments. Projects and grant awards are linked directly to programmes."
|
|
>
|
|
<form onSubmit={programmeForm.handleSubmit((data) => programmeMutation.mutate(data))} className="space-y-4">
|
|
<div>
|
|
<div className="flex items-center gap-1 mb-1">
|
|
<label className="block text-xs font-semibold text-slate-300">Programme Title *</label>
|
|
<HelpTooltip
|
|
title="Programme Identifier"
|
|
content="Official operational title under which funding calls and awards are issued."
|
|
size="sm"
|
|
placement="right"
|
|
/>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
{...programmeForm.register('name')}
|
|
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 text-white bg-[#090d16]"
|
|
placeholder="e.g. DeepTech Venture Commercialisation"
|
|
/>
|
|
{programmeForm.formState.errors.name && (
|
|
<p className="text-[10px] text-rose-500 mt-1">{programmeForm.formState.errors.name.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center gap-1 mb-1">
|
|
<label className="block text-xs font-semibold text-slate-300">Assign to Portfolio *</label>
|
|
<HelpTooltip
|
|
title="Parent Portfolio"
|
|
content="The portfolio funding umbrella to which this programme reports."
|
|
size="sm"
|
|
placement="right"
|
|
/>
|
|
</div>
|
|
<select
|
|
{...programmeForm.register('portfolioId')}
|
|
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"
|
|
>
|
|
{portfolios.map((p) => (
|
|
<option key={p.id} value={p.id}>
|
|
{p.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</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">Governance Status *</label>
|
|
<HelpTooltip
|
|
title="Approval Gate"
|
|
content="Only Approved programmes can issue active grants and enter contract agreements."
|
|
size="sm"
|
|
placement="top"
|
|
/>
|
|
</div>
|
|
<select
|
|
{...programmeForm.register('status')}
|
|
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"
|
|
>
|
|
{APPROVAL_STATUS_OPTIONS.map((opt) => (
|
|
<option key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center gap-1 mb-1">
|
|
<label className="block text-xs font-semibold text-slate-300">Owner Email *</label>
|
|
<HelpTooltip
|
|
title="Programme Manager"
|
|
content="Designated programme officer responsible for monitoring KPIs and milestone approvals."
|
|
size="sm"
|
|
placement="top"
|
|
/>
|
|
</div>
|
|
<input
|
|
type="text"
|
|
{...programmeForm.register('ownerId')}
|
|
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="khwezim@gmail.com"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="pt-3 border-t border-slate-800/80 flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsProgrammeModalOpen(false);
|
|
setEditingProgramme(null);
|
|
}}
|
|
className="px-3.5 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={programmeMutation.isPending}
|
|
className="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 cursor-pointer"
|
|
>
|
|
{programmeMutation.isPending ? 'Saving...' : editingProgramme ? 'Update Programme' : 'Create Programme'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
|
|
{/* Modal: View Funding Instruments */}
|
|
<Modal
|
|
isOpen={isInstrumentsModalOpen}
|
|
onClose={() => {
|
|
setIsInstrumentsModalOpen(false);
|
|
setSelectedProgrammeForInstruments(null);
|
|
}}
|
|
title={`Funding Instruments: ${selectedProgrammeForInstruments?.name || ''}`}
|
|
subtitle={`Authoritative funding instruments linked to Programme #${selectedProgrammeForInstruments?.id || ''}`}
|
|
>
|
|
<div className="space-y-4">
|
|
{loadingInstruments ? (
|
|
<div className="py-8 text-center text-xs text-slate-400">Loading funding instruments...</div>
|
|
) : programmeInstruments.length === 0 ? (
|
|
<div className="py-8 text-center text-xs text-slate-500">
|
|
No funding instruments registered under this programme.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2.5 max-h-[400px] overflow-y-auto">
|
|
{programmeInstruments.map((inst: Instrument) => {
|
|
const statusBadge = getActivityStatusBadge(inst.status);
|
|
return (
|
|
<div
|
|
key={inst.id}
|
|
className="p-3.5 rounded-xl border border-slate-800 bg-[#0d121f] flex flex-col gap-1.5"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs font-bold text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded border border-blue-500/20">
|
|
{inst.code || `INST-${inst.id}`}
|
|
</span>
|
|
<span className="font-semibold text-xs text-white">{inst.name}</span>
|
|
</div>
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
|
{statusBadge.label}
|
|
</span>
|
|
</div>
|
|
{inst.description && (
|
|
<p className="text-xs text-slate-400 leading-relaxed">{inst.description}</p>
|
|
)}
|
|
<div className="flex items-center justify-between pt-2 border-t border-slate-800/80 text-[10px] text-slate-500 font-mono">
|
|
<span>Created by: {getUserEmail(inst.createdBy)}</span>
|
|
<span>{formatDate(inst.createdAt)}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<div className="pt-2 border-t border-slate-800 flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsInstrumentsModalOpen(false);
|
|
setSelectedProgrammeForInstruments(null);
|
|
}}
|
|
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Modal: View Portfolio Programmes */}
|
|
<Modal
|
|
isOpen={isPortfolioProgrammesModalOpen}
|
|
onClose={() => {
|
|
setIsPortfolioProgrammesModalOpen(false);
|
|
setSelectedPortfolioForProgrammes(null);
|
|
}}
|
|
title={`Portfolio Programmes: ${selectedPortfolioForProgrammes?.name || ''}`}
|
|
subtitle={`Programmes allocated under Portfolio #${selectedPortfolioForProgrammes?.id || ''}`}
|
|
>
|
|
<div className="space-y-4">
|
|
{loadingPortfolioProgrammes ? (
|
|
<div className="py-8 text-center text-xs text-slate-400">Loading portfolio programmes...</div>
|
|
) : portfolioProgrammesList.length === 0 ? (
|
|
<div className="py-8 text-center text-xs text-slate-500">
|
|
No programmes registered under this portfolio.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2.5 max-h-[400px] overflow-y-auto">
|
|
{portfolioProgrammesList.map((pr: Programme) => {
|
|
const statusBadge = getApprovalStatusBadge(pr.status);
|
|
return (
|
|
<div
|
|
key={pr.id}
|
|
className="p-3.5 rounded-xl border border-slate-800 bg-[#0d121f] flex items-center justify-between"
|
|
>
|
|
<div>
|
|
<span className="font-semibold text-xs text-white block">{pr.name}</span>
|
|
<span className="text-[10px] text-slate-500 font-mono">
|
|
ID: {pr.id} • Owner: {getUserEmail(pr.ownedBy)} • {formatDate(pr.createdAt)}
|
|
</span>
|
|
</div>
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
|
{statusBadge.label}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
<div className="pt-2 border-t border-slate-800 flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsPortfolioProgrammesModalOpen(false);
|
|
setSelectedPortfolioForProgrammes(null);
|
|
}}
|
|
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Modal: View Organisation Details (GET /v1/api/governance/organisations/{id} & /portfolio) */}
|
|
<Modal
|
|
isOpen={isOrgDetailModalOpen}
|
|
onClose={() => {
|
|
setIsOrgDetailModalOpen(false);
|
|
setSelectedOrgForDetail(null);
|
|
}}
|
|
title={orgFreshDetail?.name || selectedOrgForDetail?.name || 'Organisation Master Record'}
|
|
subtitle={`Authoritative profile for Organisation #${selectedOrgForDetail?.id || ''}`}
|
|
>
|
|
<div className="space-y-4 text-xs">
|
|
{loadingOrgFreshDetail ? (
|
|
<div className="py-8 text-center text-slate-400">
|
|
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
|
|
Fetching canonical record (GET /v1/api/governance/organisations/{selectedOrgForDetail?.id})...
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-2">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Legal Name:</span>
|
|
<span className="text-white font-medium">{orgFreshDetail?.name || selectedOrgForDetail?.name}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Reg Number:</span>
|
|
<span className="font-mono text-slate-300">{orgFreshDetail?.registrationNo || selectedOrgForDetail?.registrationNo || '—'}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Official Email:</span>
|
|
<span className="font-mono text-slate-300">{orgFreshDetail?.email || selectedOrgForDetail?.email}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity Type:</span>
|
|
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<h4 className="font-semibold text-slate-300 text-xs mb-2 flex items-center justify-between">
|
|
<span>Linked Portfolios (GET /v1/api/governance/organisations/{selectedOrgForDetail?.id}/portfolio)</span>
|
|
<span className="text-[10px] font-mono text-slate-400">{orgPortfolios.length} linked</span>
|
|
</h4>
|
|
{loadingOrgPortfolios ? (
|
|
<p className="text-xs text-slate-500">Loading portfolios...</p>
|
|
) : orgPortfolios.length === 0 ? (
|
|
<p className="text-xs text-slate-500">No portfolios directly linked to this organisation.</p>
|
|
) : (
|
|
<div className="space-y-2 max-h-[220px] overflow-y-auto">
|
|
{orgPortfolios.map((port) => (
|
|
<div key={port.id} className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 flex items-center justify-between">
|
|
<div>
|
|
<span className="text-white font-medium block">{port.name}</span>
|
|
<span className="text-[10px] text-slate-500 font-mono">ID: {port.id}</span>
|
|
</div>
|
|
<span className="text-[10px] text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded border border-blue-500/20 font-mono">
|
|
Portfolio
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="pt-2 border-t border-slate-800 flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsOrgDetailModalOpen(false);
|
|
setSelectedOrgForDetail(null);
|
|
}}
|
|
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Modal: View Portfolio Details (GET /v1/api/governance/portfolios/{id}) */}
|
|
<Modal
|
|
isOpen={isPortfolioDetailModalOpen}
|
|
onClose={() => {
|
|
setIsPortfolioDetailModalOpen(false);
|
|
setSelectedPortfolioForDetail(null);
|
|
}}
|
|
title={portfolioFreshDetail?.name || selectedPortfolioForDetail?.name || 'Portfolio Details'}
|
|
subtitle={`Authoritative record for Portfolio #${selectedPortfolioForDetail?.id || ''}`}
|
|
>
|
|
<div className="space-y-4 text-xs">
|
|
{loadingPortfolioFreshDetail ? (
|
|
<div className="py-8 text-center text-slate-400">
|
|
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
|
|
Fetching canonical record (GET /v1/api/governance/portfolios/{selectedPortfolioForDetail?.id})...
|
|
</div>
|
|
) : (
|
|
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
|
|
<div>
|
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Title:</span>
|
|
<span className="text-white font-medium text-sm">{portfolioFreshDetail?.name || selectedPortfolioForDetail?.name}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Description:</span>
|
|
<p className="text-slate-300 mt-0.5 leading-relaxed">{portfolioFreshDetail?.description || selectedPortfolioForDetail?.description || 'None provided'}</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-slate-800/80">
|
|
<div>
|
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Owner:</span>
|
|
<span className="font-mono text-slate-300">{getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Created:</span>
|
|
<span className="font-mono text-slate-300">
|
|
{formatDate(portfolioFreshDetail?.createdAt || selectedPortfolioForDetail?.createdAt || '')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="pt-2 border-t border-slate-800 flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsPortfolioDetailModalOpen(false);
|
|
setSelectedPortfolioForDetail(null);
|
|
}}
|
|
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
|
|
{/* Modal: View Programme Details (GET /v1/api/projects/programme/{id}) */}
|
|
<Modal
|
|
isOpen={isProgrammeDetailModalOpen}
|
|
onClose={() => {
|
|
setIsProgrammeDetailModalOpen(false);
|
|
setSelectedProgrammeForDetail(null);
|
|
}}
|
|
title={programmeFreshDetail?.name || selectedProgrammeForDetail?.name || 'Programme Details'}
|
|
subtitle={`Authoritative record for Programme #${selectedProgrammeForDetail?.id || ''}`}
|
|
>
|
|
<div className="space-y-4 text-xs">
|
|
{loadingProgrammeFreshDetail ? (
|
|
<div className="py-8 text-center text-slate-400">
|
|
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
|
|
Fetching canonical record (GET /v1/api/projects/programme/{selectedProgrammeForDetail?.id})...
|
|
</div>
|
|
) : (
|
|
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
|
|
<div>
|
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Programme Name:</span>
|
|
<span className="text-white font-medium text-sm">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 pt-2">
|
|
<div>
|
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Approval Status:</span>
|
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
|
|
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Owner:</span>
|
|
<span className="font-mono text-slate-300">{getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="pt-2 border-t border-slate-800 flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setIsProgrammeDetailModalOpen(false);
|
|
setSelectedProgrammeForDetail(null);
|
|
}}
|
|
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
|
>
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
};
|