Remediated test #1 results
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user