Files
pfm/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx
T

3041 lines
140 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import {
Building2,
Plus,
FolderKanban,
Edit2,
Mail,
Shield,
ShieldCheck,
Layers,
ChevronRight,
Search,
Coins,
Eye,
RefreshCw,
ArrowRight,
FileText,
Scale,
Calendar,
UserPlus,
ChevronDown,
ChevronUp,
CheckCircle,
CheckCircle2,
Loader2,
Lock,
} from 'lucide-react';
import {
getOrganisations,
getOrganisation,
createOrganisation,
updateOrganisation,
getAllPortfolios,
getPortfolios,
getOrganisationPortfolios,
getPortfolio,
createPortfolio,
updatePortfolio,
getAllProgrammes,
getProgrammes,
getProgramme,
createProgramme,
updateProgramme,
getInstruments,
getInstructions,
createInstrument,
updateInstrument,
getRules,
createRule,
updateRule,
getPortfolioProgrammes,
getPortfolioValue,
getProgrammeValue,
getUsers,
getUser,
createUser,
assignUserRole,
getSystemRoles,
getCurrentUserTenantId,
getTenantUsers,
notifyNewUserCreated,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast';
import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useAuth } from '../auth/AuthProvider';
import { useRbac } from '../auth/rbac';
import { bumpMutationEpoch } from '../services/httpClient';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
import { RulesSection } from '../components/governance/RulesSection';
import {
Organisation,
TenantOrganisation,
TenantUser,
Portfolio,
Programme,
Instrument,
Instruction,
Rule,
User,
OrganisationType,
ActivityStatus,
ApprovalStatus,
Role,
CreateUserRequest,
CreateOrganisationRequest,
CreatePortfolioRequest,
CreateProgrammeRequest,
} from '../types';
import {
getOrganisationTypeLabel,
getActivityStatusBadge,
getOrganisationStatusBadge,
getApprovalStatusBadge,
formatDate,
formatZAR,
getUserEmail,
registerUserEmail,
getUserIdByEmail,
ORGANISATION_TYPE_OPTIONS,
ACTIVITY_STATUS_OPTIONS,
APPROVAL_STATUS_OPTIONS,
} from '../lib/formatters';
// Validation Schemas matching API constraints
const organisationSchema = z.object({
name: z.string().min(3, 'Name must be at least 3 characters'),
registrationNo: z.string().min(4, 'Registration number is mandatory'),
email: z.string().email('Enter a valid email address'),
organisationUser: z.string().min(1, 'Please select a linked executive or manager user'),
type: z.coerce.number().min(1).max(27),
status: z.coerce.number().optional(),
});
const portfolioSchema = z.object({
name: z.string().min(3, 'Portfolio name is required'),
description: z.string().min(5, 'Description is required'),
organisationId: z.coerce.number().min(1, 'Select a parent organisation'),
ownerEmail: z.string().min(1, 'Owner email is required'),
});
const programmeSchema = z.object({
name: z.string().min(3, 'Programme name is required'),
portfolioId: z.coerce.number().min(1, 'Select a parent portfolio'),
status: z.coerce.number().optional(),
ownerEmail: z.string().min(1, 'Owner email is required'),
});
type OrganisationFormValues = z.infer<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, claims, roles, tenantId } = useAuth();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker, isUnrestricted } = useRbac();
const currentUserId = sub || user?.id || '';
const { success, error: toastError } = useToast();
const effectiveUserEmail =
user?.email ||
(claims?.email as string) ||
(claims?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) ||
(claims?.upn as string) ||
(sub ? getUserEmail(sub) : '') ||
'';
useEffect(() => {
if (effectiveUserEmail) {
if (sub) registerUserEmail(sub, effectiveUserEmail);
if (user?.id) registerUserEmail(user.id, effectiveUserEmail);
}
}, [sub, user, effectiveUserEmail]);
const [searchParams, setSearchParams] = useSearchParams();
const tabParam = searchParams.get('tab');
const getValidTab = (val: string | null): 'organisations' | 'portfolios' | 'programmes' | 'instruments' | 'instructions' | 'rules' => {
if (val === 'portfolios' || val === 'programmes' || val === 'organisations' || val === 'instruments' || val === 'instructions' || val === 'rules') {
return val === 'instructions' ? 'instruments' : val;
}
return 'organisations';
};
const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes' | 'instruments' | 'instructions' | 'rules'>(() => getValidTab(tabParam));
useEffect(() => {
if (tabParam && (tabParam === 'organisations' || tabParam === 'portfolios' || tabParam === 'programmes' || tabParam === 'instruments' || tabParam === 'instructions' || tabParam === 'rules')) {
setActiveTab(tabParam === 'instructions' ? 'instruments' : tabParam);
}
}, [tabParam]);
const handleTabChange = (tab: 'organisations' | 'portfolios' | 'programmes' | 'instruments' | 'instructions' | 'rules') => {
const canonicalTab = tab === 'instructions' ? 'instruments' : tab;
setActiveTab(canonicalTab);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('tab', canonicalTab);
return next;
});
};
const [preselectedProgForInstruction, setPreselectedProgForInstruction] = useState<string | number | null>(null);
const [preselectedProgForRule, setPreselectedProgForRule] = useState<string | number | null>(null);
const [isInstructionCreateTriggered, setIsInstructionCreateTriggered] = useState(false);
const [isRuleCreateTriggered, setIsRuleCreateTriggered] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [highlightedPortfolioId, setHighlightedPortfolioId] = useState<number | string | null>(null);
const handleNavigateToPortfolioTab = (portfolioId?: number | string) => {
setIsOrgDetailModalOpen(false);
setSelectedOrgForDetail(null);
handleTabChange('portfolios');
if (portfolioId != null) {
setHighlightedPortfolioId(portfolioId);
setTimeout(() => {
const el = document.getElementById(`portfolio-card-${portfolioId}`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 150);
setTimeout(() => {
setHighlightedPortfolioId(null);
}, 3500);
}
};
// Modals & Editing State
const [isOrgModalOpen, setIsOrgModalOpen] = useState(false);
const [editingOrg, setEditingOrg] = useState<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 [selectedProgrammeForRules, setSelectedProgrammeForRules] = useState<Programme | null>(null);
const [isRulesModalOpen, setIsRulesModalOpen] = 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: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
});
const { data: systemRoles = [] } = useQuery<Role[]>({
queryKey: ['system-roles'],
queryFn: getSystemRoles,
enabled: isOrgModalOpen,
});
const assignableRolesForOrg = React.useMemo(() => {
return systemRoles.filter(
(r) => (r.name || r.Name || '').toLowerCase() !== 'admin' && (r.id || r.Id) !== '1'
);
}, [systemRoles]);
const executiveAndManagerUsers = React.useMemo(() => {
return usersDirectory.filter((u) => {
const role = String(u.role || '').toLowerCase();
return role === 'executive' || role === 'manager';
});
}, [usersDirectory]);
const isAdmin =
Boolean(isUnrestricted) ||
(Array.isArray(roles) && roles.some((r) => r.toLowerCase().trim() === 'admin')) ||
(claims?.role && String(claims.role).toLowerCase().includes('admin'));
const orgTenantId = editingOrg?.tenantId || tenantId || getCurrentUserTenantId() || 1;
const { data: tenantUsersList = [] } = useQuery<TenantUser[]>({
queryKey: ['tenant-users', orgTenantId],
queryFn: () => getTenantUsers(orgTenantId),
enabled: isOrgModalOpen && !!editingOrg && !isAdmin,
});
useEffect(() => {
if (tenantUsersList.length > 0) {
tenantUsersList.forEach((tu) => {
if (tu.userId && tu.user?.email) {
registerUserEmail(tu.userId, tu.user.email);
}
});
}
}, [tenantUsersList]);
const modalExecutiveUsers = React.useMemo(() => {
const list: User[] = [];
const seenIds = new Set<string>();
const addCandidate = (u?: User | null) => {
if (!u || !u.id) return;
const key = String(u.id).toLowerCase();
if (seenIds.has(key)) return;
seenIds.add(key);
list.push(u);
};
// 1. Always include the organisation's linked user so the select option exists and selects immediately
const linkedUserId =
editingOrg?.userId ||
editingOrg?.UserId ||
editingOrg?.organisationUser ||
editingOrg?.OrganisationUser ||
'';
if (linkedUserId) {
const resolvedEmail = getUserEmail(linkedUserId);
const matched = usersDirectory.find(
(u) => u.id && String(u.id).toLowerCase() === String(linkedUserId).toLowerCase()
);
addCandidate({
id: linkedUserId,
email: matched?.email || (resolvedEmail !== '—' && resolvedEmail ? resolvedEmail : linkedUserId),
role: matched?.role || 'Executive',
status: ActivityStatus.Active,
lastLoginAt: null,
});
}
// 2. Load Executive role users: Admin sees all executives across tenants; normal tenant user focuses on tenantusers
if (isAdmin) {
usersDirectory
.filter((u) => String(u.role || '').toLowerCase() === 'executive')
.forEach(addCandidate);
} else {
tenantUsersList
.filter((tu) => String(tu.user?.role || '').toLowerCase() === 'executive')
.forEach((tu) => {
if (tu.user) {
addCandidate(tu.user);
} else if (tu.userId) {
addCandidate({
id: tu.userId,
email: getUserEmail(tu.userId),
role: 'Executive',
status: ActivityStatus.Active,
lastLoginAt: null,
});
}
});
}
return list;
}, [isAdmin, usersDirectory, tenantUsersList, editingOrg?.id, editingOrg?.userId]);
// Quick Add Executive User Subform state
const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false);
const [quickUserEmail, setQuickUserEmail] = useState('');
const [quickUserName, setQuickUserName] = useState('');
const [quickUserPhone, setQuickUserPhone] = useState('');
const [quickUserIdNumber, setQuickUserIdNumber] = useState('');
const [quickUserRoleId, setQuickUserRoleId] = useState('');
const [isSubmittingQuickUser, setIsSubmittingQuickUser] = useState(false);
const [quickUserError, setQuickUserError] = useState<string | null>(null);
React.useEffect(() => {
if (!quickUserRoleId && assignableRolesForOrg.length > 0) {
const execRole = assignableRolesForOrg.find((r) => (r.name || r.Name || '').toLowerCase() === 'executive');
if (execRole) setQuickUserRoleId(execRole.id || execRole.Id || '');
else setQuickUserRoleId(assignableRolesForOrg[0].id || assignableRolesForOrg[0].Id || '');
}
}, [assignableRolesForOrg, quickUserRoleId]);
const handleQuickAddUser = async () => {
setQuickUserError(null);
if (!quickUserEmail || !quickUserEmail.includes('@')) {
setQuickUserError('Please provide a valid email address.');
return;
}
if (!quickUserIdNumber || quickUserIdNumber.trim().length < 4) {
setQuickUserError('Government issued ID number is required (min 4 characters).');
return;
}
setIsSubmittingQuickUser(true);
try {
const tenantId = getCurrentUserTenantId() || 1;
const rawEmail = quickUserEmail.trim().toLowerCase();
const sanitizedUserName = quickUserName.trim()
? quickUserName.trim().toLowerCase().replace(/\s+/g, '.').replace(/[^a-z0-9_.\-@+]/g, '')
: rawEmail.split('@')[0];
const payload: CreateUserRequest = {
email: rawEmail,
userName: sanitizedUserName || rawEmail,
emailConfirmed: true,
phoneNumber: quickUserPhone.trim() || null,
phoneNumberConfirmed: false,
LockoutEnabled: false,
twoFactorEnabled: false,
accessFailedCount: 0,
};
await createUser(tenantId, quickUserIdNumber.trim(), payload);
// Invalidate queries and fetch fresh user list immediately
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
await queryClient.invalidateQueries({ queryKey: ['users'] });
const freshUsers = await getUsers(1, 200);
const createdUser = freshUsers.find(
(u) => u.email && u.email.toLowerCase() === quickUserEmail.trim().toLowerCase()
);
if (createdUser && quickUserRoleId) {
try {
await assignUserRole(createdUser.id, quickUserRoleId);
await queryClient.invalidateQueries({ queryKey: ['users-directory'] });
await queryClient.invalidateQueries({ queryKey: ['users'] });
} catch {
// Handled gracefully
}
orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true });
} else if (createdUser) {
orgForm.setValue('organisationUser', createdUser.id, { shouldValidate: true });
}
// RULE 3: Notify created Executive/Manager user via InApp and Email (directs to Console)
if (createdUser) {
await notifyNewUserCreated({
userId: createdUser.id,
email: quickUserEmail,
isPartner: false,
roleName: 'Executive',
tenantId,
}).catch(console.warn);
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
queryClient.invalidateQueries({ queryKey: ['notifications'] });
}
success('Executive User Created', `User ${quickUserEmail} successfully created and linked.`);
setQuickUserEmail('');
setQuickUserName('');
setQuickUserPhone('');
setQuickUserIdNumber('');
setIsQuickAddUserOpen(false);
} catch (err: unknown) {
const errObj = err as { response?: { data?: { detail?: string } }; message?: string };
const errorDetail = errObj?.response?.data?.detail || errObj?.message || 'Failed to create user.';
setQuickUserError(errorDetail);
toastError('Failed to create user', err);
} finally {
setIsSubmittingQuickUser(false);
}
};
const {
data: organisations = [],
isLoading: loadingOrgs,
isFetching: isFetchingOrgs,
refetch: refetchOrgs,
} = useQuery({
queryKey: ['organisations'],
queryFn: () => getOrganisations(),
});
const {
data: portfolios = [],
isLoading: loadingPortfolios,
isFetching: isFetchingPortfolios,
refetch: refetchPortfolios,
} = useQuery({
queryKey: ['portfolios'],
queryFn: () => getAllPortfolios(),
});
const {
data: programmes = [],
isLoading: loadingProgrammes,
isFetching: isFetchingProgrammes,
refetch: refetchProgrammes,
} = useQuery({
queryKey: ['programmes'],
queryFn: () => getAllProgrammes(),
});
const { data: programmeInstruments = [], isLoading: loadingInstruments } = useQuery({
queryKey: ['instruments', selectedProgrammeForInstruments?.id],
queryFn: () => (selectedProgrammeForInstruments ? getInstructions(selectedProgrammeForInstruments.id) : Promise.resolve([])),
enabled: !!selectedProgrammeForInstruments && isInstrumentsModalOpen,
});
const { data: programmeRulesList = [], isLoading: loadingProgrammeRules } = useQuery({
queryKey: ['programme-rules', selectedProgrammeForRules?.id],
queryFn: () => (selectedProgrammeForRules ? getRules({ page: 1, pageSize: 200 }, selectedProgrammeForRules.id) : Promise.resolve([])),
enabled: !!selectedProgrammeForRules && isRulesModalOpen,
});
const { data: programmeDetailRules = [], isLoading: loadingDetailRules } = useQuery({
queryKey: ['programme-detail-rules', selectedProgrammeForDetail?.id],
queryFn: () => (selectedProgrammeForDetail ? getRules({ page: 1, pageSize: 200 }, selectedProgrammeForDetail.id) : Promise.resolve([])),
enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen,
});
const { data: programmeDetailInstructions = [], isLoading: loadingDetailInstructions } = useQuery({
queryKey: ['programme-detail-instructions', selectedProgrammeForDetail?.id],
queryFn: () => (selectedProgrammeForDetail ? getInstructions(selectedProgrammeForDetail.id) : Promise.resolve([])),
enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen,
});
const { data: portfolioProgrammesList = [], isLoading: loadingPortfolioProgrammes } = useQuery({
queryKey: ['portfolioProgrammes', selectedPortfolioForProgrammes?.id],
queryFn: () => (selectedPortfolioForProgrammes ? getPortfolioProgrammes(selectedPortfolioForProgrammes.id) : Promise.resolve([])),
enabled: !!selectedPortfolioForProgrammes && isPortfolioProgrammesModalOpen,
});
const { data: portfolioDirectProgrammes = [] } = useQuery({
queryKey: ['portfolio-direct-programmes', selectedPortfolioForProgrammes?.id],
queryFn: () => (selectedPortfolioForProgrammes ? getProgrammes(selectedPortfolioForProgrammes.id) : Promise.resolve([])),
enabled: !!selectedPortfolioForProgrammes && isPortfolioProgrammesModalOpen,
});
const { data: orgFreshDetail, isLoading: loadingOrgFreshDetail } = useQuery({
queryKey: ['organisation', selectedOrgForDetail?.id],
queryFn: () => (selectedOrgForDetail ? getOrganisation(selectedOrgForDetail.id) : null),
enabled: !!selectedOrgForDetail && isOrgDetailModalOpen,
});
const { data: orgPortfolios = [], isLoading: loadingOrgPortfolios } = useQuery({
queryKey: ['organisation-portfolios', selectedOrgForDetail?.id],
queryFn: () => (selectedOrgForDetail ? getOrganisationPortfolios(selectedOrgForDetail.id) : Promise.resolve([])),
enabled: !!selectedOrgForDetail && isOrgDetailModalOpen,
});
const { data: portfolioFreshDetail, isLoading: loadingPortfolioFreshDetail } = useQuery({
queryKey: ['portfolio', selectedPortfolioForDetail?.id],
queryFn: () => (selectedPortfolioForDetail ? getPortfolio(selectedPortfolioForDetail.id) : null),
enabled: !!selectedPortfolioForDetail && isPortfolioDetailModalOpen,
});
const { data: portfolioValue = 0, isLoading: loadingPortfolioValue } = useQuery({
queryKey: ['portfolio-value', selectedPortfolioForDetail?.id],
queryFn: () => (selectedPortfolioForDetail ? getPortfolioValue(selectedPortfolioForDetail.id) : Promise.resolve(0)),
enabled: !!selectedPortfolioForDetail && isPortfolioDetailModalOpen,
});
const { data: programmeFreshDetail, isLoading: loadingProgrammeFreshDetail } = useQuery({
queryKey: ['programme', selectedProgrammeForDetail?.id],
queryFn: () => (selectedProgrammeForDetail ? getProgramme(selectedProgrammeForDetail.id) : null),
enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen,
});
const { data: programmeValue = 0, isLoading: loadingProgrammeValue } = useQuery({
queryKey: ['programme-value', selectedProgrammeForDetail?.id],
queryFn: () => (selectedProgrammeForDetail ? getProgrammeValue(selectedProgrammeForDetail.id) : Promise.resolve(0)),
enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen,
});
// Owner resolution helpers
const resolveOwnerId = (emailOrId: string, fallbackOwnerId?: string | number | null): string => {
const trimmed = (emailOrId || '').trim();
if (!trimmed) {
return fallbackOwnerId ? String(fallbackOwnerId) : currentUserId;
}
// If already a valid UUID
if (/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(trimmed)) {
return trimmed;
}
const cleanEmail = trimmed.toLowerCase();
// 1. Current logged-in user match
if (effectiveUserEmail && cleanEmail === effectiveUserEmail.toLowerCase()) {
return sub || user?.id || (fallbackOwnerId ? String(fallbackOwnerId) : currentUserId);
}
// 2. Reverse cache lookup
const cachedId = getUserIdByEmail(cleanEmail);
if (cachedId) return cachedId;
// 3. Search loaded users directory
const matchedUser = usersDirectory.find(
(u) => u.email && u.email.trim().toLowerCase() === cleanEmail
);
if (matchedUser?.id) return String(matchedUser.id);
// 4. Fallback ID match check
if (fallbackOwnerId) {
const origEmail = getUserEmail(fallbackOwnerId);
if (origEmail && origEmail.toLowerCase() === cleanEmail) {
return String(fallbackOwnerId);
}
}
// 5. Default fallback
return fallbackOwnerId ? String(fallbackOwnerId) : currentUserId;
};
const resolveOwnerEmailForDisplay = async (rawOwnerId?: string | number | null): Promise<string> => {
if (!rawOwnerId) {
return effectiveUserEmail;
}
const raw = String(rawOwnerId).trim();
if (raw.includes('@')) {
return raw;
}
// Check if it's the current user
if (
(sub && raw.toLowerCase() === sub.toLowerCase()) ||
(user?.id && raw.toLowerCase() === user.id.toLowerCase())
) {
return effectiveUserEmail;
}
// Check cached email
const cached = getUserEmail(raw);
if (cached && cached.includes('@')) {
return cached;
}
// Check users directory
const found = usersDirectory.find((u) => String(u.id).toLowerCase() === raw.toLowerCase());
if (found?.email) {
return found.email;
}
// If it's a UUID, fetch user canonical record from API
if (/^[0-9a-fA-F-]{36}$/.test(raw)) {
try {
const u = await getUser(raw);
if (u?.email) {
registerUserEmail(u.id, u.email);
return u.email;
}
} catch {
// Fetch failed
}
}
return effectiveUserEmail;
};
// Forms
const orgForm = useForm<OrganisationFormValues>({
resolver: zodResolver(organisationSchema),
defaultValues: {
name: '',
registrationNo: '',
email: '',
organisationUser: '',
type: OrganisationType.Enterprise,
status: ActivityStatus.Active,
},
});
const portfolioForm = useForm<PortfolioFormValues>({
resolver: zodResolver(portfolioSchema),
defaultValues: {
name: '',
description: '',
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
ownerEmail: effectiveUserEmail,
},
});
const programmeForm = useForm<ProgrammeFormValues>({
resolver: zodResolver(programmeSchema),
defaultValues: {
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Pending,
ownerEmail: effectiveUserEmail,
},
});
// Mutations
const orgMutation = useMutation({
mutationFn: (data: OrganisationFormValues) => {
if (editingOrg) {
return updateOrganisation({
organisationId: editingOrg.id,
name: data.name,
registrationNo: data.registrationNo,
email: data.email,
organisationUser: data.organisationUser,
userId: data.organisationUser,
type: Number(data.type),
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
});
}
return createOrganisation({
name: data.name,
registrationNo: data.registrationNo,
email: data.email,
organisationUser: data.organisationUser,
type: Number(data.type),
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['organisations'] });
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
setIsOrgModalOpen(false);
setEditingOrg(null);
orgForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to save organisation', err);
},
});
const portfolioMutation = useMutation({
mutationFn: (data: PortfolioFormValues) => {
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingPortfolio?.ownedBy);
if (editingPortfolio) {
return updatePortfolio({
portfolioId: editingPortfolio.id,
name: data.name,
description: data.description,
organisationId: Number(data.organisationId),
ownerId: resolvedOwnerId,
});
}
return createPortfolio({
name: data.name,
description: data.description,
organisationId: Number(data.organisationId),
ownerId: resolvedOwnerId,
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
setIsPortfolioModalOpen(false);
setEditingPortfolio(null);
portfolioForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to save portfolio', err);
},
});
const approveProgrammeMutation = useMutation({
mutationFn: async (pr: Programme) => {
if (isMaker(pr.createdBy)) {
throw new Error('Maker-Checker policy: Record creator cannot approve their own programme.');
}
return updateProgramme({
programmeId: pr.id,
name: pr.name || '',
portfolioId: Number(pr.portfolioId),
status: ApprovalStatus.Approved,
ownerId: pr.ownedBy,
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['programmes'] });
queryClient.invalidateQueries({ queryKey: ['programme'] });
success('Programme Approved', 'Programme governance status successfully updated to Approved.');
},
onError: (err: unknown) => {
toastError('Programme Approval Failed', err);
},
});
const programmeMutation = useMutation({
mutationFn: (data: ProgrammeFormValues) => {
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
if (editingProgramme) {
const targetStatus = (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus;
if (targetStatus === ApprovalStatus.Approved && isMaker(editingProgramme.createdBy)) {
throw new Error('Maker-Checker policy violation: Record creators cannot approve their own programmes.');
}
return updateProgramme({
programmeId: editingProgramme.id,
name: data.name,
portfolioId: Number(data.portfolioId),
status: targetStatus,
ownerId: resolvedOwnerId,
});
}
return createProgramme({
name: data.name,
portfolioId: Number(data.portfolioId),
ownerId: resolvedOwnerId,
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['programmes'] });
queryClient.invalidateQueries({ queryKey: ['programme'] });
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
setIsProgrammeModalOpen(false);
setEditingProgramme(null);
programmeForm.reset();
},
onError: (err: unknown) => {
toastError('Failed to save programme', err);
},
});
const openCreateOrg = () => {
setEditingOrg(null);
const defaultUser = executiveAndManagerUsers[0];
orgForm.reset({
name: '',
registrationNo: '',
email: '',
organisationUser: defaultUser?.id || '',
type: OrganisationType.Enterprise,
status: ActivityStatus.Active,
});
setIsQuickAddUserOpen(false);
setQuickUserError(null);
setIsOrgModalOpen(true);
};
const openEditOrg = (org: Organisation) => {
setEditingOrg(org);
const targetUserId =
org.userId ||
org.UserId ||
org.organisationUser ||
org.OrganisationUser ||
org.createdBy ||
'';
if (targetUserId) {
const email = getUserEmail(targetUserId);
if (email && email !== '—') {
registerUserEmail(targetUserId, email);
}
}
const matchedUser = targetUserId
? usersDirectory.find(
(u) =>
(u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase()) ||
(u.email && getUserEmail(targetUserId) && u.email.toLowerCase() === getUserEmail(targetUserId).toLowerCase())
)
: null;
const selectedUserId = matchedUser?.id || targetUserId || '';
orgForm.reset({
name: org.name || '',
registrationNo: org.registrationNo || '',
email: org.email || '',
organisationUser: selectedUserId,
type: org.type || OrganisationType.Enterprise,
status: org.status ?? ActivityStatus.Active,
});
setIsQuickAddUserOpen(false);
setQuickUserError(null);
setIsOrgModalOpen(true);
};
const openCreatePortfolio = () => {
setEditingPortfolio(null);
portfolioForm.reset({
name: '',
description: '',
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
ownerEmail: effectiveUserEmail,
});
setIsPortfolioModalOpen(true);
};
const openEditPortfolio = async (p: Portfolio) => {
setEditingPortfolio(p);
const email = await resolveOwnerEmailForDisplay(p.ownedBy);
portfolioForm.reset({
name: p.name || '',
description: p.description || '',
organisationId: Number(p.organisationId) || 101,
ownerEmail: email,
});
setIsPortfolioModalOpen(true);
};
const openCreateProgramme = () => {
setEditingProgramme(null);
programmeForm.reset({
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Pending,
ownerEmail: effectiveUserEmail,
});
setIsProgrammeModalOpen(true);
};
const openEditProgramme = async (pr: Programme) => {
setEditingProgramme(pr);
const email = await resolveOwnerEmailForDisplay(pr.ownedBy);
programmeForm.reset({
name: pr.name || '',
portfolioId: Number(pr.portfolioId) || 201,
status: pr.status ?? ApprovalStatus.Approved,
ownerEmail: email,
});
setIsProgrammeModalOpen(true);
};
// Filtered lists
const filteredOrgs = organisations.filter(
(o) =>
o.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
o.registrationNo?.toLowerCase().includes(searchTerm.toLowerCase()) ||
o.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
String(o.id).includes(searchTerm)
);
const filteredPortfolios = portfolios.filter((p) => {
if (!searchTerm.trim()) return true;
const q = searchTerm.toLowerCase();
const org = organisations.find((o) => String(o.id) === String(p.organisationId));
const orgName = org?.name?.toLowerCase() || '';
const name = (p.name || '').toLowerCase();
const desc = (p.description || '').toLowerCase();
const id = String(p.id);
const owner = getUserEmail(p.ownedBy).toLowerCase();
return name.includes(q) || desc.includes(q) || id.includes(q) || orgName.includes(q) || owner.includes(q);
});
const filteredProgrammes = programmes.filter((pr) => {
if (!searchTerm.trim()) return true;
const q = searchTerm.toLowerCase();
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
const portName = port?.name?.toLowerCase() || '';
const name = (pr.name || '').toLowerCase();
const id = String(pr.id);
const owner = getUserEmail(pr.ownedBy).toLowerCase();
return name.includes(q) || id.includes(q) || portName.includes(q) || owner.includes(q);
});
// Table Sorting Hooks
const {
sortedData: sortedOrgs,
sortColumn: orgSortCol,
sortDirection: orgSortDir,
handleSort: handleOrgSort,
} = useTableSort(filteredOrgs, null, null, {
name: (o) => o.name,
registrationNo: (o) => o.registrationNo,
type: (o) => getOrganisationTypeLabel(o.type),
email: (o) => o.email,
status: (o) => getOrganisationStatusBadge(o).label,
});
const {
sortedData: sortedProgrammes,
sortColumn: progSortCol,
sortDirection: progSortDir,
handleSort: handleProgSort,
} = useTableSort(filteredProgrammes, null, null, {
name: (p) => p.name,
portfolioId: (p) => {
const port = portfolios.find((po) => String(po.id) === String(p.portfolioId));
return port ? port.name : `Portfolio #${p.portfolioId}`;
},
status: (p) => getApprovalStatusBadge(p.status).label,
createdAt: (p) => p.createdAt,
ownedBy: (p) => getUserEmail(p.ownedBy),
createdBy: (p) => getUserEmail(p.createdBy),
});
// Export Columns Definitions
const orgExportColumns: ExportColumn<Organisation>[] = [
{ header: 'Organisation ID', key: 'id' },
{ header: 'Legal Entity Name', key: 'name' },
{ header: 'Registration / CIPC No', key: 'registrationNo', format: (val) => val || '—' },
{ header: 'Entity Classification', key: 'type', format: (val) => getOrganisationTypeLabel(val) },
{ header: 'Official Email', key: 'email', format: (val) => val || '—' },
{ header: 'Status', key: 'status', format: (val) => getOrganisationStatusBadge({ status: val }).label },
{ header: 'Registration Date', key: 'createdAt', format: (val) => formatDate(val) },
];
const portfolioExportColumns: ExportColumn<Portfolio>[] = [
{ header: 'Portfolio ID', key: 'id' },
{ header: 'Portfolio Name', key: 'name' },
{
header: 'Organisation',
key: 'organisationId',
format: (val) => {
const org = organisations.find((o) => String(o.id) === String(val));
return org ? org.name : `Org #${val}`;
},
},
{
header: 'Programmes Count',
key: 'id',
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
},
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Description', key: 'description', format: (val) => val || '—' },
];
const programmeExportColumns: ExportColumn<Programme>[] = [
{ header: 'Programme ID', key: 'id' },
{ header: 'Programme Name', key: 'name' },
{
header: 'Parent Portfolio',
key: 'portfolioId',
format: (val) => {
const port = portfolios.find((p) => String(p.id) === String(val));
return port && port.name ? port.name : `Portfolio #${val}`;
},
},
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
];
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Header Banner */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 bg-gradient-to-r from-[#0d1322] via-[#0f172a] to-[#0d1322] p-6 sm:p-8 rounded-3xl border border-slate-800/80 shadow-2xl relative overflow-hidden">
<div className="absolute -right-10 -bottom-10 w-60 h-60 bg-blue-600/10 rounded-full blur-3xl pointer-events-none" />
<div className="space-y-2 relative z-10">
<div className="flex items-center gap-3">
<div className="h-12 w-12 rounded-2xl bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-lg shadow-blue-500/10">
<Building2 className="w-6 h-6" />
</div>
<div>
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-2xl sm:text-3xl font-bold tracking-tight text-white flex items-center gap-2.5">
Governance &amp; Structure
</h1>
<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 sm:text-sm text-slate-400 mt-1">
Maintain Master Organisation Registry, Funder Portfolios, Strategic Programmes, Funding Instruments, and Programme Rules.
</p>
</div>
</div>
</div>
{/* Tab Actions */}
<div className="flex items-center gap-2 relative z-10 self-start md:self-center flex-wrap">
{activeTab === 'organisations' && canAdd('governance') && (
<button
onClick={openCreateOrg}
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Register Organisation</span>
</button>
)}
{activeTab === 'portfolios' && canAdd('governance') && (
<button
onClick={openCreatePortfolio}
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Create Portfolio</span>
</button>
)}
{activeTab === 'programmes' && canAdd('governance') && (
<button
onClick={openCreateProgramme}
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Create Programme</span>
</button>
)}
{(activeTab === 'instruments' || activeTab === 'instructions') && canAdd('governance') && (
<button
onClick={() => setIsInstructionCreateTriggered(true)}
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Add Funding Instrument</span>
</button>
)}
{activeTab === 'rules' && canAdd('governance') && (
<button
onClick={() => setIsRuleCreateTriggered(true)}
className="inline-flex items-center gap-1.5 px-4 py-2.5 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-lg shadow-blue-600/25 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>Add Rule</span>
</button>
)}
</div>
</div>
{/* Tabs */}
<div className="flex border-b border-slate-800/80 bg-[#0d121f]/80 px-4 rounded-2xl backdrop-blur-sm overflow-x-auto">
<button
onClick={() => handleTabChange('organisations')}
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
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={() => handleTabChange('portfolios')}
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
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={() => handleTabChange('programmes')}
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
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>
<button
onClick={() => handleTabChange('instruments')}
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
activeTab === 'instruments' || activeTab === 'instructions'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Coins className="w-4 h-4" />
<span>Funding Instruments</span>
<HelpTooltip
title="Funding Instruments"
content="Binding funding instruments and financing directives attached to specific strategic funding programmes."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => handleTabChange('rules')}
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer whitespace-nowrap ${
activeTab === 'rules'
? 'border-blue-500 text-blue-400'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Scale className="w-4 h-4" />
<span>Programme Rules</span>
<HelpTooltip
title="Programme Rules"
content="Statutory, regulatory, and eligibility rules associated with specific funding programmes."
size="sm"
placement="top"
/>
</button>
</div>
{/* Search Bar & Actions for Organisations */}
{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, email, or ID..."
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>
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchOrgs();
success('Refreshed', 'Organisation directory refreshed from API.');
}}
disabled={isFetchingOrgs}
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 shadow-xs disabled:opacity-50"
title="Force refresh organisation directory from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingOrgs ? 'animate-spin' : ''}`} />
<span>Refresh Directory</span>
</button>
<ExportButton
claim="governance"
title="Organisation Master Registry"
subtitle="Official directory of verified recipient and funder entities"
filename={`pfm-organisations-${new Date().toISOString().split('T')[0]}`}
columns={orgExportColumns}
data={filteredOrgs}
/>
</div>
</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]">
<SortableTh column="name" currentColumn={orgSortCol} direction={orgSortDir} onSort={handleOrgSort} className="py-3.5 px-5">
Organisation Name
</SortableTh>
<SortableTh column="registrationNo" currentColumn={orgSortCol} direction={orgSortDir} onSort={handleOrgSort} className="py-3.5 px-4">
Registration No
</SortableTh>
<SortableTh column="type" currentColumn={orgSortCol} direction={orgSortDir} onSort={handleOrgSort} className="py-3.5 px-4">
Entity Type
</SortableTh>
<SortableTh column="email" currentColumn={orgSortCol} direction={orgSortDir} onSort={handleOrgSort} className="py-3.5 px-4">
Contact Email
</SortableTh>
<SortableTh column="status" currentColumn={orgSortCol} direction={orgSortDir} onSort={handleOrgSort} className="py-3.5 px-4">
Status
</SortableTh>
<th className="py-3.5 px-5 text-right font-medium">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>
) : sortedOrgs.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
No organisations found matching query.
</td>
</tr>
) : (
sortedOrgs.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"
>
<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>
)}
{/* Search Bar & Actions for Portfolios */}
{activeTab === 'portfolios' && (
<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 portfolios by name, ID, organisation, or owner..."
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>
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchPortfolios();
success('Refreshed', 'Funder portfolios refreshed from API.');
}}
disabled={isFetchingPortfolios}
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 shadow-xs disabled:opacity-50"
title="Force refresh portfolios from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingPortfolios ? 'animate-spin' : ''}`} />
<span>Refresh Portfolios</span>
</button>
<ExportButton
claim="governance"
title="Funder Portfolios Registry"
subtitle="High-level funding groupings and thematic capital allocations"
filename={`pfm-portfolios-${new Date().toISOString().split('T')[0]}`}
columns={portfolioExportColumns}
data={filteredPortfolios}
/>
</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 text-center py-8">Loading portfolios...</p>
) : filteredPortfolios.length === 0 ? (
<div className="col-span-3 py-12 text-center bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 p-8">
<FolderKanban className="w-8 h-8 text-slate-600 mx-auto mb-2" />
<p className="text-sm font-semibold text-slate-300">No portfolios found matching query</p>
<p className="text-xs text-slate-500 mt-1">Try refining your search keywords or create a new portfolio.</p>
</div>
) : (
filteredPortfolios.map((p) => {
const org = organisations.find((o) => String(o.id) === String(p.organisationId));
const progCount = programmes.filter((pr) => String(pr.portfolioId) === String(p.id)).length;
const isHighlighted = String(highlightedPortfolioId) === String(p.id);
return (
<div
key={p.id}
id={`portfolio-card-${p.id}`}
className={`bg-[#0f1523]/80 p-5 rounded-2xl border shadow-lg backdrop-blur-sm flex flex-col justify-between transition-all ${
isHighlighted
? 'border-blue-500 ring-2 ring-blue-500/50 shadow-blue-500/20 scale-[1.01]'
: 'border-slate-800/80 hover:border-slate-700/80'
}`}
>
<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.5">
<div className="flex items-center justify-between text-xs text-slate-400">
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org && org.name ? org.name : `Org #${p.organisationId}`}>
{org && org.name ? org.name : `Org #${p.organisationId}`}
</span>
<span className="font-mono text-[11px] text-slate-500 shrink-0">{formatDate(p.createdAt)}</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-400 min-w-0">
<span className="font-medium shrink-0">Owner:</span>
<span className="text-slate-200 font-mono text-[11px] break-all">
{getUserEmail(p.ownedBy)}
</span>
</div>
<div className="flex items-center justify-end gap-1.5 pt-1.5 border-t border-slate-800/50">
<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"
>
<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>
)}
{/* Search Bar & Actions for Programmes */}
{activeTab === 'programmes' && (
<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 programmes by name, ID, parent portfolio, or owner..."
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>
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchProgrammes();
success('Refreshed', 'Programmes hierarchy refreshed from API.');
}}
disabled={isFetchingProgrammes}
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 shadow-xs disabled:opacity-50"
title="Force refresh programmes from API"
>
<RefreshCw className={`w-3.5 h-3.5 text-blue-400 ${isFetchingProgrammes ? 'animate-spin' : ''}`} />
<span>Refresh Programmes</span>
</button>
<ExportButton
claim="governance"
title="Strategic Programmes Hierarchy"
subtitle="Operational funding vehicles linked to funder portfolios"
filename={`pfm-programmes-${new Date().toISOString().split('T')[0]}`}
columns={programmeExportColumns}
data={filteredProgrammes}
/>
</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]">
<SortableTh column="name" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-5">
Programme Name
</SortableTh>
<SortableTh column="portfolioId" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Parent Portfolio
</SortableTh>
<SortableTh column="status" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Governance Status
</SortableTh>
<SortableTh column="createdAt" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Creation Date
</SortableTh>
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Owner Email
</SortableTh>
<SortableTh column="createdBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Created By
</SortableTh>
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{loadingProgrammes ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
Loading programmes...
</td>
</tr>
) : sortedProgrammes.length === 0 ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
No programmes found matching query.
</td>
</tr>
) : (
sortedProgrammes.map((pr) => {
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
const statusBadge = getApprovalStatusBadge(pr.status);
const isApproved = Number(pr.status) === ApprovalStatus.Approved;
return (
<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-4 font-mono text-slate-400 text-[11px]">
{getUserEmail(pr.createdBy)}
</td>
<td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-1.5 flex-wrap sm:flex-nowrap">
{isApproved ? (
<span
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default"
title="Approved programmes cannot be modified."
>
<Lock className="w-3 h-3 text-emerald-400" />
<span>Approved</span>
</span>
) : (
<>
<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"
title="Edit Programme"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
{canApproveRecord('governance', pr.createdBy) && (
<button
type="button"
disabled={approveProgrammeMutation.isPending}
onClick={() => approveProgrammeMutation.mutate(pr)}
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Approve Programme"
>
<CheckCircle className="w-3 h-3" />
<span>Approve</span>
</button>
)}
{canApprove('governance') && isMaker(pr.createdBy) && (
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
Maker-Checker: Record creator cannot approve
</span>
)}
</>
)}
<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"
>
<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={() => {
setSelectedProgrammeForRules(pr);
setIsRulesModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-amber-400 hover:text-white bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="View Programme Rules"
>
<Scale className="w-3 h-3" />
Rules
</button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
)}
{/* Tab 4: Funding Instruments */}
{(activeTab === 'instruments' || activeTab === 'instructions') && (
<FundingInstrumentsSection
programmes={programmes}
currentUserId={currentUserId}
preselectedProgrammeId={preselectedProgForInstruction}
onClearPreselectedProgramme={() => setPreselectedProgForInstruction(null)}
isCreateModalOpenExternal={isInstructionCreateTriggered}
onCloseCreateModalExternal={() => setIsInstructionCreateTriggered(false)}
/>
)}
{/* Tab 5: Programme Rules */}
{activeTab === 'rules' && (
<RulesSection
programmes={programmes}
currentUserId={currentUserId}
preselectedProgrammeId={preselectedProgForRule}
onClearPreselectedProgramme={() => setPreselectedProgForRule(null)}
isCreateModalOpenExternal={isRuleCreateTriggered}
onCloseCreateModalExternal={() => setIsRuleCreateTriggered(false)}
/>
)}
{/* 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>
<div>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1">
<label className="block text-xs font-semibold text-slate-300">
Linked Executive / Manager User *
</label>
<HelpTooltip
title="Designated Organisation User"
content="Only users with an Executive or Manager role can be assigned to an organisation."
size="sm"
placement="top"
/>
</div>
{!editingOrg && (
<button
type="button"
onClick={() => {
setIsQuickAddUserOpen((prev) => !prev);
setQuickUserError(null);
}}
className="text-[11px] font-semibold text-blue-400 hover:text-blue-300 inline-flex items-center gap-1 cursor-pointer transition-colors"
>
<UserPlus className="w-3.5 h-3.5" />
{isQuickAddUserOpen ? 'Cancel' : '+ New Executive'}
{isQuickAddUserOpen ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
)}
</div>
<select
{...orgForm.register('organisationUser')}
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"
>
<option value="">-- Select linked executive or manager account --</option>
{(editingOrg ? modalExecutiveUsers : executiveAndManagerUsers).map((u) => (
<option key={u.id} value={u.id}>
{u.email || u.id} ({u.role || 'Executive'})
</option>
))}
</select>
{orgForm.formState.errors.organisationUser && (
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.organisationUser.message}</p>
)}
{executiveAndManagerUsers.length === 0 && !isQuickAddUserOpen && (
<p className="text-[11px] text-amber-400 mt-1">
No Executive or Manager user found. Create one below to continue.
</p>
)}
</div>
</div>
{/* Inline Collapsible Subform to Create Executive User */}
{isQuickAddUserOpen && !editingOrg && (
<div className="p-3.5 rounded-xl border border-blue-500/30 bg-blue-950/20 space-y-3">
<div className="flex items-center justify-between border-b border-blue-500/20 pb-2">
<span className="text-xs font-semibold text-blue-300 flex items-center gap-1.5">
<UserPlus className="w-4 h-4 text-blue-400" />
Quick Create Executive / Manager User
</span>
<span className="text-[10px] text-slate-400">Mapped to tenant automatically</span>
</div>
{quickUserError && (
<div className="p-2 rounded bg-rose-500/10 border border-rose-500/20 text-rose-400 text-[11px]">
{quickUserError}
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<div>
<label className="block text-[11px] font-medium text-slate-300 mb-1">Email Address *</label>
<input
type="email"
value={quickUserEmail}
onChange={(e) => setQuickUserEmail(e.target.value)}
placeholder="executive@organisation.org.za"
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-slate-300 mb-1">Full Name / Username</label>
<input
type="text"
value={quickUserName}
onChange={(e) => setQuickUserName(e.target.value)}
placeholder="e.g. Tony Ngcobo"
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
<p className="text-[10px] text-slate-500 mt-0.5">Will be normalized to valid login username (e.g. tony.ngcobo)</p>
</div>
<div>
<label className="block text-[11px] font-medium text-slate-300 mb-1">Phone Number</label>
<input
type="text"
value={quickUserPhone}
onChange={(e) => setQuickUserPhone(e.target.value)}
placeholder="+27 82 123 4567"
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-[11px] font-medium text-slate-300 mb-1">Government Issued ID *</label>
<input
type="text"
value={quickUserIdNumber}
onChange={(e) => setQuickUserIdNumber(e.target.value)}
placeholder="13-digit ID or Passport"
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white font-mono focus:outline-none focus:ring-1 focus:ring-blue-500"
/>
</div>
<div className="sm:col-span-2">
<label className="block text-[11px] font-medium text-slate-300 mb-1">System Role *</label>
<select
value={quickUserRoleId}
onChange={(e) => setQuickUserRoleId(e.target.value)}
className="w-full px-2.5 py-1.5 text-xs border border-slate-700/80 rounded-lg bg-[#090d16] text-white focus:outline-none focus:ring-1 focus:ring-blue-500"
>
{assignableRolesForOrg.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
</div>
</div>
<div className="flex justify-end gap-2 pt-1">
<button
type="button"
onClick={() => setIsQuickAddUserOpen(false)}
className="px-2.5 py-1 text-xs text-slate-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="button"
onClick={handleQuickAddUser}
disabled={isSubmittingQuickUser}
className="px-3 py-1.5 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-lg transition-colors cursor-pointer flex items-center gap-1.5 disabled:opacity-50"
>
{isSubmittingQuickUser ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Creating...
</>
) : (
<>
<CheckCircle2 className="w-3.5 h-3.5" />
Create & Select User
</>
)}
</button>
</div>
</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 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="email"
list="portfolio-owner-email-suggestions"
{...portfolioForm.register('ownerEmail')}
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="e.g. khwezim@gmail.com"
/>
<datalist id="portfolio-owner-email-suggestions">
{usersDirectory.filter((u) => u.email).map((u) => (
<option key={u.id} value={u.email!}>
{u.name ? `${u.name} (${u.email})` : u.email!}
</option>
))}
</datalist>
{portfolioForm.formState.errors.ownerEmail && (
<p className="text-[10px] text-rose-500 mt-1">{portfolioForm.formState.errors.ownerEmail.message}</p>
)}
</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>
{editingProgramme && Number(editingProgramme.status) === ApprovalStatus.Approved ? (
<div
className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
title="Approved programmes cannot be modified."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved</span>
</div>
) : (
(() => {
const canApproveProgramme = canApproveRecord('governance', editingProgramme?.createdBy);
const isSelf = isMaker(editingProgramme?.createdBy);
return (
<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) => {
const isApprovedOpt = opt.value === ApprovalStatus.Approved;
const isOptDisabled = isApprovedOpt && !canApproveProgramme;
let reason = '';
if (isApprovedOpt && !canApprove('governance')) {
reason = ' (Requires Update Permission)';
} else if (isApprovedOpt && isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option key={opt.value} value={opt.value} disabled={isOptDisabled}>
{opt.label}{reason}
</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="email"
list="programme-owner-email-suggestions"
{...programmeForm.register('ownerEmail')}
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="e.g. khwezim@gmail.com"
/>
<datalist id="programme-owner-email-suggestions">
{usersDirectory.filter((u) => u.email).map((u) => (
<option key={u.id} value={u.email!}>
{u.name ? `${u.name} (${u.email})` : u.email!}
</option>
))}
</datalist>
{programmeForm.formState.errors.ownerEmail && (
<p className="text-[10px] text-rose-500 mt-1">{programmeForm.formState.errors.ownerEmail.message}</p>
)}
</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 flex items-center justify-center gap-2">
<RefreshCw className="w-4 h-4 animate-spin text-blue-400" />
<span>Loading funding instruments...</span>
</div>
) : programmeInstruments.length === 0 ? (
<div className="py-8 text-center text-xs text-slate-400 space-y-3">
<div className="text-slate-500">
No funding instruments registered under this programme yet.
</div>
<button
type="button"
onClick={() => {
const pId = selectedProgrammeForInstruments?.id;
setIsInstrumentsModalOpen(false);
setSelectedProgrammeForInstruments(null);
setPreselectedProgForInstruction(pId || null);
setIsInstructionCreateTriggered(true);
handleTabChange('instruments');
}}
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer shadow-md shadow-blue-500/20"
>
<Plus className="w-3.5 h-3.5" />
Add Instrument to this Programme
</button>
</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>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
<button
type="button"
onClick={() => {
const pId = selectedProgrammeForInstruments?.id;
setIsInstrumentsModalOpen(false);
setSelectedProgrammeForInstruments(null);
setPreselectedProgForInstruction(pId || null);
handleTabChange('instruments');
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 px-2 py-0.5 rounded cursor-pointer"
title="Manage in Funding Instruments Tab"
>
<Edit2 className="w-3 h-3" />
Manage
</button>
</div>
</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-between items-center">
<button
type="button"
onClick={() => {
const pId = selectedProgrammeForInstruments?.id;
setIsInstrumentsModalOpen(false);
setSelectedProgrammeForInstruments(null);
setPreselectedProgForInstruction(pId || null);
setIsInstructionCreateTriggered(true);
handleTabChange('instruments');
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
Add Funding Instrument
</button>
<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 Programme Rules */}
<Modal
isOpen={isRulesModalOpen}
onClose={() => {
setIsRulesModalOpen(false);
setSelectedProgrammeForRules(null);
}}
title={`Programme Rules: ${selectedProgrammeForRules?.name || ''}`}
subtitle={`Authoritative governance and statutory rules linked to Programme #${selectedProgrammeForRules?.id || ''}`}
>
<div className="space-y-4">
{loadingProgrammeRules ? (
<div className="py-8 text-center text-xs text-slate-400 flex items-center justify-center gap-2">
<RefreshCw className="w-4 h-4 animate-spin text-amber-400" />
<span>Loading programme rules...</span>
</div>
) : programmeRulesList.length === 0 ? (
<div className="py-8 text-center text-xs text-slate-400 space-y-3">
<div className="text-slate-500">
No programme rules registered under this programme yet.
</div>
<button
type="button"
onClick={() => {
const pId = selectedProgrammeForRules?.id;
setIsRulesModalOpen(false);
setSelectedProgrammeForRules(null);
setPreselectedProgForRule(pId || null);
setIsRuleCreateTriggered(true);
handleTabChange('rules');
}}
className="inline-flex items-center gap-1.5 px-3.5 py-2 text-xs font-semibold text-white bg-amber-600 hover:bg-amber-500 rounded-xl transition cursor-pointer shadow-md shadow-amber-500/20"
>
<Plus className="w-3.5 h-3.5" />
Add Rule to this Programme
</button>
</div>
) : (
<div className="space-y-2.5 max-h-[400px] overflow-y-auto">
{programmeRulesList.map((r: Rule) => {
const statusBadge = getActivityStatusBadge(r.status);
return (
<div
key={r.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-amber-400 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
v{r.version || 1}
</span>
<span className="font-semibold text-xs text-white">{r.name}</span>
</div>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
<button
type="button"
onClick={() => {
const pId = selectedProgrammeForRules?.id;
setIsRulesModalOpen(false);
setSelectedProgrammeForRules(null);
setPreselectedProgForRule(pId || null);
handleTabChange('rules');
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-slate-300 hover:text-white bg-slate-800 hover:bg-slate-700 border border-slate-700 px-2 py-0.5 rounded cursor-pointer"
title="Manage in Rules Tab"
>
<Edit2 className="w-3 h-3" />
Manage
</button>
</div>
</div>
{r.description && (
<p className="text-xs text-slate-400 leading-relaxed">{r.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(r.createdBy)}</span>
<span>{formatDate(r.createdAt)}</span>
</div>
</div>
);
})}
</div>
)}
<div className="pt-2 border-t border-slate-800 flex justify-between items-center">
<button
type="button"
onClick={() => {
const pId = selectedProgrammeForRules?.id;
setIsRulesModalOpen(false);
setSelectedProgrammeForRules(null);
setPreselectedProgForRule(pId || null);
setIsRuleCreateTriggered(true);
handleTabChange('rules');
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-amber-400 hover:text-white bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/30 rounded-xl transition cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
Add Rule
</button>
<button
type="button"
onClick={() => {
setIsRulesModalOpen(false);
setSelectedProgrammeForRules(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 flex-col sm:flex-row sm:items-center justify-between gap-3"
>
<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} &bull; Owner: {getUserEmail(pr.ownedBy)} &bull; {formatDate(pr.createdAt)}
</span>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
{statusBadge.label}
</span>
<button
type="button"
onClick={() => {
setIsPortfolioProgrammesModalOpen(false);
setSelectedProgrammeForDetail(pr);
setIsProgrammeDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-cyan-400 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 px-2 py-1 rounded-lg cursor-pointer"
title="Inspect Programme"
>
<Eye className="w-3 h-3" />
Inspect
</button>
<button
type="button"
onClick={() => {
setIsPortfolioProgrammesModalOpen(false);
setSelectedProgrammeForInstruments(pr);
setIsInstrumentsModalOpen(true);
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2 py-1 rounded-lg cursor-pointer"
title="View Funding Instruments"
>
<Coins className="w-3 h-3" />
Instruments
</button>
<button
type="button"
onClick={() => {
setIsPortfolioProgrammesModalOpen(false);
setSelectedProgrammeForRules(pr);
setIsRulesModalOpen(true);
}}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-amber-400 hover:text-white bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/30 px-2 py-1 rounded-lg cursor-pointer"
title="View Programme Rules"
>
<Scale className="w-3 h-3" />
Rules
</button>
</div>
</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 & Linked 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 organisation details...
</div>
) : (
<>
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="flex items-center gap-2 min-w-0">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Legal Name:</span>
<span className="text-white font-medium truncate">{orgFreshDetail?.name || selectedOrgForDetail?.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Reg Number:</span>
<span className="font-mono text-slate-300">{orgFreshDetail?.registrationNo || selectedOrgForDetail?.registrationNo || '—'}</span>
</div>
<div className="flex items-center gap-2 min-w-0">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Official Email:</span>
<span className="font-mono text-slate-300 truncate">{orgFreshDetail?.email || selectedOrgForDetail?.email}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Entity Type:</span>
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
</div>
<div className="flex items-center gap-2 min-w-0">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Linked User:</span>
<span className="text-blue-400 font-mono text-[11px] truncate">
{getUserEmail(orgFreshDetail?.userId || selectedOrgForDetail?.userId) || orgFreshDetail?.userId || selectedOrgForDetail?.userId || '—'}
</span>
</div>
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
<h4 className="font-semibold text-slate-300 text-xs">Linked Portfolios</h4>
<span className="text-[10px] font-mono text-slate-400 bg-slate-800/80 border border-slate-700/60 px-2 py-0.5 rounded-full">
{orgPortfolios.length} linked
</span>
</div>
{orgPortfolios.length > 0 && (
<button
type="button"
onClick={() => handleNavigateToPortfolioTab()}
className="inline-flex items-center gap-1 text-[11px] font-semibold text-blue-400 hover:text-blue-300 transition cursor-pointer"
>
<span>View Portfolios tab</span>
<ArrowRight className="w-3 h-3" />
</button>
)}
</div>
{loadingOrgPortfolios ? (
<p className="text-xs text-slate-500 py-3 text-center">Loading portfolios...</p>
) : orgPortfolios.length === 0 ? (
<div className="p-3.5 bg-slate-900/40 rounded-xl border border-slate-800/80 text-center">
<p className="text-xs text-slate-400">No portfolios directly linked to this organisation.</p>
</div>
) : (
<div className="space-y-2 max-h-[220px] overflow-y-auto pr-0.5">
{orgPortfolios.map((port) => (
<div
key={port.id}
onClick={() => handleNavigateToPortfolioTab(port.id)}
className="p-3 bg-slate-900/60 hover:bg-slate-900 rounded-xl border border-slate-800 hover:border-blue-500/40 flex items-center justify-between gap-3 transition cursor-pointer group"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-white font-medium block group-hover:text-blue-300 transition-colors truncate">
{port.name}
</span>
<span className="text-[10px] text-blue-400 bg-blue-500/10 px-1.5 py-0.5 rounded border border-blue-500/20 font-mono shrink-0">
Portfolio #{port.id}
</span>
</div>
{port.description && (
<p className="text-[11px] text-slate-400 truncate mt-0.5">{port.description}</p>
)}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleNavigateToPortfolioTab(port.id);
}}
className="inline-flex items-center gap-1 text-[11px] 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 shrink-0 cursor-pointer"
>
<span>Open</span>
<ArrowRight className="w-3 h-3 group-hover:translate-x-0.5 transition-transform" />
</button>
</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 */}
<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 portfolio details...
</div>
) : (
<div className="space-y-3.5">
{/* Prominent Hero Card: Total Portfolio Budget Value */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-blue-950/80 via-[#0d172a] to-indigo-950/80 border border-blue-500/40 p-4 sm:p-5 shadow-xl">
<div className="absolute top-0 right-0 -mr-6 -mt-6 w-32 h-32 bg-blue-500/15 rounded-full blur-2xl pointer-events-none" />
<div className="flex items-center justify-between gap-3 mb-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-xl bg-blue-500/20 border border-blue-500/40 flex items-center justify-center shrink-0">
<Coins className="w-4 h-4 text-blue-400" />
</div>
<div>
<span className="text-[10px] font-bold uppercase tracking-wider text-blue-400 block">
Portfolio Total Budget Value
</span>
<span className="text-[11px] text-slate-400">
Aggregated budget across all linked strategic programmes
</span>
</div>
</div>
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded-full bg-blue-500/20 text-blue-300 border border-blue-500/30">
Total Value
</span>
</div>
<div className="mt-3 flex items-baseline gap-2">
{loadingPortfolioValue ? (
<div className="flex items-center gap-2 text-blue-300 py-1">
<RefreshCw className="w-4 h-4 animate-spin text-blue-400" />
<span className="font-mono text-xs">Calculating portfolio budget value...</span>
</div>
) : (
<>
<span className="text-2xl sm:text-3xl font-extrabold font-mono tracking-tight text-white drop-shadow-sm">
{formatZAR(portfolioValue)}
</span>
<span className="text-xs font-semibold text-blue-400 font-mono">
ZAR
</span>
</>
)}
</div>
</div>
{/* Portfolio Master Info Card */}
<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 tracking-wider">Portfolio Title:</span>
<span className="text-white font-semibold text-sm block mt-0.5">{portfolioFreshDetail?.name || selectedPortfolioForDetail?.name}</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider">Description:</span>
<p className="text-slate-300 mt-1 leading-relaxed">{portfolioFreshDetail?.description || selectedPortfolioForDetail?.description || 'None provided'}</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-3 border-t border-slate-800/80 items-center">
<div className="flex items-center gap-2 min-w-0">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Owner:</span>
<span className="font-mono text-slate-300 text-xs truncate" title={getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}>
{getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}
</span>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Created:</span>
<span className="font-mono text-slate-300 text-xs">
{formatDate(portfolioFreshDetail?.createdAt || selectedPortfolioForDetail?.createdAt || '')}
</span>
</div>
</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 */}
<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 programme details...
</div>
) : (
<div className="space-y-3.5">
{/* Prominent Hero Card: Total Programme Budget Value */}
<div className="relative overflow-hidden rounded-2xl bg-gradient-to-br from-emerald-950/80 via-[#0a1b24] to-teal-950/80 border border-emerald-500/40 p-4 sm:p-5 shadow-xl">
<div className="absolute top-0 right-0 -mr-6 -mt-6 w-32 h-32 bg-emerald-500/15 rounded-full blur-2xl pointer-events-none" />
<div className="flex items-center justify-between gap-3 mb-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-xl bg-emerald-500/20 border border-emerald-500/40 flex items-center justify-center shrink-0">
<Coins className="w-4 h-4 text-emerald-400" />
</div>
<div>
<span className="text-[10px] font-bold uppercase tracking-wider text-emerald-400 block">
Programme Total Budget Value
</span>
<span className="text-[11px] text-slate-400">
Calculated across all constituent project budgets
</span>
</div>
</div>
<span className="text-[10px] font-mono font-semibold px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
Total Value
</span>
</div>
<div className="mt-3 flex items-baseline gap-2">
{loadingProgrammeValue ? (
<div className="flex items-center gap-2 text-emerald-300 py-1">
<RefreshCw className="w-4 h-4 animate-spin text-emerald-400" />
<span className="font-mono text-xs">Calculating programme budget value...</span>
</div>
) : (
<>
<span className="text-2xl sm:text-3xl font-extrabold font-mono tracking-tight text-white drop-shadow-sm">
{formatZAR(programmeValue)}
</span>
<span className="text-xs font-semibold text-emerald-400 font-mono">
ZAR
</span>
</>
)}
</div>
</div>
{/* Programme Details Card */}
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800/80 space-y-3.5">
<div className="pb-3 border-b border-slate-800/80 flex items-start justify-between gap-4">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Title</span>
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
</div>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
</span>
{selectedProgrammeForDetail && Number(programmeFreshDetail?.status ?? selectedProgrammeForDetail.status) !== ApprovalStatus.Approved && (
canApproveRecord('governance', selectedProgrammeForDetail.createdBy) ? (
<button
type="button"
disabled={approveProgrammeMutation.isPending}
onClick={async () => {
await approveProgrammeMutation.mutateAsync(selectedProgrammeForDetail);
setIsProgrammeDetailModalOpen(false);
setSelectedProgrammeForDetail(null);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<CheckCircle className="w-3.5 h-3.5" />
<span>Approve Programme</span>
</button>
) : (
isMaker(selectedProgrammeForDetail.createdBy) && canApprove('governance') && (
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
Maker-Checker: Record creator cannot approve
</span>
)
)
)}
</div>
</div>
{/* Core Details Grid: Owner, Maker, Creation Date */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
{/* Creation Date */}
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
<div>
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Creation Date</span>
<span className="text-slate-200 font-mono text-xs font-semibold">
{formatDate(programmeFreshDetail?.createdAt || selectedProgrammeForDetail?.createdAt)}
</span>
</div>
</div>
{/* Owner */}
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
<Mail className="w-4 h-4 text-slate-400 shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Programme Lead</span>
<span className="font-mono text-slate-200 text-xs truncate block" title={getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}>
{getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}
</span>
</div>
</div>
{/* Maker */}
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
<ShieldCheck className="w-4 h-4 text-slate-400 shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Created By (Maker)</span>
<span className="font-mono text-slate-200 text-xs truncate block" title={getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}>
{getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}
</span>
</div>
</div>
</div>
{/* Metrics: Total Rules and Total Instructions */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 pt-2">
{/* Total Rules */}
<div className="p-3 bg-gradient-to-br from-amber-500/10 to-amber-500/5 rounded-xl border border-amber-500/20 flex flex-col justify-between">
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] font-semibold text-amber-300 flex items-center gap-1.5">
<Scale className="w-3.5 h-3.5 text-amber-400" />
Total Rules
</span>
<span className="text-lg font-bold font-mono text-amber-400">
{loadingDetailRules ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
programmeDetailRules.length
)}
</span>
</div>
<button
type="button"
onClick={() => {
const pId = selectedProgrammeForDetail?.id;
setIsProgrammeDetailModalOpen(false);
setSelectedProgrammeForDetail(null);
setPreselectedProgForRule(pId || null);
handleTabChange('rules');
}}
className="text-[11px] font-semibold text-amber-400 hover:text-amber-300 inline-flex items-center gap-1 mt-1 transition cursor-pointer self-start"
>
View in Rules Tab &rarr;
</button>
</div>
{/* Total Funding Instruments */}
<div className="p-3 bg-gradient-to-br from-blue-500/10 to-blue-500/5 rounded-xl border border-blue-500/20 flex flex-col justify-between">
<div className="flex items-center justify-between mb-1">
<span className="text-[11px] font-semibold text-blue-300 flex items-center gap-1.5">
<Coins className="w-3.5 h-3.5 text-blue-400" />
Total Funding Instruments
</span>
<span className="text-lg font-bold font-mono text-blue-400">
{loadingDetailInstructions ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
programmeDetailInstructions.length
)}
</span>
</div>
<button
type="button"
onClick={() => {
const pId = selectedProgrammeForDetail?.id;
setIsProgrammeDetailModalOpen(false);
setSelectedProgrammeForDetail(null);
setPreselectedProgForInstruction(pId || null);
handleTabChange('instruments');
}}
className="text-[11px] font-semibold text-blue-400 hover:text-blue-300 inline-flex items-center gap-1 mt-1 transition cursor-pointer self-start"
>
View in Funding Instruments Tab &rarr;
</button>
</div>
</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>
);
};