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); int[] decoded = hasher.Decode(hash);
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Int hash token.")) if (decoded.Length <= 0)
.ToResult(decoded[0]); return Result.Fail<int>(ValidationError.Create("Invalid or modified Int hash token."));
return Result.Ok(decoded[0]);
} }
public Result<long> DecodeLongIdHash(string hash) public Result<long> DecodeLongIdHash(string hash)
@@ -62,8 +64,10 @@ public sealed partial class HashService(IHashids hasher) : IService
long[] decoded = hasher.DecodeLong(hash); long[] decoded = hasher.DecodeLong(hash);
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Long hash token.")) if (decoded.Length <= 0)
.ToResult(decoded[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) => 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:-}}}}}" OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
# 6. Hasher Settings # 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==}}}}" HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==}}}}"
if [ -d "$TARGET_DIR" ]; then if [ -d "$TARGET_DIR" ]; then
+4 -4
View File
@@ -1,9 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls. # 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" GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted. # 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" 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 # 5. OIDC Scope
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}" OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
# 6. Hasher Settings # 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:-}}}}}" HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}"
if [ -d "$TARGET_DIR" ]; then if [ -d "$TARGET_DIR" ]; then
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..." echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
# Generate env-config.js for instant, synchronous window.__RUNTIME_CONFIG__ initialization # 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" 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__ = { window.__RUNTIME_CONFIG__ = {
API_BASE_URL: "${API_BASE_URL}", API_BASE_URL: "${API_BASE_URL}",
VITE_API_BASE_URL: "${API_BASE_URL}", VITE_API_BASE_URL: "${API_BASE_URL}",
@@ -10,6 +10,6 @@
"BaseUrl": "" "BaseUrl": ""
}, },
"HasherSettings": { "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 { formatZAR, formatCompactZAR } from '../../lib/formatters';
import { MessageViewer } from '../notifications/MessageViewer'; import { MessageViewer } from '../notifications/MessageViewer';
import { stripMarkdown } from '../../lib/markdownUtils'; import { stripMarkdown } from '../../lib/markdownUtils';
import { PfmPartnerLogo } from '../common/PfmPartnerLogo';
import { ProjectCard, EnrichedPartnerProject } from './ProjectCard'; import { ProjectCard, EnrichedPartnerProject } from './ProjectCard';
import { EvidenceUploadModal } from './EvidenceUploadModal'; import { EvidenceUploadModal } from './EvidenceUploadModal';
@@ -499,20 +500,25 @@ export function DashboardPage() {
]; ];
return ( 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 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"> <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 */} {/* Left: Brand Glyph & Horizontal Navigation Links */}
<div className="flex items-center gap-6 sm:gap-10 h-full"> <div className="flex items-center gap-6 sm:gap-10 h-full">
<div <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')} onClick={() => setActiveTab('dashboard')}
title="Dashboard Home" 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> </div>
{/* Nav Tabs */} {/* Nav Tabs */}
@@ -526,13 +532,13 @@ export function DashboardPage() {
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
className={`relative h-full flex items-center text-sm transition-colors cursor-pointer ${ className={`relative h-full flex items-center text-sm transition-colors cursor-pointer ${
isActive isActive
? 'font-bold text-[#F1F1F6]' ? 'font-bold text-slate-100'
: 'font-medium text-[#9A95B0] hover:text-[#F1F1F6]' : 'font-medium text-slate-400 hover:text-slate-100'
}`} }`}
> >
<span>{tab.label}</span> <span>{tab.label}</span>
{isActive && ( {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> </button>
); );
@@ -544,19 +550,19 @@ export function DashboardPage() {
<div className="flex items-center gap-3 sm:gap-4"> <div className="flex items-center gap-3 sm:gap-4">
{/* Search Input */} {/* Search Input */}
<div className="relative hidden sm:block w-48 md:w-64"> <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 <input
type="text" type="text"
placeholder="Search projects..." placeholder="Search projects..."
value={searchQuery} value={searchQuery}
onChange={e => setSearchQuery(e.target.value)} 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 && ( {searchQuery && (
<button <button
type="button" type="button"
onClick={() => setSearchQuery('')} 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" /> <X className="w-3.5 h-3.5" />
</button> </button>
@@ -569,9 +575,9 @@ export function DashboardPage() {
onClick={() => refetchApplicantData()} onClick={() => refetchApplicantData()}
title="Refresh projects" title="Refresh projects"
disabled={isRefreshingApplicantData} 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> </button>
{/* Notifications Bell */} {/* Notifications Bell */}
@@ -582,12 +588,12 @@ export function DashboardPage() {
setShowBellDropdown(!showBellDropdown); setShowBellDropdown(!showBellDropdown);
setShowUserDropdown(false); 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" title="Notifications"
> >
<Bell className="w-4 h-4" /> <Bell className="w-4 h-4" />
{unreadCount > 0 && ( {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} {unreadCount > 9 ? '9+' : unreadCount}
</span> </span>
)} )}
@@ -595,14 +601,14 @@ export function DashboardPage() {
{/* Bell Dropdown */} {/* Bell Dropdown */}
{showBellDropdown && ( {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"> <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 && ( {unreadCount > 0 && (
<button <button
type="button" type="button"
onClick={() => markAllAsRead()} 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 Mark all as read
</button> </button>
@@ -610,7 +616,7 @@ export function DashboardPage() {
</div> </div>
<div className="max-h-72 overflow-y-auto divide-y divide-white/[0.04]"> <div className="max-h-72 overflow-y-auto divide-y divide-white/[0.04]">
{top5Unread.length === 0 ? ( {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 => ( top5Unread.map(item => (
<div <div
@@ -622,8 +628,8 @@ export function DashboardPage() {
}} }}
className="p-3 hover:bg-white/[0.03] transition-colors cursor-pointer text-left" 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-xs font-semibold text-slate-100 truncate">{item.subject}</p>
<p className="text-[11px] text-[#9A95B0] line-clamp-2 mt-0.5">{stripMarkdown(item.message)}</p> <p className="text-[11px] text-slate-400 line-clamp-2 mt-0.5">{stripMarkdown(item.message)}</p>
</div> </div>
)) ))
)} )}
@@ -635,7 +641,7 @@ export function DashboardPage() {
setActiveTab('messages'); setActiveTab('messages');
setShowBellDropdown(false); 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 View all messages
</button> </button>
@@ -652,22 +658,22 @@ export function DashboardPage() {
setShowUserDropdown(!showUserDropdown); setShowUserDropdown(!showUserDropdown);
setShowBellDropdown(false); 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')} {getInitials(user?.profile?.name || user?.profile?.email || 'Partner')}
</div> </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'} {user?.profile?.name || 'Partner Account'}
</span> </span>
<ChevronDown className="w-3.5 h-3.5 text-[#9A95B0]" /> <ChevronDown className="w-3.5 h-3.5 text-slate-400" />
</button> </button>
{showUserDropdown && ( {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]"> <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="font-semibold text-slate-100 truncate">{user?.profile?.name || 'Partner User'}</p>
<p className="text-[11px] text-[#9A95B0] truncate">{user?.profile?.email || ''}</p> <p className="text-[11px] text-slate-400 truncate">{user?.profile?.email || ''}</p>
{partnerSummary.primaryInvitedOrg && ( {partnerSummary.primaryInvitedOrg && (
<p className="text-[10px] text-emerald-400 mt-1 truncate"> <p className="text-[10px] text-emerald-400 mt-1 truncate">
{partnerSummary.primaryInvitedOrg.name} {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"> <main className="flex-1 w-full max-w-[1440px] mx-auto px-6 py-8">
{isLoadingApplicantData ? ( {isLoadingApplicantData ? (
<div className="py-24 flex flex-col items-center justify-center space-y-4"> <div className="py-24 flex flex-col items-center justify-center space-y-4">
<RefreshCw className="w-8 h-8 text-[#8B5CF6] animate-spin" /> <RefreshCw className="w-8 h-8 text-emerald-400 animate-spin" />
<p className="text-sm text-[#9A95B0]">Loading your funding portfolio & projects...</p> <p className="text-sm text-slate-400">Loading your funding portfolio & projects...</p>
</div> </div>
) : activeTab === 'dashboard' ? ( ) : activeTab === 'dashboard' ? (
/* =================================================================== /* ===================================================================
@@ -707,10 +713,10 @@ export function DashboardPage() {
{/* Top Bar: Title & Sort Options */} {/* Top Bar: Title & Sort Options */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div> <div>
<h2 className="text-[20px] leading-[28px] font-bold text-[#F1F1F6]"> <h2 className="text-[20px] leading-[28px] font-bold text-slate-100">
Ongoing Projects Ongoing Projects
</h2> </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. Click &ldquo;View Details&rdquo; on any project to inspect its milestones, track progress, and fulfill activity evidence requirements.
</p> </p>
</div> </div>
@@ -720,14 +726,14 @@ export function DashboardPage() {
<select <select
value={sortBy} value={sortBy}
onChange={e => setSortBy(e.target.value as SortOption)} 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="date_added" className="bg-slate-900/80 text-slate-100">Sort by date added</option>
<option value="grant_amount" className="bg-[#181622] text-[#F1F1F6]">Sort by award value</option> <option value="grant_amount" className="bg-slate-900/80 text-slate-100">Sort by award value</option>
<option value="days_left" className="bg-[#181622] text-[#F1F1F6]">Sort by days remaining</option> <option value="days_left" className="bg-slate-900/80 text-slate-100">Sort by days remaining</option>
<option value="name" className="bg-[#181622] text-[#F1F1F6]">Sort by project name</option> <option value="name" className="bg-slate-900/80 text-slate-100">Sort by project name</option>
</select> </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>
</div> </div>
@@ -747,18 +753,18 @@ export function DashboardPage() {
)) ))
) : ( ) : (
/* Empty State */ /* 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="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" /> <Sparkles className="w-7 h-7" />
</div> </div>
</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'} {searchQuery ? 'No Matching Projects Found' : 'No Active Projects Assigned Yet'}
</h3> </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 {searchQuery
? `We couldn't find any ongoing projects matching "${searchQuery}". Clear your search query to see your full workspace portfolio.` ? `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."} : "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 <button
type="button" type="button"
onClick={() => setSearchQuery('')} 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" /> <X className="w-4 h-4" />
<span>Clear Search</span> <span>Clear Search</span>
@@ -780,7 +786,7 @@ export function DashboardPage() {
type="button" type="button"
onClick={() => refetchApplicantData()} onClick={() => refetchApplicantData()}
disabled={isRefreshingApplicantData} 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' : ''}`} /> <RefreshCw className={`w-4 h-4 ${isRefreshingApplicantData ? 'animate-spin' : ''}`} />
<span>{isRefreshingApplicantData ? 'Refreshing Workspace...' : 'Refresh Projects'}</span> <span>{isRefreshingApplicantData ? 'Refreshing Workspace...' : 'Refresh Projects'}</span>
@@ -797,8 +803,8 @@ export function DashboardPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4"> <div className="flex flex-wrap items-center justify-between gap-4">
<div> <div>
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Messages & Communications</h1> <h1 className="text-[20px] font-bold text-slate-100">Messages & Communications</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5"> <p className="text-[12px] text-slate-400 mt-0.5">
Direct communication with your funding programme officers. Direct communication with your funding programme officers.
</p> </p>
</div> </div>
@@ -812,16 +818,16 @@ export function DashboardPage() {
setMessageSubject(''); setMessageSubject('');
setMessageBody(''); 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" /> <Plus className="w-4 h-4" />
<span>New Message</span> <span>New Message</span>
</button> </button>
</div> </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 ? ( {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. No new messages in your inbox yet. Messages sent by programme managers will be displayed here.
</div> </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" 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="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')} {getInitials(msg.subject || 'PFM')}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <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'} {msg.subject || 'Programme Notification'}
</h3> </h3>
{isUnread && ( {isUnread && (
@@ -851,10 +857,10 @@ export function DashboardPage() {
</span> </span>
)} )}
</div> </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>
</div> </div>
<span className="text-xs text-[#9A95B0]/70 shrink-0"> <span className="text-xs text-slate-400/70 shrink-0">
{formatFullDate(msg.createdAt)} {formatFullDate(msg.createdAt)}
</span> </span>
</div> </div>
@@ -869,15 +875,15 @@ export function DashboardPage() {
=================================================================== */ =================================================================== */
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Contracts & Approved Awards</h1> <h1 className="text-[20px] font-bold text-slate-100">Contracts & Approved Awards</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5"> <p className="text-[12px] text-slate-400 mt-0.5">
Review your approved funding awards and download or sign grant agreements. Review your approved funding awards and download or sign grant agreements.
</p> </p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{partnerSummary.contractEntries.length === 0 ? ( {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. No contracts have been issued to your organisation yet.
</div> </div>
) : ( ) : (
@@ -887,11 +893,11 @@ export function DashboardPage() {
return ( return (
<div <div
key={contract.id} 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="space-y-3">
<div className="flex items-center justify-between gap-2"> <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} Agreement v{contractVer}
</span> </span>
<span <span
@@ -904,18 +910,18 @@ export function DashboardPage() {
{isExecuted ? 'Signed & Executed' : 'Action Required: Review & Sign'} {isExecuted ? 'Signed & Executed' : 'Action Required: Review & Sign'}
</span> </span>
</div> </div>
<h3 className="text-lg font-bold text-[#F1F1F6]"> <h3 className="text-lg font-bold text-slate-100">
{fundingOrganisation?.name || 'Grant Funding Agreement'} {fundingOrganisation?.name || 'Grant Funding Agreement'}
</h3> </h3>
<div className="grid grid-cols-2 gap-4 pt-2 text-xs"> <div className="grid grid-cols-2 gap-4 pt-2 text-xs">
<div> <div>
<p className="text-[#9A95B0]">Contract Value</p> <p className="text-slate-400">Contract Value</p>
<p className="text-sm font-bold text-[#F1F1F6] mt-0.5"> <p className="text-sm font-bold text-slate-100 mt-0.5">
{formatZAR(contract.totalValue || award?.amount || 0)} {formatZAR(contract.totalValue || award?.amount || 0)}
</p> </p>
</div> </div>
<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"> <p className="text-[#D1CBDD] font-medium mt-0.5">
{formatShortDate(contract.effectiveAt)} – {formatFullDate(contract.expiresAt)} {formatShortDate(contract.effectiveAt)} – {formatFullDate(contract.expiresAt)}
</p> </p>
@@ -927,7 +933,7 @@ export function DashboardPage() {
<button <button
type="button" type="button"
onClick={() => handleOpenSecureFile('contract', contract.id)} 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" /> <ExternalLink className="w-3.5 h-3.5" />
<span>Download PDF</span> <span>Download PDF</span>
@@ -937,7 +943,7 @@ export function DashboardPage() {
type="button" type="button"
onClick={() => signContractMutation.mutate(contract)} onClick={() => signContractMutation.mutate(contract)}
disabled={signContractMutation.isPending} 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" /> <CheckCircle2 className="w-3.5 h-3.5" />
<span>{signContractMutation.isPending ? 'Signing...' : 'Accept & Sign'}</span> <span>{signContractMutation.isPending ? 'Signing...' : 'Accept & Sign'}</span>
@@ -956,43 +962,43 @@ export function DashboardPage() {
=================================================================== */ =================================================================== */
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Portfolio Analytics</h1> <h1 className="text-[20px] font-bold text-slate-100">Portfolio Analytics</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5"> <p className="text-[12px] text-slate-400 mt-0.5">
Real-time performance metrics and deliverable fulfillment rates across your assigned projects. Real-time performance metrics and deliverable fulfillment rates across your assigned projects.
</p> </p>
</div> </div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6"> <div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
{/* Card 1: Milestone Fulfillment Rate */} {/* Card 1: Milestone Fulfillment Rate */}
<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-[#8B5CF6] mb-2"> <div className="flex items-center justify-between text-emerald-400 mb-2">
<TrendingUp className="w-5 h-5" /> <TrendingUp className="w-5 h-5" />
<span className="text-xs font-semibold text-emerald-400"> <span className="text-xs font-semibold text-emerald-400">
{portfolioStats.completedMilestones} of {portfolioStats.totalMilestones} Milestones {portfolioStats.completedMilestones} of {portfolioStats.totalMilestones} Milestones
</span> </span>
</div> </div>
<p className="text-2xl font-bold text-[#F1F1F6]">{portfolioStats.milestoneRate}%</p> <p className="text-2xl font-bold text-slate-100">{portfolioStats.milestoneRate}%</p>
<p className="text-xs text-[#9A95B0] mt-1">Milestone Completion Rate</p> <p className="text-xs text-slate-400 mt-1">Milestone Completion Rate</p>
</div> </div>
{/* Card 2: Active Projects */} {/* 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"> <div className="flex items-center justify-between text-teal-400 mb-2">
<Briefcase className="w-5 h-5" /> <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> </div>
<p className="text-2xl font-bold text-[#F1F1F6]">{portfolioStats.totalProjects} Projects</p> <p className="text-2xl font-bold text-slate-100">{portfolioStats.totalProjects} Projects</p>
<p className="text-xs text-[#9A95B0] mt-1">Assigned Funded Projects</p> <p className="text-xs text-slate-400 mt-1">Assigned Funded Projects</p>
</div> </div>
{/* Card 3: Total Supporting Proofs */} {/* 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"> <div className="flex items-center justify-between text-amber-400 mb-2">
<AwardIcon className="w-5 h-5" /> <AwardIcon className="w-5 h-5" />
<span className="text-xs font-semibold text-amber-400">Audited</span> <span className="text-xs font-semibold text-amber-400">Audited</span>
</div> </div>
<p className="text-2xl font-bold text-[#F1F1F6]">{portfolioStats.totalEvidenceCount} Documents</p> <p className="text-2xl font-bold text-slate-100">{portfolioStats.totalEvidenceCount} Documents</p>
<p className="text-xs text-[#9A95B0] mt-1">Uploaded Evidence Deliverables</p> <p className="text-xs text-slate-400 mt-1">Uploaded Evidence Deliverables</p>
</div> </div>
</div> </div>
</div> </div>
@@ -1002,16 +1008,16 @@ export function DashboardPage() {
=================================================================== */ =================================================================== */
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
<h1 className="text-[20px] font-bold text-[#F1F1F6]">Deliverables & Evidence Documents</h1> <h1 className="text-[20px] font-bold text-slate-100">Deliverables & Evidence Documents</h1>
<p className="text-[12px] text-[#9A95B0] mt-0.5"> <p className="text-[12px] text-slate-400 mt-0.5">
Invoices, reports, and verified deliverable proofs submitted across projects. Invoices, reports, and verified deliverable proofs submitted across projects.
</p> </p>
</div> </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 ? ( {enrichedProjects.flatMap(p => p.evidence.map(ev => ({ ev, project: p.project }))).length === 0 ? (
<div className="p-12 text-center space-y-3"> <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. No documents uploaded yet. Expand any project on your Dashboard to upload proof files.
</p> </p>
</div> </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]" 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="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" /> <FileText className="w-5 h-5" />
</div> </div>
<div className="min-w-0"> <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'}{' '} {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}) (v{ev.version || 1})
</span> </span>
</p> </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)} Project: {project.name} • Submitted {formatFullDate(evDate)}
</p> </p>
</div> </div>
@@ -1046,7 +1052,7 @@ export function DashboardPage() {
<button <button
type="button" type="button"
onClick={() => handleOpenSecureFile('evidence', ev.id)} 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" /> <ExternalLink className="w-3.5 h-3.5" />
<span>View File</span> <span>View File</span>
@@ -1177,7 +1183,7 @@ export function DashboardPage() {
</div> </div>
<div className="space-y-3 text-xs"> <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>From: {selectedNotification.destination?.split('|')[0] || 'Programme Management Office'}</span>
<span>{formatFullDate(selectedNotification.createdAt)}</span> <span>{formatFullDate(selectedNotification.createdAt)}</span>
</div> </div>
@@ -18,7 +18,11 @@ import {
getProjectEvidence, getProjectEvidence,
submitActivityForApproval submitActivityForApproval
} from '../../services/api'; } from '../../services/api';
import { getEvidenceTypeLabel } from '../../lib/formatters'; import {
getEvidenceTypeLabel,
getIndustrySectorLabel,
getProjectTypeLabel
} from '../../lib/formatters';
import { EvidenceUploadModal } from './EvidenceUploadModal'; import { EvidenceUploadModal } from './EvidenceUploadModal';
import { import {
FolderKanban, FolderKanban,
@@ -76,7 +80,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
const [submittingActivityId, setSubmittingActivityId] = useState<string | number | null>(null); 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<{ const [uploadTarget, setUploadTarget] = useState<{
project: Project; project: Project;
milestone?: Milestone | null; 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 => { const formatDateTime = (dateStr?: string | null): string => {
if (!dateStr) return 'TBD'; if (!dateStr) return 'TBD';
try { 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 renderStatusBadge = (status?: any, fallback = 'Active') => {
const num = Number(status); const num = Number(status);
if (num === ApprovalStatus.Approved || String(status).toLowerCase().includes('approved')) { if (num === ApprovalStatus.Approved || String(status).toLowerCase().includes('approved')) {
return ( 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" /> <CheckCircle2 className="w-3 h-3 text-emerald-400" />
<span>Approved</span> <span>Approved</span>
</span> </span>
@@ -144,23 +148,21 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
} }
if (num === ApprovalStatus.Review || String(status).toLowerCase().includes('review')) { if (num === ApprovalStatus.Review || String(status).toLowerCase().includes('review')) {
return ( 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" /> <Clock className="w-3 h-3 text-amber-400" />
<span>Under Review</span> <span>Under Review</span>
</span> </span>
); );
} }
return ( 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>{String(status || fallback)}</span>
</span> </span>
); );
}; };
// ========================================================================= // On-demand fetch when expanded
// ON-DEMAND FETCH: WHEN "View details" IS CLICKED, FETCH USING projectId ON HAND
// (Followup Requirement #1 & #2)
// =========================================================================
const { const {
data: projectDetails, data: projectDetails,
isLoading: isLoadingDetails, isLoading: isLoadingDetails,
@@ -195,12 +197,11 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
staleTime: 1000 * 20, 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 effectiveMilestones: Milestone[] = projectDetails?.milestones ?? (item.milestones?.length ? item.milestones : []);
const effectiveActivities: Activity[] = projectDetails?.activities ?? (item.activities?.length ? item.activities : []); const effectiveActivities: Activity[] = projectDetails?.activities ?? (item.activities?.length ? item.activities : []);
const effectiveEvidence: Evidence[] = projectDetails?.evidence ?? (item.evidence?.length ? item.evidence : []); 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 sortedMilestones = [...effectiveMilestones].sort((a, b) => {
const startA = new Date(a.createdAt || a.dueAt || 0).getTime(); const startA = new Date(a.createdAt || a.dueAt || 0).getTime();
const startB = new Date(b.createdAt || b.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(); 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 getEvidenceForActivity = (activityId: string | number): Evidence[] => {
const targetNum = toIntegerId(activityId); const targetNum = toIntegerId(activityId);
return effectiveEvidence 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()); .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 getActivitiesForMilestone = (milestoneId: string | number): Activity[] => {
const targetMilestoneNum = toIntegerId(milestoneId); const targetMilestoneNum = toIntegerId(milestoneId);
return effectiveActivities return effectiveActivities
@@ -228,7 +227,6 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
}); });
}; };
// Unassigned activities
const unassignedActivities = effectiveActivities const unassignedActivities = effectiveActivities
.filter(a => !a.milestoneId || toIntegerId(a.milestoneId) === 0) .filter(a => !a.milestoneId || toIntegerId(a.milestoneId) === 0)
.sort((a, b) => { .sort((a, b) => {
@@ -237,7 +235,6 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
return dateA - dateB; return dateA - dateB;
}); });
// Evidence fulfillment progress
const totalActivitiesCount = effectiveActivities.length; const totalActivitiesCount = effectiveActivities.length;
const fulfilledActivitiesCount = effectiveActivities.filter(a => { const fulfilledActivitiesCount = effectiveActivities.filter(a => {
const evs = getEvidenceForActivity(a.id); const evs = getEvidenceForActivity(a.id);
@@ -248,7 +245,6 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
? Math.round((fulfilledActivitiesCount / totalActivitiesCount) * 100) ? Math.round((fulfilledActivitiesCount / totalActivitiesCount) * 100)
: (effectiveMilestones.length > 0 ? 0 : 100); : (effectiveMilestones.length > 0 ? 0 : 100);
// Handle submit activity for approval
const handleSubmitActivity = async (activity: Activity, milestone?: Milestone | null) => { const handleSubmitActivity = async (activity: Activity, milestone?: Milestone | null) => {
const evs = getEvidenceForActivity(activity.id); const evs = getEvidenceForActivity(activity.id);
setSubmittingActivityId(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 ( 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"> <div className="space-y-5">
{/* Row 1: Badges & Project Title */} {/* Row 1: Badges & Project Title */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3"> <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="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" /> <FolderKanban className="w-4 h-4" />
</div> </div>
{hashedProjectId && ( {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} #{hashedProjectId}
</span> </span>
)} )}
{renderStatusBadge(project.status, 'Active Implementation')} {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"> {/* Requirement #3: Display enum string description instead of integer value */}
Sector: {project.sector} {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> </span>
)} )}
{project.projectType && ( {projectTypeDescription && (
<span className="text-[11px] font-medium text-slate-300 bg-white/5 border border-white/10 px-2 py-0.5 rounded-md"> <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: {project.projectType} Type: {projectTypeDescription}
</span> </span>
)} )}
</div> </div>
@@ -296,32 +298,32 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<button <button
type="button" type="button"
onClick={toggleExpand} 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> <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> </button>
</div> </div>
{/* Row 2: Title and Description */} {/* Row 2: Title and Description */}
<div> <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}`} {project.name || `Project #${hashedProjectId}`}
</h3> </h3>
{project.description && ( {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} {project.description}
</p> </p>
)} )}
</div> </div>
{/* Row 3: Rest of Applicant Data (Funder, Recipient Org, Linked Contract & Award) */} {/* 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 */} {/* Funder */}
<div className="flex items-center gap-2.5"> <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"> <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"> <span className="text-slate-200 font-medium truncate block">
{fundingOrganisation?.name || 'Programme Office'} {fundingOrganisation?.name || 'Programme Office'}
</span> </span>
@@ -330,9 +332,9 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
{/* Recipient Organisation */} {/* Recipient Organisation */}
<div className="flex items-center gap-2.5"> <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"> <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"> <span className="text-slate-200 font-medium truncate block">
{invitedOrganisation?.name || 'Assigned Partner'} {invitedOrganisation?.name || 'Assigned Partner'}
</span> </span>
@@ -343,7 +345,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<FileText className="w-4 h-4 text-cyan-400 shrink-0" /> <FileText className="w-4 h-4 text-cyan-400 shrink-0" />
<div className="min-w-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 ? ( {contract ? (
<button <button
type="button" type="button"
@@ -364,7 +366,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<AwardIcon className="w-4 h-4 text-amber-400 shrink-0" /> <AwardIcon className="w-4 h-4 text-amber-400 shrink-0" />
<div className="min-w-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"> <span className="text-amber-300 font-medium font-mono truncate block">
{award ? `Award #${encodeHashIdSync(award.id)}` : 'Approved Award'} ({formatZAR(grantAmount)}) {award ? `Award #${encodeHashIdSync(award.id)}` : 'Approved Award'} ({formatZAR(grantAmount)})
</span> </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 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"> <div className="flex flex-wrap items-center gap-5 sm:gap-7">
{/* Total Award */} {/* Total Award */}
<div className="flex items-center gap-1.5 font-medium text-[#D1CBDD]"> <div className="flex items-center gap-1.5 font-medium text-slate-300">
<span className="p-1 rounded-md bg-purple-500/10 text-purple-400 flex items-center justify-center"> <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" /> <DollarSign className="w-3.5 h-3.5" />
</span> </span>
<span className="font-bold text-[#F1F1F6] font-mono">{formatZAR(grantAmount)}</span> <span className="font-bold text-slate-100 font-mono">{formatZAR(grantAmount)}</span>
<span className="text-[#9A95B0]">Award</span> <span className="text-slate-400">Award</span>
</div> </div>
{/* Spend Disbursed */} {/* Spend Disbursed */}
<div className="flex items-center gap-1.5 font-medium text-[#D1CBDD]"> <div className="flex items-center gap-1.5 font-medium text-slate-300">
<span className="p-1 rounded-md bg-[#F97316]/10 text-[#F97316] flex items-center justify-center"> <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" /> <DollarSign className="w-3.5 h-3.5" />
</span> </span>
<span className="font-bold text-[#F1F1F6] font-mono">{formatZAR(disbursedAmount)}</span> <span className="font-bold text-slate-100 font-mono">{formatZAR(disbursedAmount)}</span>
<span className="text-[#9A95B0]">Disbursed</span> <span className="text-slate-400">Disbursed</span>
</div> </div>
{/* Timeline */} {/* Timeline */}
<div className="flex items-center gap-1.5 font-medium text-[#D1CBDD]"> <div className="flex items-center gap-1.5 font-medium text-slate-300">
<span className="p-1 rounded-md bg-amber-500/10 text-amber-400 flex items-center justify-center"> <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" /> <Calendar className="w-3.5 h-3.5" />
</span> </span>
<span className="font-medium text-[#F1F1F6]"> <span className="font-medium text-slate-200">
{formatDate(project.startedAt || contract?.effectiveAt)} – {formatDate(project.endedAt || contract?.expiresAt)} {formatDate(project.startedAt || contract?.effectiveAt)} – {formatDate(project.endedAt || contract?.expiresAt)}
</span> </span>
<span className="text-[#9A95B0]">({daysLeft})</span> <span className="text-slate-400">({daysLeft})</span>
</div> </div>
{/* Deliverables Count */} {/* Deliverables Count */}
<div className="flex items-center gap-1.5 font-medium text-[#D1CBDD]"> <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"> <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" /> <Layers className="w-3.5 h-3.5" />
</span> </span>
<span className="font-semibold text-[#F1F1F6]">{effectiveMilestones.length} Milestones</span> <span className="font-semibold text-slate-100">{effectiveMilestones.length} Milestones</span>
<span className="text-[#9A95B0]">• {totalActivitiesCount} Activities</span> <span className="text-slate-400">• {totalActivitiesCount} Activities</span>
</div> </div>
{/* Docs Submitted */} {/* Docs Submitted */}
<div className="flex items-center gap-1.5 font-medium text-[#D1CBDD]"> <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"> <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" /> <Paperclip className="w-3.5 h-3.5" />
</span> </span>
<span className="font-bold text-[#F1F1F6] font-mono">{effectiveEvidence.length}</span> <span className="font-bold text-slate-100 font-mono">{effectiveEvidence.length}</span>
<span className="text-[#9A95B0]">Uploaded Proofs</span> <span className="text-slate-400">Uploaded Proofs</span>
</div> </div>
</div> </div>
{/* Evidence Completion Progress Pill */} {/* Evidence Completion Progress Pill */}
<div className="flex items-center gap-2"> <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> <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 <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}%` }} style={{ width: `${progressPercent}%` }}
/> />
</div> </div>
@@ -438,22 +440,21 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div> </div>
{/* ========================================================================= {/* =========================================================================
INLINE EXPANDED SECTION: MILESTONES & ACTIVITIES INLINE EXPANDED SECTION: MILESTONES & ACTIVITIES (Landing Page Palette)
(Followup Requirements #1, #2, #3)
========================================================================= */} ========================================================================= */}
{isExpanded && ( {isExpanded && (
<div className="mt-6 pt-6 border-t border-white/10 space-y-6 animate-in fade-in duration-300"> <div className="mt-6 pt-6 border-t border-white/10 space-y-6 animate-in fade-in duration-300">
{/* Section Sub-header & Guidance */} {/* 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> <div>
<h4 className="text-sm font-bold text-white flex items-center gap-2"> <h4 className="text-sm font-bold text-slate-100 flex items-center gap-2">
<Layers className="w-4 h-4 text-purple-400" /> <Layers className="w-4 h-4 text-emerald-400" />
<span>Project Milestones & Contractual Activities</span> <span>Project Milestones & Contractual Activities</span>
{isFetchingDetails && ( {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> </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. All milestones are enabled. Complete activities by uploading supporting evidence documents, then submit for review and approval by organisation staff.
</p> </p>
</div> </div>
@@ -462,13 +463,13 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
type="button" type="button"
onClick={() => refetchProjectData()} onClick={() => refetchProjectData()}
disabled={isFetchingDetails} 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" 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> <span>Refresh</span>
</button> </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" /> <Info className="w-3.5 h-3.5" />
<span>Fulfillment Only</span> <span>Fulfillment Only</span>
</div> </div>
@@ -477,18 +478,18 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
{/* LOADING STATE WHEN VIEW DETAILS IS FIRST CLICKED */} {/* LOADING STATE WHEN VIEW DETAILS IS FIRST CLICKED */}
{isLoadingDetails ? ( {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]"> <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-purple-400 animate-spin" /> <Loader2 className="w-6 h-6 text-emerald-400 animate-spin" />
<span>Fetching project milestones, activities, and evidence...</span> <span>Fetching project milestones, activities, and evidence...</span>
</div> </div>
) : sortedMilestones.length === 0 ? ( ) : 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" /> <Layers className="w-8 h-8 text-slate-600 mx-auto" />
<p>No contractual milestones recorded for this project yet.</p> <p>No contractual milestones recorded for this project yet.</p>
<button <button
type="button" type="button"
onClick={() => refetchProjectData()} 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" /> <RefreshCw className="w-3 h-3" />
<span>Click here to re-check schedule</span> <span>Click here to re-check schedule</span>
@@ -508,13 +509,13 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
return ( return (
<div <div
key={milestone.id} 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 */} {/* 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="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="space-y-1">
<div className="flex items-center gap-2 flex-wrap"> <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} Milestone #{mIdx + 1}
</span> </span>
<span className="font-mono text-[10px] text-slate-400"> <span className="font-mono text-[10px] text-slate-400">
@@ -528,19 +529,19 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</span> </span>
)} )}
</div> </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}`} {milestone.name || `Milestone Target #${mIdx + 1}`}
</h5> </h5>
</div> </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> <div>
<span className="text-[10px] text-slate-500 block uppercase font-bold">Start Date</span> <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> <span className="text-slate-300 font-mono">{formatDate(milestone.createdAt)}</span>
</div> </div>
<div> <div>
<span className="text-[10px] text-slate-500 block uppercase font-bold">Due Date</span> <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>
<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"> <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 {milestoneFulfilledActs.length} / {milestoneActs.length} Fulfilled
@@ -548,9 +549,9 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div> </div>
</div> </div>
{/* NESTED ACTIVITIES LISTING (Requirement #1: Fetched for each milestone) */} {/* NESTED ACTIVITIES LISTING */}
<div className="space-y-3 pt-1"> <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>Activities Under Milestone ({milestoneActs.length})</span>
<span className="text-[10px] font-normal normal-case text-slate-500"> <span className="text-[10px] font-normal normal-case text-slate-500">
Sorted by execution date Sorted by execution date
@@ -558,7 +559,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div> </div>
{milestoneActs.length === 0 ? ( {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. No specific activities defined under this milestone.
</div> </div>
) : ( ) : (
@@ -572,17 +573,17 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
return ( return (
<div <div
key={act.id} 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 */} {/* Activity Details Row */}
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3"> <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="space-y-1.5 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <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)}`} {act.title || `Activity #${encodeHashIdSync(act.id)}`}
</span> </span>
{act.category && ( {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} {act.category}
</span> </span>
)} )}
@@ -597,12 +598,12 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
Submitted • Staff Review Pending Submitted • Staff Review Pending
</span> </span>
) : hasEvidence ? ( ) : 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"> <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-cyan-400" /> <CheckCircle2 className="w-3 h-3 text-teal-400" />
{actEvidences.length} Proof{actEvidences.length > 1 ? 's' : ''} Attached (Ready) {actEvidences.length} Proof{actEvidences.length > 1 ? 's' : ''} Attached (Ready)
</span> </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" /> <Clock className="w-3 h-3 text-slate-500" />
Evidence Required Evidence Required
</span> </span>
@@ -610,7 +611,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div> </div>
{act.description && ( {act.description && (
<p className="text-xs text-[#9A95B0] leading-relaxed"> <p className="text-xs text-slate-300 leading-relaxed">
{act.description} {act.description}
</p> </p>
)} )}
@@ -627,24 +628,22 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
{/* Action Buttons: Add Evidence + Submit for Approval */} {/* Action Buttons: Add Evidence + Submit for Approval */}
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
{/* Add Evidence Button (Requirement #3: Allow user to add evidence, once uploaded refresh data) */}
<button <button
type="button" type="button"
onClick={() => setUploadTarget({ project, milestone, activity: act })} 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" title="Add supporting evidence document for this activity"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
<span>{hasEvidence ? 'Add More Evidence' : 'Add Evidence'}</span> <span>{hasEvidence ? 'Add More Evidence' : 'Add Evidence'}</span>
</button> </button>
{/* Submit for Approval Button */}
{!isApproved && !isUnderReview && ( {!isApproved && !isUnderReview && (
<button <button
type="button" type="button"
onClick={() => handleSubmitActivity(act, milestone)} onClick={() => handleSubmitActivity(act, milestone)}
disabled={!hasEvidence || submittingActivityId === act.id} 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'} title={hasEvidence ? 'Submit uploaded evidence for organisation staff approval' : 'Upload at least one document before submitting for approval'}
> >
{submittingActivityId === act.id ? ( {submittingActivityId === act.id ? (
@@ -658,7 +657,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div> </div>
</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 && ( {actEvidences.length > 0 && (
<div className="pt-2 border-t border-white/5 space-y-2"> <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"> <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) => ( {actEvidences.map((ev) => (
<div <div
key={ev.id} 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"> <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"> <div className="min-w-0">
<p className="text-slate-200 font-medium truncate"> <p className="text-slate-200 font-medium truncate">
{(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`} {(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}
</p> </p>
<p className="text-[10px] text-slate-500 truncate"> <p className="text-[10px] text-slate-400 truncate">
<span className="font-semibold text-purple-300"> <span className="font-semibold text-emerald-300">
{getEvidenceTypeLabel(ev.type)} {getEvidenceTypeLabel(ev.type)}
</span>{' '} </span>{' '}
• {formatDateTime(ev.createdAt)} • {formatDateTime(ev.createdAt)}
@@ -694,7 +693,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<button <button
type="button" type="button"
onClick={() => onOpenSecureFile('evidence', ev.id)} 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" title="View or download secure file"
> >
<ExternalLink className="w-3.5 h-3.5" /> <ExternalLink className="w-3.5 h-3.5" />
@@ -719,13 +718,13 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
{/* UNASSIGNED ACTIVITIES */} {/* UNASSIGNED ACTIVITIES */}
{unassignedActivities.length > 0 && ( {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"> <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" /> <Layers className="w-4 h-4 text-cyan-400" />
<span>General Project Activities</span> <span>General Project Activities</span>
</h5> </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. Activities not bound to a specific milestone. Fulfill by submitting required documents.
</p> </p>
</div> </div>
@@ -740,18 +739,18 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
return ( return (
<div <div
key={act.id} 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="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
<div className="space-y-1 min-w-0"> <div className="space-y-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap"> <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)}`} {act.title || `Activity #${encodeHashIdSync(act.id)}`}
</span> </span>
{renderStatusBadge(act.status, 'Pending Evidence')} {renderStatusBadge(act.status, 'Pending Evidence')}
</div> </div>
{act.description && ( {act.description && (
<p className="text-xs text-[#9A95B0] leading-relaxed"> <p className="text-xs text-slate-300 leading-relaxed">
{act.description} {act.description}
</p> </p>
)} )}
@@ -765,7 +764,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
<button <button
type="button" type="button"
onClick={() => setUploadTarget({ project, activity: act })} 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" /> <Plus className="w-3.5 h-3.5" />
<span>{hasEvidence ? 'Add More Evidence' : 'Add Evidence'}</span> <span>{hasEvidence ? 'Add More Evidence' : 'Add Evidence'}</span>
@@ -776,7 +775,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
type="button" type="button"
onClick={() => handleSubmitActivity(act, null)} onClick={() => handleSubmitActivity(act, null)}
disabled={!hasEvidence || submittingActivityId === act.id} 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" /> <Send className="w-3.5 h-3.5" />
<span>Submit for Approval</span> <span>Submit for Approval</span>
@@ -790,20 +789,20 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
{actEvidences.map((ev) => ( {actEvidences.map((ev) => (
<div <div
key={ev.id} 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"> <div className="min-w-0 pr-2">
<span className="text-slate-200 truncate block"> <span className="text-slate-200 truncate block">
{(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`} {(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}
</span> </span>
<span className="text-[10px] text-slate-500"> <span className="text-[10px] text-slate-400">
{getEvidenceTypeLabel(ev.type)} • {formatDateTime(ev.createdAt)} {getEvidenceTypeLabel(ev.type)} • {formatDateTime(ev.createdAt)}
</span> </span>
</div> </div>
<button <button
type="button" type="button"
onClick={() => onOpenSecureFile('evidence', ev.id)} 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" /> <ExternalLink className="w-3.5 h-3.5" />
</button> </button>
@@ -820,9 +819,7 @@ export const ProjectCard: React.FC<ProjectCardProps> = ({
</div> </div>
)} )}
{/* ========================================================================= {/* EVIDENCE UPLOAD MODAL */}
EVIDENCE UPLOAD MODAL (Requirement #3: Allow user to add evidence, once uploaded refresh data)
========================================================================= */}
{uploadTarget && ( {uploadTarget && (
<EvidenceUploadModal <EvidenceUploadModal
isOpen={true} isOpen={true}
@@ -462,14 +462,36 @@ export const getOrganisationTypeLabel = (type: OrganisationType | number): strin
return opt ? opt.label : 'Organisation'; return opt ? opt.label : 'Organisation';
}; };
export const getIndustrySectorLabel = (sector: IndustrySector | number): string => { export const getIndustrySectorLabel = (sector?: IndustrySector | number | string | null): string => {
const opt = INDUSTRY_SECTOR_OPTIONS.find((o) => o.value === Number(sector)); if (sector === undefined || sector === null || sector === '') return '';
return opt ? opt.label : 'Industry Sector'; 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 => { export const getProjectTypeLabel = (type?: ProjectType | number | string | null): string => {
const opt = PROJECT_TYPE_OPTIONS.find((o) => o.value === Number(type)); if (type === undefined || type === null || type === '') return '';
return opt ? opt.label : 'Project Type'; 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 => { export const getEvidenceTypeLabel = (type: EvidenceType | number): string => {
@@ -5,13 +5,17 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
? import.meta.env ? import.meta.env
: {}) as Record<string, string | undefined>; : {}) 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. * Deterministic Hashids configuration matching pfm-api HasherSettings.
* Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend. * Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend.
*/ */
export const HASHER_SETTINGS = { export const HASHER_SETTINGS = {
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11, minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
salt: (env.VITE_HASHER_SALT as string) || '', salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
}; };
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength); 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() !== '') { if (salt && typeof salt === 'string' && salt.trim() !== '') {
HASHER_SETTINGS.salt = salt.trim(); HASHER_SETTINGS.salt = salt.trim();
updated = true; updated = true;
} else if (!HASHER_SETTINGS.salt) {
HASHER_SETTINGS.salt = DEFAULT_HASHER_SALT;
updated = true;
} }
if (minLength !== undefined && minLength !== null && minLength !== '') { 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) { if (!isNaN(parsed) && parsed > 0) {
HASHER_SETTINGS.minHashLength = parsed; HASHER_SETTINGS.minHashLength = parsed;
updated = true; 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 ( if (
!candidate ||
typeof candidate !== 'string' || typeof candidate !== 'string' ||
candidate.length < HASHER_SETTINGS.minHashLength || candidate.length < HASHER_SETTINGS.minHashLength ||
/^\d+$/.test(candidate) /^\d+$/.test(candidate)
@@ -47,20 +56,28 @@ export function isAlreadyHashId(candidate: string): boolean {
if (!Array.isArray(decoded) || decoded.length === 0) { if (!Array.isArray(decoded) || decoded.length === 0) {
return false; return false;
} }
return hasher.encode(decoded as number[]) === candidate; return hasher.encode(decoded as any) === candidate;
} catch { } catch {
return false; 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 === '') { if (id === undefined || id === null || id === '') {
return ''; return '';
} }
if (typeof id === 'bigint') {
if (id < 0n) return '';
return hasher.encode(id);
}
if (typeof id === 'number') { if (typeof id === 'number') {
if (isNaN(id) || !isFinite(id) || id < 0) return ''; 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(); const str = String(id).trim();
@@ -72,10 +89,8 @@ export function encodeHashIdSync(id: number | string | null | undefined): string
if (/^\d+$/.test(str)) { if (/^\d+$/.test(str)) {
try { try {
const num = parseInt(str, 10); // JavaScript hashids v2 natively supports BigInt, matching C# Hashids.net EncodeLong without int64 precision loss
if (!isNaN(num) && num >= 0) { return hasher.encode(BigInt(str));
return hasher.encode(num);
}
} catch { } catch {
// Fall through // Fall through
} }
@@ -84,22 +99,37 @@ export function encodeHashIdSync(id: number | string | null | undefined): string
return str; 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 (hashId === undefined || hashId === null || hashId === '') return '';
if (typeof hashId === 'number') return hashId; if (typeof hashId === 'number') return hashId;
if (typeof hashId === 'bigint') return hashId.toString();
const str = String(hashId).trim(); const str = String(hashId).trim();
if (!str) return ''; if (!str) return '';
if (/^-?\d+$/.test(str)) { if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10); try {
return isNaN(parsed) ? str : parsed; 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 { try {
const decoded = hasher.decode(str); const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) { 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 { } catch {
// If decoding fails, return raw string // If decoding fails, return raw string
@@ -108,11 +138,14 @@ export function decodeHashIdSync(hashId: string | number | null | undefined): nu
return str; 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 (hashId === null || hashId === undefined || hashId === '') return 0;
if (typeof hashId === 'number') { if (typeof hashId === 'number') {
return isNaN(hashId) ? 0 : Math.trunc(hashId); return isNaN(hashId) ? 0 : Math.trunc(hashId);
} }
if (typeof hashId === 'bigint') {
return Number(hashId);
}
const str = String(hashId).trim(); const str = String(hashId).trim();
if (/^-?\d+$/.test(str)) { if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10); const parsed = parseInt(str, 10);
@@ -129,19 +162,19 @@ export function decodeToNumberSync(hashId: number | string | null | undefined):
} }
export const toolsService = { export const toolsService = {
encodeIdSync(id: number | string | null | undefined): string { encodeIdSync(id: number | string | bigint | null | undefined): string {
return encodeHashIdSync(id); return encodeHashIdSync(id);
}, },
decodeIdSync(hashId: string | number | null | undefined): number | string { decodeIdSync(hashId: string | number | bigint | null | undefined): number | string {
return decodeHashIdSync(hashId); return decodeHashIdSync(hashId);
}, },
decodeToNumberSync(hashId: number | string | null | undefined): number { decodeToNumberSync(hashId: number | string | bigint | null | undefined): number {
return decodeToNumberSync(hashId); 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); return encodeHashIdSync(id);
}, },
@@ -149,35 +182,35 @@ export const toolsService = {
return decodeHashIdSync(hashId); return decodeHashIdSync(hashId);
}, },
async resolveProjectId(projectId: number | string): Promise<string> { async resolveProjectId(projectId: number | string | bigint): Promise<string> {
return encodeHashIdSync(projectId); return encodeHashIdSync(projectId);
}, },
async resolveOrganisationId(organisationId: number | string): Promise<string> { async resolveOrganisationId(organisationId: number | string | bigint): Promise<string> {
return encodeHashIdSync(organisationId); return encodeHashIdSync(organisationId);
}, },
async resolveProgrammeId(programmeId: number | string): Promise<string> { async resolveProgrammeId(programmeId: number | string | bigint): Promise<string> {
return encodeHashIdSync(programmeId); return encodeHashIdSync(programmeId);
}, },
async resolvePortfolioId(portfolioId: number | string): Promise<string> { async resolvePortfolioId(portfolioId: number | string | bigint): Promise<string> {
return encodeHashIdSync(portfolioId); return encodeHashIdSync(portfolioId);
}, },
async resolveAwardId(awardId: number | string): Promise<string> { async resolveAwardId(awardId: number | string | bigint): Promise<string> {
return encodeHashIdSync(awardId); return encodeHashIdSync(awardId);
}, },
async resolveContractId(contractId: number | string): Promise<string> { async resolveContractId(contractId: number | string | bigint): Promise<string> {
return encodeHashIdSync(contractId); return encodeHashIdSync(contractId);
}, },
async resolveActivityId(activityId: number | string): Promise<string> { async resolveActivityId(activityId: number | string | bigint): Promise<string> {
return encodeHashIdSync(activityId); return encodeHashIdSync(activityId);
}, },
async resolveMilestoneId(milestoneId: number | string): Promise<string> { async resolveMilestoneId(milestoneId: number | string | bigint): Promise<string> {
return encodeHashIdSync(milestoneId); 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({ return useQuery({
queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''], queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''],
queryFn: () => Promise.resolve(encodeHashIdSync(id)), queryFn: () => Promise.resolve(encodeHashIdSync(id)),
@@ -4,9 +4,13 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
? import.meta.env ? import.meta.env
: {}) as Record<string, string | undefined>; : {}) as Record<string, string | undefined>;
export const DEFAULT_HASHER_SALT =
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
export const DEFAULT_MIN_HASH_LENGTH = 11;
export const HASHER_SETTINGS = { export const HASHER_SETTINGS = {
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11, minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
salt: (env.VITE_HASHER_SALT as string) || '', salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
}; };
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength); 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() !== '') { if (salt && typeof salt === 'string' && salt.trim() !== '') {
HASHER_SETTINGS.salt = salt.trim(); HASHER_SETTINGS.salt = salt.trim();
updated = true; updated = true;
} else if (!HASHER_SETTINGS.salt) {
HASHER_SETTINGS.salt = DEFAULT_HASHER_SALT;
updated = true;
} }
if (minLength !== undefined && minLength !== null && minLength !== '') { 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) { if (!isNaN(parsed) && parsed > 0) {
HASHER_SETTINGS.minHashLength = parsed; HASHER_SETTINGS.minHashLength = parsed;
updated = true; updated = true;
@@ -46,7 +54,7 @@ export function isHashId(candidate: string | null | undefined): boolean {
if ( if (
Array.isArray(decodedNumbers) && Array.isArray(decodedNumbers) &&
decodedNumbers.length > 0 && decodedNumbers.length > 0 &&
hasher.encode(decodedNumbers as number[]) === candidate hasher.encode(decodedNumbers as any) === candidate
) { ) {
return true; 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. * 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 { export function encodeEntityId(id: string | number | bigint | null | undefined): string {
if (id === undefined || id === null || id === '') return ''; 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 (!str) return '';
if (isHashId(str)) return str; if (isHashId(str)) return str;
if (/^\d+$/.test(str)) { if (/^\d+$/.test(str)) {
try { try {
const num = Number(str); return hasher.encode(BigInt(str));
if (num <= Number.MAX_SAFE_INTEGER && num >= 0) {
return hasher.encode(num);
}
const bigHex = BigInt(str).toString(16);
return hasher.encodeHex(bigHex);
} catch { } catch {
// Fall through // Fall through
} }
@@ -189,18 +198,9 @@ export function decodeEntityId(hashId: string | number | null | undefined): stri
if (Array.isArray(decoded) && decoded.length > 0) { if (Array.isArray(decoded) && decoded.length > 0) {
return decoded[0].toString(); return decoded[0].toString();
} }
const decodedHex = hasher.decodeHex(str);
if (decodedHex) {
try {
return BigInt('0x' + decodedHex).toString();
} catch {
return decodedHex;
}
}
} catch { } catch {
// Return raw // Return raw
} }
return str; return str;
} }