Fixed partnerui hashing and over all theming

This commit is contained in:
2026-10-25 19:44:46 +02:00
parent 0bcc3484dc
commit 5ac317cbe3
11 changed files with 341 additions and 273 deletions
@@ -51,8 +51,10 @@ public sealed partial class HashService(IHashids hasher) : IService
int[] decoded = hasher.Decode(hash);
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Int hash token."))
.ToResult(decoded[0]);
if (decoded.Length <= 0)
return Result.Fail<int>(ValidationError.Create("Invalid or modified Int hash token."));
return Result.Ok(decoded[0]);
}
public Result<long> DecodeLongIdHash(string hash)
@@ -62,8 +64,10 @@ public sealed partial class HashService(IHashids hasher) : IService
long[] decoded = hasher.DecodeLong(hash);
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Long hash token."))
.ToResult(decoded[0]);
if (decoded.Length <= 0)
return Result.Fail<long>(ValidationError.Create("Invalid or modified Long hash token."));
return Result.Ok(decoded[0]);
}
public async Task<Result<string>> DecodeHexHash(string hex) =>
@@ -30,7 +30,7 @@ OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__Cli
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
# 6. Hasher Settings
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}}"
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}"
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==}}}}"
if [ -d "$TARGET_DIR" ]; then
+4 -4
View File
@@ -1,9 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
# HasherSettings for HashIds compatibility with backend pfm-api
VITE_HASHER_MIN_LENGTH="11"
VITE_HASHER_SALT="TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=="
@@ -29,17 +29,15 @@ OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__Cli
# 5. OIDC Scope
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
# 6. Hasher Settings
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
# 6. Hasher Settings (Fix: Exactly 4 closing braces matching the 4 open variable expansions)
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}"
if [ -d "$TARGET_DIR" ]; then
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
# Generate env-config.js for instant, synchronous window.__RUNTIME_CONFIG__ initialization
# Public SPA clients use Authorization Code flow with PKCE; client secrets and hasher salts must NEVER be injected or exposed here.
# Public SPA clients: salt secrets and client secrets must NEVER be injected or exposed in public static files.
cat <<EOF > "$TARGET_DIR/env-config.js"
// Generated dynamically at container startup from deployment environment variables
// Note: Secrets such as client secrets, private keys, or hashing salts must NEVER be exposed here.
window.__RUNTIME_CONFIG__ = {
API_BASE_URL: "${API_BASE_URL}",
VITE_API_BASE_URL: "${API_BASE_URL}",
@@ -10,6 +10,6 @@
"BaseUrl": ""
},
"HasherSettings": {
"MinHashLength": ""
"MinHashLength": "11"
}
}
@@ -1 +1,9 @@
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
// Runtime configuration for development or container environments
// Note: Secret values (like hashing salt or client secrets) are not exposed here.
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {
HASHER_MIN_LENGTH: "11",
VITE_HASHER_MIN_LENGTH: "11",
HasherSettings: {
MinHashLength: "11"
}
};
@@ -63,6 +63,7 @@ import {
import { formatZAR, formatCompactZAR } from '../../lib/formatters';
import { MessageViewer } from '../notifications/MessageViewer';
import { stripMarkdown } from '../../lib/markdownUtils';
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
import { ProjectCard, EnrichedPartnerProject } from './ProjectCard';
import { EvidenceUploadModal } from './EvidenceUploadModal';
@@ -499,20 +500,25 @@ export function DashboardPage() {
];
return (
<div className="min-h-screen w-full bg-[#0F0E17] text-[#F1F1F6] flex flex-col selection:bg-[#8B5CF6]/30 selection:text-[#F1F1F6]">
<div className="relative min-h-screen w-full bg-slate-950 text-slate-100 flex flex-col selection:bg-emerald-500/30 selection:text-slate-100">
{/* Ambient background glow from landing page */}
<div className="pointer-events-none fixed inset-0 z-0 bg-[radial-gradient(ellipse_80%_80%_at_50%_-20%,rgba(16,185,129,0.08),rgba(2,6,23,0))]" />
{/* =====================================================================
TOP NAVIGATION BAR — 72PX, FULL-WIDTH EDGE-TO-EDGE
===================================================================== */}
<header className="sticky top-0 z-30 w-full bg-[#0F0E17]/95 backdrop-blur-md border-b border-white/[0.06] h-[72px] flex items-center transition-all">
<header className="sticky top-0 z-30 w-full bg-slate-950/85 backdrop-blur-md border-b border-white/[0.08] h-[72px] flex items-center transition-all">
<div className="max-w-[1440px] mx-auto px-6 h-full flex items-center justify-between gap-4 w-full">
{/* Left: Brand Glyph & Horizontal Navigation Links */}
<div className="flex items-center gap-6 sm:gap-10 h-full">
<div
className="w-8 h-8 rounded-lg bg-white flex items-center justify-center shrink-0 cursor-pointer shadow-sm hover:opacity-90 transition-opacity"
className="flex items-center gap-2.5 cursor-pointer group shrink-0"
onClick={() => setActiveTab('dashboard')}
title="Dashboard Home"
>
<InfinityIcon className="w-5 h-5 text-black -rotate-[30deg]" strokeWidth={2.5} />
<PfmPartnerLogo className="h-8 w-8 drop-shadow-[0_2px_10px_rgba(16,185,129,0.25)] transition-transform group-hover:scale-105 duration-200" />
<span className="text-base sm:text-lg font-black tracking-tight bg-gradient-to-r from-emerald-400 via-teal-200 to-cyan-300 bg-clip-text text-transparent hidden sm:inline">
PFM Partner
</span>
</div>
{/* Nav Tabs */}
@@ -526,13 +532,13 @@ export function DashboardPage() {
onClick={() => setActiveTab(tab.id)}
className={`relative h-full flex items-center text-sm transition-colors cursor-pointer ${
isActive
? 'font-bold text-[#F1F1F6]'
: 'font-medium text-[#9A95B0] hover:text-[#F1F1F6]'
? 'font-bold text-slate-100'
: 'font-medium text-slate-400 hover:text-slate-100'
}`}
>
<span>{tab.label}</span>
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-[3px] bg-[#8B5CF6] rounded-t-[2px]" />
<span className="absolute bottom-0 left-0 right-0 h-[2.5px] bg-gradient-to-r from-emerald-400 to-teal-400 rounded-t-[2px] shadow-[0_0_12px_rgba(16,185,129,0.6)]" />
)}
</button>
);
@@ -544,19 +550,19 @@ export function DashboardPage() {
<div className="flex items-center gap-3 sm:gap-4">
{/* Search Input */}
<div className="relative hidden sm:block w-48 md:w-64">
<Search className="w-4 h-4 text-[#9A95B0] absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
<Search className="w-4 h-4 text-slate-400 absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
type="text"
placeholder="Search projects..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="w-full bg-[#181622] border border-white/[0.06] focus:border-[#8B5CF6]/50 rounded-xl pl-9 pr-3 py-1.5 text-xs text-[#F1F1F6] placeholder-[#9A95B0] focus:outline-none transition-colors"
className="w-full bg-slate-900/80 border border-white/10 focus:border-emerald-500/50 rounded-xl pl-9 pr-3 py-1.5 text-xs text-slate-100 placeholder-slate-400 focus:outline-none transition-colors"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-[#9A95B0] hover:text-white"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
>
<X className="w-3.5 h-3.5" />
</button>
@@ -569,9 +575,9 @@ export function DashboardPage() {
onClick={() => refetchApplicantData()}
title="Refresh projects"
disabled={isRefreshingApplicantData}
className="p-2 rounded-xl bg-[#181622] border border-white/[0.06] text-[#9A95B0] hover:text-[#F1F1F6] transition-colors cursor-pointer disabled:opacity-50"
className="p-2 rounded-xl bg-slate-900/80 border border-white/10 text-slate-400 hover:text-emerald-400 hover:border-emerald-500/30 transition-colors cursor-pointer disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${isRefreshingApplicantData ? 'animate-spin text-[#8B5CF6]' : ''}`} />
<RefreshCw className={`w-4 h-4 ${isRefreshingApplicantData ? 'animate-spin text-emerald-400' : ''}`} />
</button>
{/* Notifications Bell */}
@@ -582,12 +588,12 @@ export function DashboardPage() {
setShowBellDropdown(!showBellDropdown);
setShowUserDropdown(false);
}}
className="relative p-2 rounded-xl bg-[#181622] border border-white/[0.06] text-[#9A95B0] hover:text-[#F1F1F6] transition-colors cursor-pointer"
className="relative p-2 rounded-xl bg-slate-900/80 border border-white/10 text-slate-400 hover:text-emerald-400 hover:border-emerald-500/30 transition-colors cursor-pointer"
title="Notifications"
>
<Bell className="w-4 h-4" />
{unreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex h-4 min-w-[16px] px-1 items-center justify-center rounded-full bg-[#8B5CF6] text-white text-[10px] font-bold ring-2 ring-[#0F0E17]">
<span className="absolute -top-1 -right-1 flex h-4 min-w-[16px] px-1 items-center justify-center rounded-full bg-emerald-500 text-slate-950 text-[10px] font-bold ring-2 ring-slate-950">
{unreadCount > 9 ? '9+' : unreadCount}
</span>
)}
@@ -595,14 +601,14 @@ export function DashboardPage() {
{/* Bell Dropdown */}
{showBellDropdown && (
<div className="absolute right-0 mt-2 w-80 rounded-2xl bg-[#181622] border border-white/[0.08] shadow-2xl py-3 z-50">
<div className="absolute right-0 mt-2 w-80 rounded-2xl bg-slate-900/95 border border-white/10 shadow-2xl backdrop-blur-xl py-3 z-50">
<div className="px-4 pb-2 border-b border-white/[0.06] flex items-center justify-between">
<span className="text-xs font-semibold text-[#F1F1F6]">Notifications</span>
<span className="text-xs font-semibold text-slate-100">Notifications</span>
{unreadCount > 0 && (
<button
type="button"
onClick={() => markAllAsRead()}
className="text-[11px] text-[#8B5CF6] hover:underline cursor-pointer"
className="text-[11px] text-emerald-400 hover:underline cursor-pointer"
>
Mark all as read
</button>
@@ -610,7 +616,7 @@ export function DashboardPage() {
</div>
<div className="max-h-72 overflow-y-auto divide-y divide-white/[0.04]">
{top5Unread.length === 0 ? (
<div className="p-4 text-center text-xs text-[#9A95B0]">No unread notifications</div>
<div className="p-4 text-center text-xs text-slate-400">No unread notifications</div>
) : (
top5Unread.map(item => (
<div
@@ -622,8 +628,8 @@ export function DashboardPage() {
}}
className="p-3 hover:bg-white/[0.03] transition-colors cursor-pointer text-left"
>
<p className="text-xs font-semibold text-[#F1F1F6] truncate">{item.subject}</p>
<p className="text-[11px] text-[#9A95B0] line-clamp-2 mt-0.5">{stripMarkdown(item.message)}</p>
<p className="text-xs font-semibold text-slate-100 truncate">{item.subject}</p>
<p className="text-[11px] text-slate-400 line-clamp-2 mt-0.5">{stripMarkdown(item.message)}</p>
</div>
))
)}
@@ -635,7 +641,7 @@ export function DashboardPage() {
setActiveTab('messages');
setShowBellDropdown(false);
}}
className="text-xs text-[#8B5CF6] hover:underline font-medium cursor-pointer"
className="text-xs text-emerald-400 hover:underline font-medium cursor-pointer"
>
View all messages
</button>
@@ -652,22 +658,22 @@ export function DashboardPage() {
setShowUserDropdown(!showUserDropdown);
setShowBellDropdown(false);
}}
className="flex items-center gap-2 p-1.5 pl-2 rounded-xl bg-[#181622] border border-white/[0.06] hover:border-white/[0.12] transition-colors cursor-pointer"
className="flex items-center gap-2 p-1.5 pl-2 rounded-xl bg-slate-900/80 border border-white/[0.06] hover:border-white/[0.12] transition-colors cursor-pointer"
>
<div className="w-6 h-6 rounded-lg bg-gradient-to-tr from-[#8B5CF6] to-indigo-600 flex items-center justify-center text-white text-[11px] font-bold">
<div className="w-6 h-6 rounded-lg bg-gradient-to-tr from-emerald-600 via-teal-600 to-cyan-600 flex items-center justify-center text-white text-[11px] font-bold">
{getInitials(user?.profile?.name || user?.profile?.email || 'Partner')}
</div>
<span className="text-xs font-medium text-[#F1F1F6] hidden md:inline max-w-[120px] truncate">
<span className="text-xs font-medium text-slate-100 hidden md:inline max-w-[120px] truncate">
{user?.profile?.name || 'Partner Account'}
</span>
<ChevronDown className="w-3.5 h-3.5 text-[#9A95B0]" />
<ChevronDown className="w-3.5 h-3.5 text-slate-400" />
</button>
{showUserDropdown && (
<div className="absolute right-0 mt-2 w-56 rounded-2xl bg-[#181622] border border-white/[0.08] shadow-2xl p-2 z-50 text-xs">
<div className="absolute right-0 mt-2 w-56 rounded-2xl bg-slate-900/95 border border-white/10 shadow-2xl backdrop-blur-xl p-2 z-50 text-xs">
<div className="px-3 py-2 border-b border-white/[0.06]">
<p className="font-semibold text-[#F1F1F6] truncate">{user?.profile?.name || 'Partner User'}</p>
<p className="text-[11px] text-[#9A95B0] truncate">{user?.profile?.email || ''}</p>
<p className="font-semibold text-slate-100 truncate">{user?.profile?.name || 'Partner User'}</p>
<p className="text-[11px] text-slate-400 truncate">{user?.profile?.email || ''}</p>
{partnerSummary.primaryInvitedOrg && (
<p className="text-[10px] text-emerald-400 mt-1 truncate">
{partnerSummary.primaryInvitedOrg.name}
@@ -696,8 +702,8 @@ export function DashboardPage() {
<main className="flex-1 w-full max-w-[1440px] mx-auto px-6 py-8">
{isLoadingApplicantData ? (
<div className="py-24 flex flex-col items-center justify-center space-y-4">
<RefreshCw className="w-8 h-8 text-[#8B5CF6] animate-spin" />
<p className="text-sm text-[#9A95B0]">Loading your funding portfolio & projects...</p>
<RefreshCw className="w-8 h-8 text-emerald-400 animate-spin" />
<p className="text-sm text-slate-400">Loading your funding portfolio & projects...</p>
</div>
) : activeTab === 'dashboard' ? (
/* ===================================================================
@@ -707,10 +713,10 @@ export function DashboardPage() {
{/* Top Bar: Title & Sort Options */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h2 className="text-[20px] leading-[28px] font-bold text-[#F1F1F6]">
<h2 className="text-[20px] leading-[28px] font-bold text-slate-100">
Ongoing Projects
</h2>
<p className="text-xs text-[#9A95B0] mt-0.5">
<p className="text-xs text-slate-400 mt-0.5">
Click &ldquo;View Details&rdquo; on any project to inspect its milestones, track progress, and fulfill activity evidence requirements.
</p>
</div>
@@ -720,14 +726,14 @@ export function DashboardPage() {
<select
value={sortBy}
onChange={e => setSortBy(e.target.value as SortOption)}
className="appearance-none bg-[#181622] border border-white/[0.06] rounded-xl px-3 pr-8 py-2 text-xs font-medium text-[#F1F1F6] focus:outline-none cursor-pointer transition-colors"
className="appearance-none bg-slate-900/80 border border-white/[0.06] rounded-xl px-3 pr-8 py-2 text-xs font-medium text-slate-100 focus:outline-none cursor-pointer transition-colors"
>
<option value="date_added" className="bg-[#181622] text-[#F1F1F6]">Sort by date added</option>
<option value="grant_amount" className="bg-[#181622] text-[#F1F1F6]">Sort by award value</option>
<option value="days_left" className="bg-[#181622] text-[#F1F1F6]">Sort by days remaining</option>
<option value="name" className="bg-[#181622] text-[#F1F1F6]">Sort by project name</option>
<option value="date_added" className="bg-slate-900/80 text-slate-100">Sort by date added</option>
<option value="grant_amount" className="bg-slate-900/80 text-slate-100">Sort by award value</option>
<option value="days_left" className="bg-slate-900/80 text-slate-100">Sort by days remaining</option>
<option value="name" className="bg-slate-900/80 text-slate-100">Sort by project name</option>
</select>
<ChevronDown className="w-3.5 h-3.5 text-[#9A95B0] absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
<ChevronDown className="w-3.5 h-3.5 text-slate-400 absolute right-2.5 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
</div>
@@ -747,18 +753,18 @@ export function DashboardPage() {
))
) : (
/* Empty State */
<div className="rounded-[16px] bg-[#181622] border border-white/[0.06] p-8 sm:p-12 shadow-sm flex flex-col items-center justify-center text-center relative overflow-hidden">
<div className="rounded-[16px] bg-slate-900/80 border border-white/[0.06] p-8 sm:p-12 shadow-sm flex flex-col items-center justify-center text-center relative overflow-hidden">
<div className="relative mb-5">
<div className="w-14 h-14 rounded-2xl bg-[#8B5CF6]/15 border border-[#8B5CF6]/25 text-[#8B5CF6] flex items-center justify-center shadow-lg shadow-[#8B5CF6]/10">
<div className="w-14 h-14 rounded-2xl bg-emerald-500/15 border border-emerald-500/25 text-emerald-400 flex items-center justify-center shadow-lg shadow-emerald-500/10">
<Sparkles className="w-7 h-7" />
</div>
</div>
<h3 className="text-[20px] font-bold text-[#F1F1F6] tracking-tight">
<h3 className="text-[20px] font-bold text-slate-100 tracking-tight">
{searchQuery ? 'No Matching Projects Found' : 'No Active Projects Assigned Yet'}
</h3>
<p className="text-[14px] leading-[22px] font-normal text-[#9A95B0] max-w-lg mt-2">
<p className="text-[14px] leading-[22px] font-normal text-slate-400 max-w-lg mt-2">
{searchQuery
? `We couldn't find any ongoing projects matching "${searchQuery}". Clear your search query to see your full workspace portfolio.`
: "Welcome to your Partner Workspace! Your funding organisation hasn't assigned any active project records to your profile just yet. If your funding award was recently approved or updated, click below to sync and retrieve your latest project schedule."}
@@ -769,7 +775,7 @@ export function DashboardPage() {
<button
type="button"
onClick={() => setSearchQuery('')}
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-[#F1F1F6] text-xs font-semibold transition-all cursor-pointer"
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-slate-100 text-xs font-semibold transition-all cursor-pointer"
>
<X className="w-4 h-4" />
<span>Clear Search</span>
@@ -780,7 +786,7 @@ export function DashboardPage() {
type="button"
onClick={() => refetchApplicantData()}
disabled={isRefreshingApplicantData}
className="inline-flex items-center gap-2.5 px-6 py-2.5 rounded-xl bg-[#8B5CF6] hover:bg-[#7C3AED] text-white font-semibold text-xs tracking-wide shadow-lg shadow-[#8B5CF6]/25 transition-all cursor-pointer disabled:opacity-60"
className="inline-flex items-center gap-2.5 px-6 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-xs tracking-wide shadow-lg shadow-emerald-500/25 transition-all cursor-pointer disabled:opacity-60"
>
<RefreshCw className={`w-4 h-4 ${isRefreshingApplicantData ? 'animate-spin' : ''}`} />
<span>{isRefreshingApplicantData ? 'Refreshing Workspace...' : 'Refresh Projects'}</span>
@@ -797,8 +803,8 @@ export function DashboardPage() {
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Messages & Communications</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5">
<h1 className="text-[20px] font-bold text-slate-100">Messages & Communications</h1>
<p className="text-[12px] text-slate-400 mt-0.5">
Direct communication with your funding programme officers.
</p>
</div>
@@ -812,16 +818,16 @@ export function DashboardPage() {
setMessageSubject('');
setMessageBody('');
}}
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-[#8B5CF6] hover:bg-[#7C3AED] text-white font-semibold text-xs shadow-lg shadow-[#8B5CF6]/20 transition-all cursor-pointer"
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-xs shadow-lg shadow-emerald-500/20 transition-all cursor-pointer"
>
<Plus className="w-4 h-4" />
<span>New Message</span>
</button>
</div>
<div className="rounded-[16px] bg-[#181622] border border-white/[0.06] divide-y divide-white/[0.06] overflow-hidden">
<div className="rounded-[16px] bg-slate-900/80 border border-white/[0.06] divide-y divide-white/[0.06] overflow-hidden">
{inboxNotifications.length === 0 ? (
<div className="p-12 text-center text-[#9A95B0] text-sm">
<div className="p-12 text-center text-slate-400 text-sm">
No new messages in your inbox yet. Messages sent by programme managers will be displayed here.
</div>
) : (
@@ -837,12 +843,12 @@ export function DashboardPage() {
className="p-5 hover:bg-white/[0.03] transition-colors flex items-start justify-between gap-4 cursor-pointer"
>
<div className="flex items-start gap-4 min-w-0">
<div className="h-10 w-10 rounded-full bg-[#8B5CF6]/15 border border-[#8B5CF6]/30 text-[#8B5CF6] font-bold text-xs flex items-center justify-center shrink-0">
<div className="h-10 w-10 rounded-full bg-emerald-500/15 border border-[#8B5CF6]/30 text-emerald-400 font-bold text-xs flex items-center justify-center shrink-0">
{getInitials(msg.subject || 'PFM')}
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-[#F1F1F6] truncate">
<h3 className="text-sm font-semibold text-slate-100 truncate">
{msg.subject || 'Programme Notification'}
</h3>
{isUnread && (
@@ -851,10 +857,10 @@ export function DashboardPage() {
</span>
)}
</div>
<p className="text-xs text-[#9A95B0] mt-1 line-clamp-2">{stripMarkdown(msg.message || '')}</p>
<p className="text-xs text-slate-400 mt-1 line-clamp-2">{stripMarkdown(msg.message || '')}</p>
</div>
</div>
<span className="text-xs text-[#9A95B0]/70 shrink-0">
<span className="text-xs text-slate-400/70 shrink-0">
{formatFullDate(msg.createdAt)}
</span>
</div>
@@ -869,15 +875,15 @@ export function DashboardPage() {
=================================================================== */
<div className="space-y-6">
<div>
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Contracts & Approved Awards</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5">
<h1 className="text-[20px] font-bold text-slate-100">Contracts & Approved Awards</h1>
<p className="text-[12px] text-slate-400 mt-0.5">
Review your approved funding awards and download or sign grant agreements.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{partnerSummary.contractEntries.length === 0 ? (
<div className="col-span-full rounded-[16px] bg-[#181622] border border-white/[0.06] p-10 text-center text-sm text-[#9A95B0]">
<div className="col-span-full rounded-[16px] bg-slate-900/80 border border-white/[0.06] p-10 text-center text-sm text-slate-400">
No contracts have been issued to your organisation yet.
</div>
) : (
@@ -887,11 +893,11 @@ export function DashboardPage() {
return (
<div
key={contract.id}
className="rounded-[16px] bg-[#181622] border border-white/[0.06] p-6 flex flex-col justify-between gap-5"
className="rounded-[16px] bg-slate-900/80 border border-white/[0.06] p-6 flex flex-col justify-between gap-5"
>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<span className="text-xs font-semibold text-[#8B5CF6] uppercase tracking-wider">
<span className="text-xs font-semibold text-emerald-400 uppercase tracking-wider">
Agreement v{contractVer}
</span>
<span
@@ -904,18 +910,18 @@ export function DashboardPage() {
{isExecuted ? 'Signed & Executed' : 'Action Required: Review & Sign'}
</span>
</div>
<h3 className="text-lg font-bold text-[#F1F1F6]">
<h3 className="text-lg font-bold text-slate-100">
{fundingOrganisation?.name || 'Grant Funding Agreement'}
</h3>
<div className="grid grid-cols-2 gap-4 pt-2 text-xs">
<div>
<p className="text-[#9A95B0]">Contract Value</p>
<p className="text-sm font-bold text-[#F1F1F6] mt-0.5">
<p className="text-slate-400">Contract Value</p>
<p className="text-sm font-bold text-slate-100 mt-0.5">
{formatZAR(contract.totalValue || award?.amount || 0)}
</p>
</div>
<div>
<p className="text-[#9A95B0]">Valid Period</p>
<p className="text-slate-400">Valid Period</p>
<p className="text-[#D1CBDD] font-medium mt-0.5">
{formatShortDate(contract.effectiveAt)} – {formatFullDate(contract.expiresAt)}
</p>
@@ -927,7 +933,7 @@ export function DashboardPage() {
<button
type="button"
onClick={() => handleOpenSecureFile('contract', contract.id)}
className="flex-1 py-2.5 px-4 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-[#F1F1F6] text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer"
className="flex-1 py-2.5 px-4 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-slate-100 text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>Download PDF</span>
@@ -937,7 +943,7 @@ export function DashboardPage() {
type="button"
onClick={() => signContractMutation.mutate(contract)}
disabled={signContractMutation.isPending}
className="flex-1 py-2.5 px-4 rounded-xl bg-[#8B5CF6] hover:bg-[#7C3AED] text-white text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer disabled:opacity-50"
className="flex-1 py-2.5 px-4 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold flex items-center justify-center gap-2 transition-colors cursor-pointer disabled:opacity-50"
>
<CheckCircle2 className="w-3.5 h-3.5" />
<span>{signContractMutation.isPending ? 'Signing...' : 'Accept & Sign'}</span>
@@ -956,43 +962,43 @@ export function DashboardPage() {
=================================================================== */
<div className="space-y-6">
<div>
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Portfolio Analytics</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5">
<h1 className="text-[20px] font-bold text-slate-100">Portfolio Analytics</h1>
<p className="text-[12px] text-slate-400 mt-0.5">
Real-time performance metrics and deliverable fulfillment rates across your assigned projects.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{/* Card 1: Milestone Fulfillment Rate */}
<div className="p-6 rounded-[16px] bg-[#181622] border border-white/[0.06]">
<div className="flex items-center justify-between text-[#8B5CF6] mb-2">
<div className="p-6 rounded-[16px] bg-slate-900/80 border border-white/[0.06]">
<div className="flex items-center justify-between text-emerald-400 mb-2">
<TrendingUp className="w-5 h-5" />
<span className="text-xs font-semibold text-emerald-400">
{portfolioStats.completedMilestones} of {portfolioStats.totalMilestones} Milestones
</span>
</div>
<p className="text-2xl font-bold text-[#F1F1F6]">{portfolioStats.milestoneRate}%</p>
<p className="text-xs text-[#9A95B0] mt-1">Milestone Completion Rate</p>
<p className="text-2xl font-bold text-slate-100">{portfolioStats.milestoneRate}%</p>
<p className="text-xs text-slate-400 mt-1">Milestone Completion Rate</p>
</div>
{/* Card 2: Active Projects */}
<div className="p-6 rounded-[16px] bg-[#181622] border border-white/[0.06]">
<div className="p-6 rounded-[16px] bg-slate-900/80 border border-white/[0.06]">
<div className="flex items-center justify-between text-teal-400 mb-2">
<Briefcase className="w-5 h-5" />
<span className="text-xs font-semibold text-[#8B5CF6]">Active</span>
<span className="text-xs font-semibold text-emerald-400">Active</span>
</div>
<p className="text-2xl font-bold text-[#F1F1F6]">{portfolioStats.totalProjects} Projects</p>
<p className="text-xs text-[#9A95B0] mt-1">Assigned Funded Projects</p>
<p className="text-2xl font-bold text-slate-100">{portfolioStats.totalProjects} Projects</p>
<p className="text-xs text-slate-400 mt-1">Assigned Funded Projects</p>
</div>
{/* Card 3: Total Supporting Proofs */}
<div className="p-6 rounded-[16px] bg-[#181622] border border-white/[0.06]">
<div className="p-6 rounded-[16px] bg-slate-900/80 border border-white/[0.06]">
<div className="flex items-center justify-between text-amber-400 mb-2">
<AwardIcon className="w-5 h-5" />
<span className="text-xs font-semibold text-amber-400">Audited</span>
</div>
<p className="text-2xl font-bold text-[#F1F1F6]">{portfolioStats.totalEvidenceCount} Documents</p>
<p className="text-xs text-[#9A95B0] mt-1">Uploaded Evidence Deliverables</p>
<p className="text-2xl font-bold text-slate-100">{portfolioStats.totalEvidenceCount} Documents</p>
<p className="text-xs text-slate-400 mt-1">Uploaded Evidence Deliverables</p>
</div>
</div>
</div>
@@ -1002,16 +1008,16 @@ export function DashboardPage() {
=================================================================== */
<div className="space-y-6">
<div>
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Deliverables & Evidence Documents</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5">
<h1 className="text-[20px] font-bold text-slate-100">Deliverables & Evidence Documents</h1>
<p className="text-[12px] text-slate-400 mt-0.5">
Invoices, reports, and verified deliverable proofs submitted across projects.
</p>
</div>
<div className="rounded-[16px] bg-[#181622] border border-white/[0.06] overflow-hidden">
<div className="rounded-[16px] bg-slate-900/80 border border-white/[0.06] overflow-hidden">
{enrichedProjects.flatMap(p => p.evidence.map(ev => ({ ev, project: p.project }))).length === 0 ? (
<div className="p-12 text-center space-y-3">
<p className="text-sm text-[#9A95B0]">
<p className="text-sm text-slate-400">
No documents uploaded yet. Expand any project on your Dashboard to upload proof files.
</p>
</div>
@@ -1028,17 +1034,17 @@ export function DashboardPage() {
className="p-4 sm:p-5 flex items-center justify-between gap-4 hover:bg-white/[0.02]"
>
<div className="flex items-center gap-3.5 min-w-0">
<div className="p-2.5 rounded-xl bg-[#8B5CF6]/10 text-[#8B5CF6]">
<div className="p-2.5 rounded-xl bg-[#8B5CF6]/10 text-emerald-400">
<FileText className="w-5 h-5" />
</div>
<div className="min-w-0">
<p className="text-sm font-semibold text-[#F1F1F6] truncate">
<p className="text-sm font-semibold text-slate-100 truncate">
{EVIDENCE_TYPE_LABELS[evType] || 'Supporting Document'}{' '}
<span className="text-xs font-normal text-[#9A95B0]">
<span className="text-xs font-normal text-slate-400">
(v{ev.version || 1})
</span>
</p>
<p className="text-xs text-[#9A95B0] truncate mt-0.5">
<p className="text-xs text-slate-400 truncate mt-0.5">
Project: {project.name} • Submitted {formatFullDate(evDate)}
</p>
</div>
@@ -1046,7 +1052,7 @@ export function DashboardPage() {
<button
type="button"
onClick={() => handleOpenSecureFile('evidence', ev.id)}
className="px-3.5 py-2 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-xs font-semibold text-[#8B5CF6] flex items-center gap-1.5 shrink-0 cursor-pointer"
className="px-3.5 py-2 rounded-xl bg-white/[0.06] hover:bg-white/[0.1] text-xs font-semibold text-emerald-400 flex items-center gap-1.5 shrink-0 cursor-pointer"
>
<ExternalLink className="w-3.5 h-3.5" />
<span>View File</span>
@@ -1177,7 +1183,7 @@ export function DashboardPage() {
</div>
<div className="space-y-3 text-xs">
<div className="flex items-center justify-between text-[#9A95B0]">
<div className="flex items-center justify-between text-slate-400">
<span>From: {selectedNotification.destination?.split('|')[0] || 'Programme Management Office'}</span>
<span>{formatFullDate(selectedNotification.createdAt)}</span>
</div>
@@ -18,7 +18,11 @@ import {
getProjectEvidence,
submitActivityForApproval
} from '../../services/api';
import { getEvidenceTypeLabel } from '../../lib/formatters';
import {
getEvidenceTypeLabel,
getIndustrySectorLabel,
getProjectTypeLabel
} from '../../lib/formatters';
import { EvidenceUploadModal } from './EvidenceUploadModal';
import {
FolderKanban,
@@ -76,7 +80,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
const [submittingActivityId, setSubmittingActivityId] = useState<string | number | null>(null);
// Evidence upload modal state (Requirement #3: Allow user to add evidence, once uploaded refresh data)
// Evidence upload modal state
const [uploadTarget, setUploadTarget] = useState<{
project: Project;
milestone?: Milestone | null;
@@ -120,7 +124,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
}
};
// Date & Time formatter (Requirement #2: sorted by time, displaying time)
// Date & Time formatter
const formatDateTime = (dateStr?: string | null): string => {
if (!dateStr) return 'TBD';
try {
@@ -131,12 +135,12 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
}
};
// Status Badge Helper
// Status Badge Helper using Landing Page emerald/teal/amber color palette
const renderStatusBadge = (status?: any, fallback = 'Active') => {
const num = Number(status);
if (num === ApprovalStatus.Approved || String(status).toLowerCase().includes('approved')) {
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-950/80 border border-emerald-500/30 text-emerald-300">
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-emerald-950/80 border border-emerald-500/40 text-emerald-300 shadow-sm shadow-emerald-950/50">
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
<span>Approved</span>
</span>
@@ -144,23 +148,21 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
}
if (num === ApprovalStatus.Review || String(status).toLowerCase().includes('review')) {
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-amber-950/80 border border-amber-500/30 text-amber-300">
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-amber-950/80 border border-amber-500/40 text-amber-300 shadow-sm shadow-amber-950/50">
<Clock className="w-3 h-3 text-amber-400" />
<span>Under Review</span>
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-slate-800/80 border border-slate-700 text-slate-300">
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-teal-950/80 border border-teal-500/40 text-teal-300 shadow-sm shadow-teal-950/50">
<span className="w-1.5 h-1.5 rounded-full bg-teal-400 animate-pulse" />
<span>{String(status || fallback)}</span>
</span>
);
};
// =========================================================================
// ON-DEMAND FETCH: WHEN "View details" IS CLICKED, FETCH USING projectId ON HAND
// (Followup Requirement #1 & #2)
// =========================================================================
// On-demand fetch when expanded
const {
data: projectDetails,
isLoading: isLoadingDetails,
@@ -195,12 +197,11 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
staleTime: 1000 * 20,
});
// Effective data sources (prioritize freshly fetched projectDetails, fallback to item arrays)
const effectiveMilestones: Milestone[] = projectDetails?.milestones ?? (item.milestones?.length ? item.milestones : []);
const effectiveActivities: Activity[] = projectDetails?.activities ?? (item.activities?.length ? item.activities : []);
const effectiveEvidence: Evidence[] = projectDetails?.evidence ?? (item.evidence?.length ? item.evidence : []);
// 1. Sort Milestones according to Start Date (or createdAt) then Due Date (Requirement #5)
// Sort Milestones according to Start Date then Due Date
const sortedMilestones = [...effectiveMilestones].sort((a, b) => {
const startA = new Date(a.createdAt || a.dueAt || 0).getTime();
const startB = new Date(b.createdAt || b.dueAt || 0).getTime();
@@ -208,7 +209,6 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
return new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime();
});
// 2. Fetch project evidence, list existing evidence under matching activity by activityId sorted by time (Requirement #2)
const getEvidenceForActivity = (activityId: string | number): Evidence[] => {
const targetNum = toIntegerId(activityId);
return effectiveEvidence
@@ -216,7 +216,6 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
.sort((e1, e2) => new Date(e2.createdAt || 0).getTime() - new Date(e1.createdAt || 0).getTime());
};
// 3. Nested activities matching MilestoneId sorted by date (Requirement #6)
const getActivitiesForMilestone = (milestoneId: string | number): Activity[] => {
const targetMilestoneNum = toIntegerId(milestoneId);
return effectiveActivities
@@ -228,7 +227,6 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
});
};
// Unassigned activities
const unassignedActivities = effectiveActivities
.filter(a => !a.milestoneId || toIntegerId(a.milestoneId) === 0)
.sort((a, b) => {
@@ -237,7 +235,6 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
return dateA - dateB;
});
// Evidence fulfillment progress
const totalActivitiesCount = effectiveActivities.length;
const fulfilledActivitiesCount = effectiveActivities.filter(a => {
const evs = getEvidenceForActivity(a.id);
@@ -248,7 +245,6 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
? Math.round((fulfilledActivitiesCount / totalActivitiesCount) * 100)
: (effectiveMilestones.length > 0 ? 0 : 100);
// Handle submit activity for approval
const handleSubmitActivity = async (activity: Activity, milestone?: Milestone | null) => {
const evs = getEvidenceForActivity(activity.id);
setSubmittingActivityId(activity.id);
@@ -262,32 +258,38 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
}
};
// Enum String Descriptions (Requirement #3)
const sectorDescription = getIndustrySectorLabel(project.sector);
const projectTypeDescription = getProjectTypeLabel(project.projectType);
return (
<div className="w-full rounded-[16px] bg-[#181622] border border-white/[0.06] hover:border-white/[0.12] transition-all p-6 sm:p-7 shadow-sm">
<div className="w-full rounded-2xl bg-slate-900/70 border border-white/10 hover:border-emerald-500/30 transition-all p-6 sm:p-7 shadow-xl shadow-black/20 backdrop-blur-xl">
{/* =========================================================================
TOP PORTION: FULL-WIDTH HEADER, METADATA FROM APPLICANT DATA & METRICS
TOP PORTION: FULL-WIDTH HEADER, METADATA & METRICS (Landing Page Theme)
========================================================================= */}
<div className="space-y-5">
{/* Row 1: Badges & Project Title */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-2 flex-wrap">
<div className="p-2 rounded-xl bg-purple-500/10 border border-purple-500/20 text-purple-400">
<div className="p-2 rounded-xl bg-emerald-500/10 border border-emerald-500/20 text-emerald-400">
<FolderKanban className="w-4 h-4" />
</div>
{hashedProjectId && (
<span className="font-mono text-xs font-bold text-purple-300 bg-purple-950/70 border border-purple-500/30 px-2.5 py-0.5 rounded-md">
<span className="font-mono text-xs font-bold text-emerald-300 bg-emerald-950/80 border border-emerald-500/30 px-2.5 py-0.5 rounded-md">
#{hashedProjectId}
</span>
)}
{renderStatusBadge(project.status, 'Active Implementation')}
{project.sector && (
<span className="text-[11px] font-medium text-slate-300 bg-white/5 border border-white/10 px-2 py-0.5 rounded-md">
Sector: {project.sector}
{/* Requirement #3: Display enum string description instead of integer value */}
{sectorDescription && (
<span className="text-[11px] font-medium text-emerald-300 bg-emerald-950/60 border border-emerald-500/30 px-2.5 py-0.5 rounded-md">
Sector: {sectorDescription}
</span>
)}
{project.projectType && (
<span className="text-[11px] font-medium text-slate-300 bg-white/5 border border-white/10 px-2 py-0.5 rounded-md">
Type: {project.projectType}
{projectTypeDescription && (
<span className="text-[11px] font-medium text-teal-300 bg-teal-950/60 border border-teal-500/30 px-2.5 py-0.5 rounded-md">
Type: {projectTypeDescription}
</span>
)}
</div>
@@ -296,32 +298,32 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<button
type="button"
onClick={toggleExpand}
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-[#8B5CF6]/15 hover:bg-[#8B5CF6]/25 border border-[#8B5CF6]/30 text-[#F1F1F6] text-xs font-semibold transition-all cursor-pointer shadow-sm hover:shadow-[0_0_15px_rgba(139,92,246,0.25)] shrink-0 active:scale-95"
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl bg-emerald-500/15 hover:bg-emerald-500/25 border border-emerald-500/30 text-emerald-300 hover:text-emerald-200 text-xs font-semibold transition-all cursor-pointer shadow-sm hover:shadow-[0_0_15px_rgba(16,185,129,0.25)] shrink-0 active:scale-95"
>
<span>{isExpanded ? 'Hide Details' : 'View Details'}</span>
{isExpanded ? <ChevronUp className="w-4 h-4 text-[#8B5CF6]" /> : <ChevronDown className="w-4 h-4 text-[#8B5CF6]" />}
{isExpanded ? <ChevronUp className="w-4 h-4 text-emerald-400" /> : <ChevronDown className="w-4 h-4 text-emerald-400" />}
</button>
</div>
{/* Row 2: Title and Description */}
<div>
<h3 className="text-xl font-bold text-[#F1F1F6] tracking-tight">
<h3 className="text-xl font-bold text-slate-100 tracking-tight">
{project.name || `Project #${hashedProjectId}`}
</h3>
{project.description && (
<p className="text-[14px] leading-[22px] text-[#9A95B0] mt-1.5 max-w-4xl">
<p className="text-[14px] leading-[22px] text-slate-300 mt-1.5 max-w-4xl">
{project.description}
</p>
)}
</div>
{/* Row 3: Rest of Applicant Data (Funder, Recipient Org, Linked Contract & Award) */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 py-3 px-4 rounded-xl bg-white/[0.02] border border-white/5 text-xs">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3 py-3 px-4 rounded-xl bg-slate-950/60 border border-white/5 text-xs">
{/* Funder */}
<div className="flex items-center gap-2.5">
<Landmark className="w-4 h-4 text-purple-400 shrink-0" />
<Landmark className="w-4 h-4 text-emerald-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-[#9A95B0] uppercase font-bold block">Funding Organisation</span>
<span className="text-[10px] text-slate-400 uppercase font-bold block">Funding Organisation</span>
<span className="text-slate-200 font-medium truncate block">
{fundingOrganisation?.name || 'Programme Office'}
</span>
@@ -330,9 +332,9 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
{/* Recipient Organisation */}
<div className="flex items-center gap-2.5">
<Building className="w-4 h-4 text-emerald-400 shrink-0" />
<Building className="w-4 h-4 text-teal-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-[#9A95B0] uppercase font-bold block">Partner Recipient</span>
<span className="text-[10px] text-slate-400 uppercase font-bold block">Partner Recipient</span>
<span className="text-slate-200 font-medium truncate block">
{invitedOrganisation?.name || 'Assigned Partner'}
</span>
@@ -343,7 +345,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<div className="flex items-center gap-2.5">
<FileText className="w-4 h-4 text-cyan-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-[#9A95B0] uppercase font-bold block">Grant Agreement</span>
<span className="text-[10px] text-slate-400 uppercase font-bold block">Grant Agreement</span>
{contract ? (
<button
type="button"
@@ -364,7 +366,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<div className="flex items-center gap-2.5">
<AwardIcon className="w-4 h-4 text-amber-400 shrink-0" />
<div className="min-w-0">
<span className="text-[10px] text-[#9A95B0] uppercase font-bold block">Funding Award</span>
<span className="text-[10px] text-slate-400 uppercase font-bold block">Funding Award</span>
<span className="text-amber-300 font-medium font-mono truncate block">
{award ? `Award #${encodeHashIdSync(award.id)}` : 'Approved Award'} ({formatZAR(grantAmount)})
</span>
@@ -376,60 +378,60 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<div className="flex flex-wrap items-center justify-between gap-4 pt-2 border-t border-white/5 text-xs">
<div className="flex flex-wrap items-center gap-5 sm:gap-7">
{/* Total Award */}
<div className="flex items-center gap-1.5 font-medium text-[#D1CBDD]">
<span className="p-1 rounded-md bg-purple-500/10 text-purple-400 flex items-center justify-center">
<div className="flex items-center gap-1.5 font-medium text-slate-300">
<span className="p-1 rounded-md bg-emerald-500/10 text-emerald-400 flex items-center justify-center">
<DollarSign className="w-3.5 h-3.5" />
</span>
<span className="font-bold text-[#F1F1F6] font-mono">{formatZAR(grantAmount)}</span>
<span className="text-[#9A95B0]">Award</span>
<span className="font-bold text-slate-100 font-mono">{formatZAR(grantAmount)}</span>
<span className="text-slate-400">Award</span>
</div>
{/* Spend Disbursed */}
<div className="flex items-center gap-1.5 font-medium text-[#D1CBDD]">
<span className="p-1 rounded-md bg-[#F97316]/10 text-[#F97316] flex items-center justify-center">
<div className="flex items-center gap-1.5 font-medium text-slate-300">
<span className="p-1 rounded-md bg-teal-500/10 text-teal-400 flex items-center justify-center">
<DollarSign className="w-3.5 h-3.5" />
</span>
<span className="font-bold text-[#F1F1F6] font-mono">{formatZAR(disbursedAmount)}</span>
<span className="text-[#9A95B0]">Disbursed</span>
<span className="font-bold text-slate-100 font-mono">{formatZAR(disbursedAmount)}</span>
<span className="text-slate-400">Disbursed</span>
</div>
{/* Timeline */}
<div className="flex items-center gap-1.5 font-medium text-[#D1CBDD]">
<span className="p-1 rounded-md bg-amber-500/10 text-amber-400 flex items-center justify-center">
<div className="flex items-center gap-1.5 font-medium text-slate-300">
<span className="p-1 rounded-md bg-cyan-500/10 text-cyan-400 flex items-center justify-center">
<Calendar className="w-3.5 h-3.5" />
</span>
<span className="font-medium text-[#F1F1F6]">
<span className="font-medium text-slate-200">
{formatDate(project.startedAt || contract?.effectiveAt)} – {formatDate(project.endedAt || contract?.expiresAt)}
</span>
<span className="text-[#9A95B0]">({daysLeft})</span>
<span className="text-slate-400">({daysLeft})</span>
</div>
{/* Deliverables Count */}
<div className="flex items-center gap-1.5 font-medium text-[#D1CBDD]">
<span className="p-1 rounded-md bg-teal-500/10 text-teal-400 flex items-center justify-center">
<div className="flex items-center gap-1.5 font-medium text-slate-300">
<span className="p-1 rounded-md bg-slate-800 text-slate-300 flex items-center justify-center">
<Layers className="w-3.5 h-3.5" />
</span>
<span className="font-semibold text-[#F1F1F6]">{effectiveMilestones.length} Milestones</span>
<span className="text-[#9A95B0]">• {totalActivitiesCount} Activities</span>
<span className="font-semibold text-slate-100">{effectiveMilestones.length} Milestones</span>
<span className="text-slate-400">• {totalActivitiesCount} Activities</span>
</div>
{/* Docs Submitted */}
<div className="flex items-center gap-1.5 font-medium text-[#D1CBDD]">
<span className="p-1 rounded-md bg-cyan-500/10 text-cyan-400 flex items-center justify-center">
<div className="flex items-center gap-1.5 font-medium text-slate-300">
<span className="p-1 rounded-md bg-emerald-500/10 text-emerald-400 flex items-center justify-center">
<Paperclip className="w-3.5 h-3.5" />
</span>
<span className="font-bold text-[#F1F1F6] font-mono">{effectiveEvidence.length}</span>
<span className="text-[#9A95B0]">Uploaded Proofs</span>
<span className="font-bold text-slate-100 font-mono">{effectiveEvidence.length}</span>
<span className="text-slate-400">Uploaded Proofs</span>
</div>
</div>
{/* Evidence Completion Progress Pill */}
<div className="flex items-center gap-2">
<span className="text-[11px] text-[#9A95B0]">Fulfillment:</span>
<span className="text-[11px] text-slate-400">Fulfillment:</span>
<span className="text-xs font-bold text-emerald-400 font-mono">{progressPercent}%</span>
<div className="w-20 bg-slate-900 rounded-full h-1.5 overflow-hidden border border-white/10">
<div className="w-20 bg-slate-950 rounded-full h-1.5 overflow-hidden border border-white/10">
<div
className="bg-gradient-to-r from-purple-500 to-emerald-400 h-full rounded-full transition-all duration-500"
className="bg-gradient-to-r from-teal-500 via-emerald-400 to-cyan-300 h-full rounded-full transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
@@ -438,22 +440,21 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div>
{/* =========================================================================
INLINE EXPANDED SECTION: MILESTONES & ACTIVITIES
(Followup Requirements #1, #2, #3)
INLINE EXPANDED SECTION: MILESTONES & ACTIVITIES (Landing Page Palette)
========================================================================= */}
{isExpanded && (
<div className="mt-6 pt-6 border-t border-white/10 space-y-6 animate-in fade-in duration-300">
{/* Section Sub-header & Guidance */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 bg-[#0F0E17]/80 p-4 rounded-xl border border-white/5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 bg-slate-950/80 p-4 rounded-xl border border-white/5">
<div>
<h4 className="text-sm font-bold text-white flex items-center gap-2">
<Layers className="w-4 h-4 text-purple-400" />
<h4 className="text-sm font-bold text-slate-100 flex items-center gap-2">
<Layers className="w-4 h-4 text-emerald-400" />
<span>Project Milestones & Contractual Activities</span>
{isFetchingDetails && (
<RefreshCw className="w-3.5 h-3.5 text-purple-400 animate-spin ml-2" />
<RefreshCw className="w-3.5 h-3.5 text-emerald-400 animate-spin ml-2" />
)}
</h4>
<p className="text-[11px] text-[#9A95B0] mt-0.5">
<p className="text-[11px] text-slate-400 mt-0.5">
All milestones are enabled. Complete activities by uploading supporting evidence documents, then submit for review and approval by organisation staff.
</p>
</div>
@@ -462,13 +463,13 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
type="button"
onClick={() => refetchProjectData()}
disabled={isFetchingDetails}
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-white/5 hover:bg-white/10 text-xs text-slate-300 hover:text-white transition-colors cursor-pointer"
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-lg bg-white/5 hover:bg-white/10 text-xs text-slate-300 hover:text-emerald-300 transition-colors cursor-pointer"
title="Refresh project deliverables & evidence"
>
<RefreshCw className={`w-3.5 h-3.5 ${isFetchingDetails ? 'animate-spin text-purple-400' : ''}`} />
<RefreshCw className={`w-3.5 h-3.5 ${isFetchingDetails ? 'animate-spin text-emerald-400' : ''}`} />
<span>Refresh</span>
</button>
<div className="flex items-center gap-1.5 text-[11px] text-purple-300 bg-purple-950/60 border border-purple-500/30 px-3 py-1 rounded-lg">
<div className="flex items-center gap-1.5 text-[11px] text-teal-300 bg-teal-950/60 border border-teal-500/30 px-3 py-1 rounded-lg">
<Info className="w-3.5 h-3.5" />
<span>Fulfillment Only</span>
</div>
@@ -477,18 +478,18 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
{/* LOADING STATE WHEN VIEW DETAILS IS FIRST CLICKED */}
{isLoadingDetails ? (
<div className="p-8 rounded-2xl bg-[#0F0E17]/60 border border-white/5 flex flex-col items-center justify-center space-y-3 text-xs text-[#9A95B0]">
<Loader2 className="w-6 h-6 text-purple-400 animate-spin" />
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 flex flex-col items-center justify-center space-y-3 text-xs text-slate-400">
<Loader2 className="w-6 h-6 text-emerald-400 animate-spin" />
<span>Fetching project milestones, activities, and evidence...</span>
</div>
) : sortedMilestones.length === 0 ? (
<div className="p-8 rounded-2xl bg-[#0F0E17]/60 border border-white/5 text-center text-xs text-[#9A95B0] space-y-2">
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
<Layers className="w-8 h-8 text-slate-600 mx-auto" />
<p>No contractual milestones recorded for this project yet.</p>
<button
type="button"
onClick={() => refetchProjectData()}
className="inline-flex items-center gap-1 text-purple-400 hover:underline pt-1 cursor-pointer"
className="inline-flex items-center gap-1 text-emerald-400 hover:underline pt-1 cursor-pointer"
>
<RefreshCw className="w-3 h-3" />
<span>Click here to re-check schedule</span>
@@ -508,13 +509,13 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
return (
<div
key={milestone.id}
className="rounded-2xl border border-white/[0.08] bg-[#0F0E17]/90 p-5 space-y-4 shadow-sm"
className="rounded-2xl border border-white/[0.08] bg-slate-950/90 p-5 space-y-4 shadow-sm"
>
{/* Milestone Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-3 border-b border-white/5">
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-xs font-bold text-purple-300 bg-purple-950/70 border border-purple-500/20 px-2.5 py-0.5 rounded-md">
<span className="font-mono text-xs font-bold text-emerald-300 bg-emerald-950/70 border border-emerald-500/30 px-2.5 py-0.5 rounded-md">
Milestone #{mIdx + 1}
</span>
<span className="font-mono text-[10px] text-slate-400">
@@ -528,19 +529,19 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</span>
)}
</div>
<h5 className="text-base font-bold text-white mt-1">
<h5 className="text-base font-bold text-slate-100 mt-1">
{milestone.name || `Milestone Target #${mIdx + 1}`}
</h5>
</div>
<div className="flex items-center gap-4 text-xs text-[#9A95B0]">
<div className="flex items-center gap-4 text-xs text-slate-400">
<div>
<span className="text-[10px] text-slate-500 block uppercase font-bold">Start Date</span>
<span className="text-slate-300 font-mono">{formatDate(milestone.createdAt)}</span>
</div>
<div>
<span className="text-[10px] text-slate-500 block uppercase font-bold">Due Date</span>
<span className="text-purple-300 font-bold font-mono">{formatDate(milestone.dueAt)}</span>
<span className="text-emerald-300 font-bold font-mono">{formatDate(milestone.dueAt)}</span>
</div>
<div className="font-mono text-[11px] text-emerald-300 bg-emerald-950/60 border border-emerald-500/20 px-2.5 py-1 rounded-lg">
{milestoneFulfilledActs.length} / {milestoneActs.length} Fulfilled
@@ -548,9 +549,9 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div>
</div>
{/* NESTED ACTIVITIES LISTING (Requirement #1: Fetched for each milestone) */}
{/* NESTED ACTIVITIES LISTING */}
<div className="space-y-3 pt-1">
<div className="text-[11px] font-semibold uppercase tracking-wider text-[#9A95B0] flex items-center justify-between">
<div className="text-[11px] font-semibold uppercase tracking-wider text-slate-400 flex items-center justify-between">
<span>Activities Under Milestone ({milestoneActs.length})</span>
<span className="text-[10px] font-normal normal-case text-slate-500">
Sorted by execution date
@@ -558,7 +559,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div>
{milestoneActs.length === 0 ? (
<div className="p-4 rounded-xl bg-white/[0.02] border border-white/5 text-xs text-slate-500 italic">
<div className="p-4 rounded-xl bg-slate-900/40 border border-white/5 text-xs text-slate-500 italic">
No specific activities defined under this milestone.
</div>
) : (
@@ -572,17 +573,17 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
return (
<div
key={act.id}
className="p-4 rounded-xl bg-white/[0.02] border border-white/5 hover:border-white/10 transition-all space-y-3"
className="p-4 rounded-xl bg-slate-900/40 border border-white/5 hover:border-emerald-500/20 transition-all space-y-3"
>
{/* Activity Details Row */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="space-y-1.5 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-white">
<span className="text-sm font-semibold text-slate-100">
{act.title || `Activity #${encodeHashIdSync(act.id)}`}
</span>
{act.category && (
<span className="text-[10px] font-medium px-2 py-0.5 rounded-md bg-purple-500/10 border border-purple-500/20 text-purple-300">
<span className="text-[10px] font-medium px-2 py-0.5 rounded-md bg-emerald-500/10 border border-emerald-500/20 text-emerald-300">
{act.category}
</span>
)}
@@ -597,12 +598,12 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
Submitted • Staff Review Pending
</span>
) : hasEvidence ? (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-bold bg-cyan-950/80 border border-cyan-500/30 text-cyan-300">
<CheckCircle2 className="w-3 h-3 text-cyan-400" />
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-bold bg-teal-950/80 border border-teal-500/30 text-teal-300">
<CheckCircle2 className="w-3 h-3 text-teal-400" />
{actEvidences.length} Proof{actEvidences.length > 1 ? 's' : ''} Attached (Ready)
</span>
) : (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium bg-slate-900 border border-white/10 text-slate-400">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[10px] font-medium bg-slate-950 border border-white/10 text-slate-400">
<Clock className="w-3 h-3 text-slate-500" />
Evidence Required
</span>
@@ -610,7 +611,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div>
{act.description && (
<p className="text-xs text-[#9A95B0] leading-relaxed">
<p className="text-xs text-slate-300 leading-relaxed">
{act.description}
</p>
)}
@@ -627,24 +628,22 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
{/* Action Buttons: Add Evidence + Submit for Approval */}
<div className="flex items-center gap-2 shrink-0">
{/* Add Evidence Button (Requirement #3: Allow user to add evidence, once uploaded refresh data) */}
<button
type="button"
onClick={() => setUploadTarget({ project, milestone, activity: act })}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 border border-purple-500/30 text-purple-300 hover:text-purple-200 text-xs font-semibold transition-all cursor-pointer shadow-sm active:scale-95"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-emerald-500/15 hover:bg-emerald-500/25 border border-emerald-500/30 text-emerald-300 hover:text-emerald-200 text-xs font-semibold transition-all cursor-pointer shadow-sm hover:shadow-[0_0_12px_rgba(16,185,129,0.2)] active:scale-95"
title="Add supporting evidence document for this activity"
>
<Plus className="w-3.5 h-3.5" />
<span>{hasEvidence ? 'Add More Evidence' : 'Add Evidence'}</span>
</button>
{/* Submit for Approval Button */}
{!isApproved && !isUnderReview && (
<button
type="button"
onClick={() => handleSubmitActivity(act, milestone)}
disabled={!hasEvidence || submittingActivityId === act.id}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-400 hover:to-teal-400 text-slate-950 text-xs font-bold transition-all shadow-[0_2px_10px_rgba(16,185,129,0.25)] hover:shadow-[0_4px_16px_rgba(16,185,129,0.35)] disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer active:scale-95"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-gradient-to-r from-emerald-500 via-teal-400 to-cyan-400 hover:from-emerald-400 hover:to-cyan-300 text-slate-950 text-xs font-bold transition-all shadow-[0_2px_12px_rgba(16,185,129,0.35)] hover:shadow-[0_4px_16px_rgba(16,185,129,0.45)] disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer active:scale-95"
title={hasEvidence ? 'Submit uploaded evidence for organisation staff approval' : 'Upload at least one document before submitting for approval'}
>
{submittingActivityId === act.id ? (
@@ -658,7 +657,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div>
</div>
{/* LIST EXISTING EVIDENCE UNDER MATCHING ACTIVITY BY activityId SORTED BY TIME, WITH TYPE AND STATUS (Requirement #2) */}
{/* LIST EXISTING EVIDENCE UNDER MATCHING ACTIVITY */}
{actEvidences.length > 0 && (
<div className="pt-2 border-t border-white/5 space-y-2">
<div className="text-[10px] uppercase font-bold tracking-wider text-slate-400 flex items-center justify-between">
@@ -672,16 +671,16 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
{actEvidences.map((ev) => (
<div
key={ev.id}
className="flex items-center justify-between p-2.5 rounded-lg bg-[#0F0E17] border border-white/10 text-xs"
className="flex items-center justify-between p-2.5 rounded-lg bg-slate-950 border border-white/10 text-xs"
>
<div className="flex items-center gap-2.5 min-w-0 pr-2">
<FileText className="w-4 h-4 text-purple-400 shrink-0" />
<FileText className="w-4 h-4 text-teal-400 shrink-0" />
<div className="min-w-0">
<p className="text-slate-200 font-medium truncate">
{(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}
</p>
<p className="text-[10px] text-slate-500 truncate">
<span className="font-semibold text-purple-300">
<p className="text-[10px] text-slate-400 truncate">
<span className="font-semibold text-emerald-300">
{getEvidenceTypeLabel(ev.type)}
</span>{' '}
• {formatDateTime(ev.createdAt)}
@@ -694,7 +693,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<button
type="button"
onClick={() => onOpenSecureFile('evidence', ev.id)}
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-purple-300 hover:text-purple-200 transition-colors cursor-pointer"
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-emerald-300 hover:text-emerald-200 transition-colors cursor-pointer"
title="View or download secure file"
>
<ExternalLink className="w-3.5 h-3.5" />
@@ -719,13 +718,13 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
{/* UNASSIGNED ACTIVITIES */}
{unassignedActivities.length > 0 && (
<div className="rounded-2xl border border-white/[0.08] bg-[#0F0E17]/90 p-5 space-y-4 shadow-sm">
<div className="rounded-2xl border border-white/[0.08] bg-slate-950/90 p-5 space-y-4 shadow-sm">
<div className="border-b border-white/5 pb-2">
<h5 className="text-base font-bold text-white flex items-center gap-2">
<h5 className="text-base font-bold text-slate-100 flex items-center gap-2">
<Layers className="w-4 h-4 text-cyan-400" />
<span>General Project Activities</span>
</h5>
<p className="text-xs text-[#9A95B0] mt-0.5">
<p className="text-xs text-slate-400 mt-0.5">
Activities not bound to a specific milestone. Fulfill by submitting required documents.
</p>
</div>
@@ -740,18 +739,18 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
return (
<div
key={act.id}
className="p-4 rounded-xl bg-white/[0.02] border border-white/5 hover:border-white/10 transition-all space-y-3"
className="p-4 rounded-xl bg-slate-900/40 border border-white/5 hover:border-emerald-500/20 transition-all space-y-3"
>
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="space-y-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-white">
<span className="text-sm font-semibold text-slate-100">
{act.title || `Activity #${encodeHashIdSync(act.id)}`}
</span>
{renderStatusBadge(act.status, 'Pending Evidence')}
</div>
{act.description && (
<p className="text-xs text-[#9A95B0] leading-relaxed">
<p className="text-xs text-slate-300 leading-relaxed">
{act.description}
</p>
)}
@@ -765,7 +764,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<button
type="button"
onClick={() => setUploadTarget({ project, activity: act })}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-purple-500/10 hover:bg-purple-500/20 border border-purple-500/30 text-purple-300 text-xs font-semibold cursor-pointer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-emerald-500/15 hover:bg-emerald-500/25 border border-emerald-500/30 text-emerald-300 text-xs font-semibold cursor-pointer"
>
<Plus className="w-3.5 h-3.5" />
<span>{hasEvidence ? 'Add More Evidence' : 'Add Evidence'}</span>
@@ -776,7 +775,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
type="button"
onClick={() => handleSubmitActivity(act, null)}
disabled={!hasEvidence || submittingActivityId === act.id}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-emerald-500 hover:bg-emerald-400 text-slate-950 text-xs font-bold disabled:opacity-40 cursor-pointer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-gradient-to-r from-emerald-500 via-teal-400 to-cyan-400 hover:from-emerald-400 hover:to-cyan-300 text-slate-950 text-xs font-bold disabled:opacity-40 cursor-pointer"
>
<Send className="w-3.5 h-3.5" />
<span>Submit for Approval</span>
@@ -790,20 +789,20 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
{actEvidences.map((ev) => (
<div
key={ev.id}
className="flex items-center justify-between p-2.5 rounded-lg bg-[#0F0E17] border border-white/10 text-xs"
className="flex items-center justify-between p-2.5 rounded-lg bg-slate-950 border border-white/10 text-xs"
>
<div className="min-w-0 pr-2">
<span className="text-slate-200 truncate block">
{(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}
</span>
<span className="text-[10px] text-slate-500">
<span className="text-[10px] text-slate-400">
{getEvidenceTypeLabel(ev.type)} • {formatDateTime(ev.createdAt)}
</span>
</div>
<button
type="button"
onClick={() => onOpenSecureFile('evidence', ev.id)}
className="p-1 rounded text-purple-300 hover:text-purple-200"
className="p-1 rounded text-emerald-300 hover:text-emerald-200"
>
<ExternalLink className="w-3.5 h-3.5" />
</button>
@@ -820,9 +819,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div>
)}
{/* =========================================================================
EVIDENCE UPLOAD MODAL (Requirement #3: Allow user to add evidence, once uploaded refresh data)
========================================================================= */}
{/* EVIDENCE UPLOAD MODAL */}
{uploadTarget && (
<EvidenceUploadModal
isOpen={true}
@@ -462,14 +462,36 @@ export const getOrganisationTypeLabel = (type: OrganisationType | number): strin
return opt ? opt.label : 'Organisation';
};
export const getIndustrySectorLabel = (sector: IndustrySector | number): string => {
const opt = INDUSTRY_SECTOR_OPTIONS.find((o) => o.value === Number(sector));
return opt ? opt.label : 'Industry Sector';
export const getIndustrySectorLabel = (sector?: IndustrySector | number | string | null): string => {
if (sector === undefined || sector === null || sector === '') return '';
const num = Number(sector);
if (!isNaN(num)) {
const opt = INDUSTRY_SECTOR_OPTIONS.find((o) => o.value === num);
if (opt) return opt.label;
}
const str = String(sector).trim().toLowerCase();
for (const opt of INDUSTRY_SECTOR_OPTIONS) {
if (opt.label.toLowerCase() === str) return opt.label;
const enumKey = (IndustrySector as any)[opt.value];
if (enumKey && String(enumKey).toLowerCase() === str) return opt.label;
}
return String(sector).replace(/([a-z])([A-Z])/g, '$1 $2');
};
export const getProjectTypeLabel = (type: ProjectType | number): string => {
const opt = PROJECT_TYPE_OPTIONS.find((o) => o.value === Number(type));
return opt ? opt.label : 'Project Type';
export const getProjectTypeLabel = (type?: ProjectType | number | string | null): string => {
if (type === undefined || type === null || type === '') return '';
const num = Number(type);
if (!isNaN(num)) {
const opt = PROJECT_TYPE_OPTIONS.find((o) => o.value === num);
if (opt) return opt.label;
}
const str = String(type).trim().toLowerCase();
for (const opt of PROJECT_TYPE_OPTIONS) {
if (opt.label.toLowerCase() === str) return opt.label;
const enumKey = (ProjectType as any)[opt.value];
if (enumKey && String(enumKey).toLowerCase() === str) return opt.label;
}
return String(type).replace(/([a-z])([A-Z])/g, '$1 $2');
};
export const getEvidenceTypeLabel = (type: EvidenceType | number): string => {
@@ -5,13 +5,17 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
? import.meta.env
: {}) as Record<string, string | undefined>;
export const DEFAULT_HASHER_SALT =
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
export const DEFAULT_MIN_HASH_LENGTH = 11;
/**
* Deterministic Hashids configuration matching pfm-api HasherSettings.
* Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend.
*/
export const HASHER_SETTINGS = {
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
salt: (env.VITE_HASHER_SALT as string) || '',
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
};
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
@@ -21,9 +25,13 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
if (salt && typeof salt === 'string' && salt.trim() !== '') {
HASHER_SETTINGS.salt = salt.trim();
updated = true;
} else if (!HASHER_SETTINGS.salt) {
HASHER_SETTINGS.salt = DEFAULT_HASHER_SALT;
updated = true;
}
if (minLength !== undefined && minLength !== null && minLength !== '') {
const parsed = Number(minLength);
const cleanMin = String(minLength).replace(/[^0-9]/g, '');
const parsed = Number(cleanMin);
if (!isNaN(parsed) && parsed > 0) {
HASHER_SETTINGS.minHashLength = parsed;
updated = true;
@@ -34,8 +42,9 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
}
}
export function isAlreadyHashId(candidate: string): boolean {
export function isAlreadyHashId(candidate: string | null | undefined): boolean {
if (
!candidate ||
typeof candidate !== 'string' ||
candidate.length < HASHER_SETTINGS.minHashLength ||
/^\d+$/.test(candidate)
@@ -47,20 +56,28 @@ export function isAlreadyHashId(candidate: string): boolean {
if (!Array.isArray(decoded) || decoded.length === 0) {
return false;
}
return hasher.encode(decoded as number[]) === candidate;
return hasher.encode(decoded as any) === candidate;
} catch {
return false;
}
}
export function encodeHashIdSync(id: number | string | null | undefined): string {
export function encodeHashIdSync(id: number | string | bigint | null | undefined): string {
if (id === undefined || id === null || id === '') {
return '';
}
if (typeof id === 'bigint') {
if (id < 0n) return '';
return hasher.encode(id);
}
if (typeof id === 'number') {
if (isNaN(id) || !isFinite(id) || id < 0) return '';
return hasher.encode(id);
if (id <= Number.MAX_SAFE_INTEGER) {
return hasher.encode(id);
}
return hasher.encode(BigInt(Math.trunc(id)));
}
const str = String(id).trim();
@@ -72,10 +89,8 @@ export function encodeHashIdSync(id: number | string | null | undefined): string
if (/^\d+$/.test(str)) {
try {
const num = parseInt(str, 10);
if (!isNaN(num) && num >= 0) {
return hasher.encode(num);
}
// JavaScript hashids v2 natively supports BigInt, matching C# Hashids.net EncodeLong without int64 precision loss
return hasher.encode(BigInt(str));
} catch {
// Fall through
}
@@ -84,22 +99,37 @@ export function encodeHashIdSync(id: number | string | null | undefined): string
return str;
}
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
export function decodeHashIdSync(hashId: string | number | bigint | null | undefined): number | string {
if (hashId === undefined || hashId === null || hashId === '') return '';
if (typeof hashId === 'number') return hashId;
if (typeof hashId === 'bigint') return hashId.toString();
const str = String(hashId).trim();
if (!str) return '';
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? str : parsed;
try {
const b = BigInt(str);
if (b <= BigInt(Number.MAX_SAFE_INTEGER) && b >= BigInt(Number.MIN_SAFE_INTEGER)) {
return Number(b);
}
return str;
} catch {
return str;
}
}
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
const val = decoded[0];
if (typeof val === 'bigint') {
if (val <= BigInt(Number.MAX_SAFE_INTEGER) && val >= BigInt(Number.MIN_SAFE_INTEGER)) {
return Number(val);
}
return val.toString();
}
return Number(val);
}
} catch {
// If decoding fails, return raw string
@@ -108,11 +138,14 @@ export function decodeHashIdSync(hashId: string | number | null | undefined): nu
return str;
}
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
export function decodeToNumberSync(hashId: number | string | bigint | null | undefined): number {
if (hashId === null || hashId === undefined || hashId === '') return 0;
if (typeof hashId === 'number') {
return isNaN(hashId) ? 0 : Math.trunc(hashId);
}
if (typeof hashId === 'bigint') {
return Number(hashId);
}
const str = String(hashId).trim();
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
@@ -129,19 +162,19 @@ export function decodeToNumberSync(hashId: number | string | null | undefined):
}
export const toolsService = {
encodeIdSync(id: number | string | null | undefined): string {
encodeIdSync(id: number | string | bigint | null | undefined): string {
return encodeHashIdSync(id);
},
decodeIdSync(hashId: string | number | null | undefined): number | string {
decodeIdSync(hashId: string | number | bigint | null | undefined): number | string {
return decodeHashIdSync(hashId);
},
decodeToNumberSync(hashId: number | string | null | undefined): number {
decodeToNumberSync(hashId: number | string | bigint | null | undefined): number {
return decodeToNumberSync(hashId);
},
async encodeId(id: number | string | null | undefined, _context = 'general'): Promise<string> {
async encodeId(id: number | string | bigint | null | undefined, _context = 'general'): Promise<string> {
return encodeHashIdSync(id);
},
@@ -149,35 +182,35 @@ export const toolsService = {
return decodeHashIdSync(hashId);
},
async resolveProjectId(projectId: number | string): Promise<string> {
async resolveProjectId(projectId: number | string | bigint): Promise<string> {
return encodeHashIdSync(projectId);
},
async resolveOrganisationId(organisationId: number | string): Promise<string> {
async resolveOrganisationId(organisationId: number | string | bigint): Promise<string> {
return encodeHashIdSync(organisationId);
},
async resolveProgrammeId(programmeId: number | string): Promise<string> {
async resolveProgrammeId(programmeId: number | string | bigint): Promise<string> {
return encodeHashIdSync(programmeId);
},
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
async resolvePortfolioId(portfolioId: number | string | bigint): Promise<string> {
return encodeHashIdSync(portfolioId);
},
async resolveAwardId(awardId: number | string): Promise<string> {
async resolveAwardId(awardId: number | string | bigint): Promise<string> {
return encodeHashIdSync(awardId);
},
async resolveContractId(contractId: number | string): Promise<string> {
async resolveContractId(contractId: number | string | bigint): Promise<string> {
return encodeHashIdSync(contractId);
},
async resolveActivityId(activityId: number | string): Promise<string> {
async resolveActivityId(activityId: number | string | bigint): Promise<string> {
return encodeHashIdSync(activityId);
},
async resolveMilestoneId(milestoneId: number | string): Promise<string> {
async resolveMilestoneId(milestoneId: number | string | bigint): Promise<string> {
return encodeHashIdSync(milestoneId);
},
@@ -200,7 +233,7 @@ export const toolsService = {
},
};
export function useHashId(id?: number | string | null, context = 'general') {
export function useHashId(id?: number | string | bigint | null, context = 'general') {
return useQuery({
queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''],
queryFn: () => Promise.resolve(encodeHashIdSync(id)),
@@ -4,9 +4,13 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
? import.meta.env
: {}) as Record<string, string | undefined>;
export const DEFAULT_HASHER_SALT =
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
export const DEFAULT_MIN_HASH_LENGTH = 11;
export const HASHER_SETTINGS = {
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
salt: (env.VITE_HASHER_SALT as string) || '',
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
};
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
@@ -16,9 +20,13 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
if (salt && typeof salt === 'string' && salt.trim() !== '') {
HASHER_SETTINGS.salt = salt.trim();
updated = true;
} else if (!HASHER_SETTINGS.salt) {
HASHER_SETTINGS.salt = DEFAULT_HASHER_SALT;
updated = true;
}
if (minLength !== undefined && minLength !== null && minLength !== '') {
const parsed = Number(minLength);
const cleanMin = String(minLength).replace(/[^0-9]/g, '');
const parsed = Number(cleanMin);
if (!isNaN(parsed) && parsed > 0) {
HASHER_SETTINGS.minHashLength = parsed;
updated = true;
@@ -46,7 +54,7 @@ export function isHashId(candidate: string | null | undefined): boolean {
if (
Array.isArray(decodedNumbers) &&
decodedNumbers.length > 0 &&
hasher.encode(decodedNumbers as number[]) === candidate
hasher.encode(decodedNumbers as any) === candidate
) {
return true;
}
@@ -149,23 +157,24 @@ export function toSafeInt64String(val: string | number | bigint | null | undefin
/**
* Deterministically encodes a numeric ID or 64-bit integer into a standard HashID string.
* Uses BigInt for full int64 range, interoperable with C# Hashids.net EncodeLong.
*/
export function encodeEntityId(id: string | number | bigint | null | undefined): string {
if (id === undefined || id === null || id === '') return '';
const str = typeof id === 'bigint' ? id.toString() : String(id).trim();
if (typeof id === 'bigint') {
if (id < 0n) return '';
return hasher.encode(id);
}
const str = String(id).trim();
if (!str) return '';
if (isHashId(str)) return str;
if (/^\d+$/.test(str)) {
try {
const num = Number(str);
if (num <= Number.MAX_SAFE_INTEGER && num >= 0) {
return hasher.encode(num);
}
const bigHex = BigInt(str).toString(16);
return hasher.encodeHex(bigHex);
return hasher.encode(BigInt(str));
} catch {
// Fall through
}
@@ -189,18 +198,9 @@ export function decodeEntityId(hashId: string | number | null | undefined): stri
if (Array.isArray(decoded) && decoded.length > 0) {
return decoded[0].toString();
}
const decodedHex = hasher.decodeHex(str);
if (decodedHex) {
try {
return BigInt('0x' + decodedHex).toString();
} catch {
return decodedHex;
}
}
} catch {
// Return raw
}
return str;
}