Remediated test #1 results

This commit is contained in:
2026-09-20 13:51:01 +02:00
parent 5829bffaa0
commit d428f6e17c
16 changed files with 8367 additions and 758 deletions
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
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';
@@ -16,6 +17,7 @@ import {
Coins,
Eye,
RefreshCw,
ArrowRight,
} from 'lucide-react';
import {
getOrganisations,
@@ -34,6 +36,8 @@ import {
updateProgramme,
getInstruments,
getPortfolioProgrammes,
getUsers,
getUser,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
@@ -58,6 +62,8 @@ import {
getApprovalStatusBadge,
formatDate,
getUserEmail,
registerUserEmail,
getUserIdByEmail,
ORGANISATION_TYPE_OPTIONS,
ACTIVITY_STATUS_OPTIONS,
APPROVAL_STATUS_OPTIONS,
@@ -76,14 +82,14 @@ const portfolioSchema = z.object({
name: z.string().min(3, 'Portfolio name is required'),
description: z.string().min(5, 'Description is required'),
organisationId: z.coerce.number().min(1, 'Select a parent organisation'),
ownerId: z.string().min(1, 'Owner ID is required'),
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(),
ownerId: z.string().min(1, 'Owner ID is required'),
ownerEmail: z.string().min(1, 'Owner email is required'),
});
type OrganisationFormValues = z.infer<typeof organisationSchema>;
@@ -92,11 +98,72 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
export const GovernancePage: React.FC = () => {
const queryClient = useQueryClient();
const { sub, user } = useAuth();
const { sub, user, claims } = useAuth();
const currentUserId = sub || user?.id || '';
const { success, error: toastError } = useToast();
const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes'>('organisations');
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' => {
if (val === 'portfolios' || val === 'programmes' || val === 'organisations') {
return val;
}
return 'organisations';
};
const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes'>(() => getValidTab(tabParam));
useEffect(() => {
if (tabParam && (tabParam === 'organisations' || tabParam === 'portfolios' || tabParam === 'programmes')) {
setActiveTab(tabParam);
}
}, [tabParam]);
const handleTabChange = (tab: 'organisations' | 'portfolios' | 'programmes') => {
setActiveTab(tab);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('tab', tab);
return next;
});
};
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);
@@ -125,6 +192,12 @@ export const GovernancePage: React.FC = () => {
const [isProgrammeDetailModalOpen, setIsProgrammeDetailModalOpen] = useState(false);
// Queries
const { data: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
const { data: organisations = [], isLoading: loadingOrgs } = useQuery({
queryKey: ['organisations'],
queryFn: () => getOrganisations(),
@@ -182,6 +255,92 @@ export const GovernancePage: React.FC = () => {
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) as any,
@@ -200,7 +359,7 @@ export const GovernancePage: React.FC = () => {
name: '',
description: '',
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
ownerId: currentUserId,
ownerEmail: effectiveUserEmail,
},
});
@@ -210,7 +369,7 @@ export const GovernancePage: React.FC = () => {
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved,
ownerId: currentUserId,
ownerEmail: effectiveUserEmail,
},
});
@@ -243,16 +402,22 @@ export const GovernancePage: React.FC = () => {
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: data.ownerId,
ownerId: resolvedOwnerId,
});
}
return createPortfolio(data as CreatePortfolioRequest);
return createPortfolio({
name: data.name,
description: data.description,
organisationId: Number(data.organisationId),
ownerId: resolvedOwnerId,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
@@ -268,16 +433,21 @@ export const GovernancePage: React.FC = () => {
const programmeMutation = useMutation({
mutationFn: (data: ProgrammeFormValues) => {
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
if (editingProgramme) {
return updateProgramme({
programmeId: editingProgramme.id,
name: data.name,
portfolioId: Number(data.portfolioId),
status: data.status !== undefined ? Number(data.status) : undefined,
ownerId: data.ownerId,
ownerId: resolvedOwnerId,
});
}
return createProgramme(data as CreateProgrammeRequest);
return createProgramme({
name: data.name,
portfolioId: Number(data.portfolioId),
ownerId: resolvedOwnerId,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['programmes'] });
@@ -321,18 +491,19 @@ export const GovernancePage: React.FC = () => {
name: '',
description: '',
organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101,
ownerId: currentUserId,
ownerEmail: effectiveUserEmail,
});
setIsPortfolioModalOpen(true);
};
const openEditPortfolio = (p: Portfolio) => {
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,
ownerId: p.ownedBy || currentUserId,
ownerEmail: email,
});
setIsPortfolioModalOpen(true);
};
@@ -343,18 +514,19 @@ export const GovernancePage: React.FC = () => {
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved,
ownerId: currentUserId,
ownerEmail: effectiveUserEmail,
});
setIsProgrammeModalOpen(true);
};
const openEditProgramme = (pr: Programme) => {
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,
ownerId: pr.ownedBy || currentUserId,
ownerEmail: email,
});
setIsProgrammeModalOpen(true);
};
@@ -425,7 +597,7 @@ export const GovernancePage: React.FC = () => {
{/* Tabs */}
<div className="flex border-b border-slate-800/80 bg-[#0d121f]/80 px-4 rounded-2xl backdrop-blur-sm">
<button
onClick={() => setActiveTab('organisations')}
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 ${
activeTab === 'organisations'
? 'border-blue-500 text-blue-400'
@@ -442,7 +614,7 @@ export const GovernancePage: React.FC = () => {
/>
</button>
<button
onClick={() => setActiveTab('portfolios')}
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 ${
activeTab === 'portfolios'
? 'border-blue-500 text-blue-400'
@@ -459,7 +631,7 @@ export const GovernancePage: React.FC = () => {
/>
</button>
<button
onClick={() => setActiveTab('programmes')}
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 ${
activeTab === 'programmes'
? 'border-blue-500 text-blue-400'
@@ -568,7 +740,7 @@ export const GovernancePage: React.FC = () => {
setIsOrgDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
title="Inspect Organisation (GET /v1/api/governance/organisations/{id})"
title="Inspect Organisation"
>
<Eye className="w-3 h-3" />
Inspect
@@ -601,8 +773,17 @@ export const GovernancePage: React.FC = () => {
portfolios.map((p) => {
const org = organisations.find((o) => String(o.id) === String(p.organisationId));
const progCount = programmes.filter((pr) => String(pr.portfolioId) === String(p.id)).length;
const isHighlighted = String(highlightedPortfolioId) === String(p.id);
return (
<div key={p.id} className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 shadow-lg backdrop-blur-sm flex flex-col justify-between hover:border-slate-700/80 transition-all">
<div
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">
@@ -614,48 +795,53 @@ export const GovernancePage: React.FC = () => {
<p className="text-xs text-slate-400 leading-relaxed">{p.description}</p>
</div>
<div className="mt-4 pt-3 border-t border-slate-800/80 space-y-2">
<div className="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-[170px] font-medium text-slate-300">{org ? org.name : `Org #${p.organisationId}`}</span>
<span className="font-mono text-[11px] text-slate-500">{formatDate(p.createdAt)}</span>
</div>
<div className="flex items-center justify-between pt-1 text-xs">
<span className="text-slate-400 font-medium truncate max-w-[190px]">
Owner: <span className="text-slate-200 font-mono text-[11px]">{getUserEmail(p.ownedBy)}</span>
<span className="truncate max-w-[200px] font-medium text-slate-300" title={org ? org.name : `Org #${p.organisationId}`}>
{org ? org.name : `Org #${p.organisationId}`}
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => {
setSelectedPortfolioForDetail(p);
setIsPortfolioDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-cyan-400 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Inspect Portfolio (GET /v1/api/governance/portfolios/{id})"
>
<Eye className="w-3 h-3" />
Inspect
</button>
<button
type="button"
onClick={() => {
setSelectedPortfolioForProgrammes(p);
setIsPortfolioProgrammesModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<Layers className="w-3 h-3" />
Programmes
</button>
<button
type="button"
onClick={() => openEditPortfolio(p)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</div>
<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>
@@ -718,7 +904,7 @@ export const GovernancePage: React.FC = () => {
setIsProgrammeDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-cyan-400 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Inspect Programme (GET /v1/api/projects/programme/{id})"
title="Inspect Programme"
>
<Eye className="w-3 h-3" />
Inspect
@@ -972,11 +1158,22 @@ export const GovernancePage: React.FC = () => {
/>
</div>
<input
type="text"
{...portfolioForm.register('ownerId')}
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="khwezim@gmail.com"
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>
@@ -1111,11 +1308,22 @@ export const GovernancePage: React.FC = () => {
/>
</div>
<input
type="text"
{...programmeForm.register('ownerId')}
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="khwezim@gmail.com"
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>
@@ -1260,7 +1468,7 @@ export const GovernancePage: React.FC = () => {
</div>
</Modal>
{/* Modal: View Organisation Details (GET /v1/api/governance/organisations/{id} & /portfolio) */}
{/* Modal: View Organisation Details & Linked Portfolio */}
<Modal
isOpen={isOrgDetailModalOpen}
onClose={() => {
@@ -1274,51 +1482,89 @@ export const GovernancePage: React.FC = () => {
{loadingOrgFreshDetail ? (
<div className="py-8 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Fetching canonical record (GET /v1/api/governance/organisations/{selectedOrgForDetail?.id})...
Fetching organisation details...
</div>
) : (
<>
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-2">
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Legal Name:</span>
<span className="text-white font-medium">{orgFreshDetail?.name || selectedOrgForDetail?.name}</span>
<div 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>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Reg Number:</span>
<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>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Official Email:</span>
<span className="font-mono text-slate-300">{orgFreshDetail?.email || selectedOrgForDetail?.email}</span>
<div 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>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity Type:</span>
<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>
</div>
<div>
<h4 className="font-semibold text-slate-300 text-xs mb-2 flex items-center justify-between">
<span>Linked Portfolios (GET /v1/api/governance/organisations/{selectedOrgForDetail?.id}/portfolio)</span>
<span className="text-[10px] font-mono text-slate-400">{orgPortfolios.length} linked</span>
</h4>
<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">Loading portfolios...</p>
<p className="text-xs text-slate-500 py-3 text-center">Loading portfolios...</p>
) : orgPortfolios.length === 0 ? (
<p className="text-xs text-slate-500">No portfolios directly linked to this organisation.</p>
<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">
<div className="space-y-2 max-h-[220px] overflow-y-auto pr-0.5">
{orgPortfolios.map((port) => (
<div key={port.id} className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 flex items-center justify-between">
<div>
<span className="text-white font-medium block">{port.name}</span>
<span className="text-[10px] text-slate-500 font-mono">ID: {port.id}</span>
<div
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>
<span className="text-[10px] text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded border border-blue-500/20 font-mono">
Portfolio
</span>
<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>
@@ -1342,7 +1588,7 @@ export const GovernancePage: React.FC = () => {
</div>
</Modal>
{/* Modal: View Portfolio Details (GET /v1/api/governance/portfolios/{id}) */}
{/* Modal: View Portfolio Details */}
<Modal
isOpen={isPortfolioDetailModalOpen}
onClose={() => {
@@ -1356,7 +1602,7 @@ export const GovernancePage: React.FC = () => {
{loadingPortfolioFreshDetail ? (
<div className="py-8 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Fetching canonical record (GET /v1/api/governance/portfolios/{selectedPortfolioForDetail?.id})...
Fetching portfolio details...
</div>
) : (
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
@@ -1368,14 +1614,16 @@ export const GovernancePage: React.FC = () => {
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Description:</span>
<p className="text-slate-300 mt-0.5 leading-relaxed">{portfolioFreshDetail?.description || selectedPortfolioForDetail?.description || 'None provided'}</p>
</div>
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-slate-800/80">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Owner:</span>
<span className="font-mono text-slate-300">{getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}</span>
<div 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>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Created:</span>
<span className="font-mono text-slate-300">
<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>
@@ -1398,7 +1646,7 @@ export const GovernancePage: React.FC = () => {
</div>
</Modal>
{/* Modal: View Programme Details (GET /v1/api/projects/programme/{id}) */}
{/* Modal: View Programme Details */}
<Modal
isOpen={isProgrammeDetailModalOpen}
onClose={() => {
@@ -1412,24 +1660,26 @@ export const GovernancePage: React.FC = () => {
{loadingProgrammeFreshDetail ? (
<div className="py-8 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Fetching canonical record (GET /v1/api/projects/programme/{selectedProgrammeForDetail?.id})...
Fetching programme details...
</div>
) : (
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Programme Name:</span>
<span className="text-white font-medium text-sm">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
<div 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">
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Name</span>
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Approval Status:</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 items-center">
<div className="flex items-center gap-2">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Approval Status:</span>
<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>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Owner:</span>
<span className="font-mono text-slate-300">{getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}</span>
<div 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(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}>
{getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}
</span>
</div>
</div>
</div>