Files
pfm/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx
T
2026-09-11 12:13:25 +02:00

861 lines
45 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,
} 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<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);
// 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<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', 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 (
<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>
</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" />
Organisations Directory ({organisations.length})
</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" />
Funder Portfolios ({portfolios.length})
</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" />
Programmes Hierarchy ({programmes.length})
</button>
</div>
{/* Search Bar */}
{activeTab === 'organisations' && (
<div className="relative max-w-md">
<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>
)}
{/* 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 = getActivityStatusBadge(org.status);
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">
<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>
</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>
<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>
)}
{/* 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">
<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>
</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)"
>
<form onSubmit={orgForm.handleSubmit((data) => orgMutation.mutate(data))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Legal Organisation Name *
</label>
<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>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Registration / CIPC Number *
</label>
<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>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Entity Classification *
</label>
<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>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Official Contact Email *
</label>
<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>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Operating Status *
</label>
<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"
>
<form onSubmit={portfolioForm.handleSubmit((data) => portfolioMutation.mutate(data))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Portfolio Title *</label>
<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>
<label className="block text-xs font-semibold text-slate-300 mb-1">Parent Organisation *</label>
<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>
<label className="block text-xs font-semibold text-slate-300 mb-1">Owner Email *</label>
<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>
<label className="block text-xs font-semibold text-slate-300 mb-1">Strategic Objective *</label>
<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"
>
<form onSubmit={programmeForm.handleSubmit((data) => programmeMutation.mutate(data))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Programme Title *</label>
<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>
<label className="block text-xs font-semibold text-slate-300 mb-1">Assign to Portfolio *</label>
<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>
<label className="block text-xs font-semibold text-slate-300 mb-1">Governance Status *</label>
<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>
<label className="block text-xs font-semibold text-slate-300 mb-1">Owner Email *</label>
<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>
</div>
);
};