diff --git a/PostFundManagement.Application/Services/Shared/HashService.cs b/PostFundManagement.Application/Services/Shared/HashService.cs index ca489d2..7c4309d 100644 --- a/PostFundManagement.Application/Services/Shared/HashService.cs +++ b/PostFundManagement.Application/Services/Shared/HashService.cs @@ -51,8 +51,10 @@ public sealed partial class HashService(IHashids hasher) : IService int[] decoded = hasher.Decode(hash); - return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Int hash token.")) - .ToResult(decoded[0]); + if (decoded.Length <= 0) + return Result.Fail(ValidationError.Create("Invalid or modified Int hash token.")); + + return Result.Ok(decoded[0]); } public Result DecodeLongIdHash(string hash) @@ -62,8 +64,10 @@ public sealed partial class HashService(IHashids hasher) : IService long[] decoded = hasher.DecodeLong(hash); - return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Long hash token.")) - .ToResult(decoded[0]); + if (decoded.Length <= 0) + return Result.Fail(ValidationError.Create("Invalid or modified Long hash token.")); + + return Result.Ok(decoded[0]); } public async Task> DecodeHexHash(string hex) => diff --git a/PostFundManagement.ConsoleUI/docker-entrypoint.sh b/PostFundManagement.ConsoleUI/docker-entrypoint.sh index 350e640..abab790 100644 --- a/PostFundManagement.ConsoleUI/docker-entrypoint.sh +++ b/PostFundManagement.ConsoleUI/docker-entrypoint.sh @@ -30,7 +30,7 @@ OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__Cli OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}" # 6. Hasher Settings -HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}}" +HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}" HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==}}}}" if [ -d "$TARGET_DIR" ]; then diff --git a/PostFundManagement.PartnerUI/.env.example b/PostFundManagement.PartnerUI/.env.example index 7a550fe..6a8e7d8 100644 --- a/PostFundManagement.PartnerUI/.env.example +++ b/PostFundManagement.PartnerUI/.env.example @@ -1,9 +1,9 @@ # GEMINI_API_KEY: Required for Gemini AI API calls. -# AI Studio automatically injects this at runtime from user secrets. -# Users configure this via the Secrets panel in the AI Studio UI. GEMINI_API_KEY="MY_GEMINI_API_KEY" # APP_URL: The URL where this applet is hosted. -# AI Studio automatically injects this at runtime with the Cloud Run service URL. -# Used for self-referential links, OAuth callbacks, and API endpoints. APP_URL="MY_APP_URL" + +# HasherSettings for HashIds compatibility with backend pfm-api +VITE_HASHER_MIN_LENGTH="11" +VITE_HASHER_SALT="TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==" diff --git a/PostFundManagement.PartnerUI/docker-entrypoint.sh b/PostFundManagement.PartnerUI/docker-entrypoint.sh index a0defa4..e6263dd 100644 --- a/PostFundManagement.PartnerUI/docker-entrypoint.sh +++ b/PostFundManagement.PartnerUI/docker-entrypoint.sh @@ -29,17 +29,15 @@ OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__Cli # 5. OIDC Scope OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}" -# 6. Hasher Settings -HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}" +# 6. Hasher Settings (Fix: Exactly 4 closing braces matching the 4 open variable expansions) +HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}" if [ -d "$TARGET_DIR" ]; then echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..." # Generate env-config.js for instant, synchronous window.__RUNTIME_CONFIG__ initialization - # Public SPA clients use Authorization Code flow with PKCE; client secrets and hasher salts must NEVER be injected or exposed here. + # Public SPA clients: salt secrets and client secrets must NEVER be injected or exposed in public static files. cat < "$TARGET_DIR/env-config.js" -// Generated dynamically at container startup from deployment environment variables -// Note: Secrets such as client secrets, private keys, or hashing salts must NEVER be exposed here. window.__RUNTIME_CONFIG__ = { API_BASE_URL: "${API_BASE_URL}", VITE_API_BASE_URL: "${API_BASE_URL}", diff --git a/PostFundManagement.PartnerUI/public/config.json b/PostFundManagement.PartnerUI/public/config.json index a03ad0a..3d892ad 100644 --- a/PostFundManagement.PartnerUI/public/config.json +++ b/PostFundManagement.PartnerUI/public/config.json @@ -10,6 +10,6 @@ "BaseUrl": "" }, "HasherSettings": { - "MinHashLength": "" + "MinHashLength": "11" } } diff --git a/PostFundManagement.PartnerUI/public/env-config.js b/PostFundManagement.PartnerUI/public/env-config.js index bedb84d..fb7ce5b 100644 --- a/PostFundManagement.PartnerUI/public/env-config.js +++ b/PostFundManagement.PartnerUI/public/env-config.js @@ -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" + } +}; diff --git a/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx b/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx index be2f497..d0422a7 100644 --- a/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx +++ b/PostFundManagement.PartnerUI/src/components/dashboard/DashboardPage.tsx @@ -63,6 +63,7 @@ import { import { formatZAR, formatCompactZAR } from '../../lib/formatters'; import { MessageViewer } from '../notifications/MessageViewer'; import { stripMarkdown } from '../../lib/markdownUtils'; +import { PfmPartnerLogo } from '../common/PfmPartnerLogo'; import { ProjectCard, EnrichedPartnerProject } from './ProjectCard'; import { EvidenceUploadModal } from './EvidenceUploadModal'; @@ -499,20 +500,25 @@ export function DashboardPage() { ]; return ( -
+
+ {/* Ambient background glow from landing page */} +
{/* ===================================================================== TOP NAVIGATION BAR — 72PX, FULL-WIDTH EDGE-TO-EDGE ===================================================================== */} -
+
{/* Left: Brand Glyph & Horizontal Navigation Links */}
setActiveTab('dashboard')} title="Dashboard Home" > - + + + PFM Partner +
{/* Nav Tabs */} @@ -526,13 +532,13 @@ export function DashboardPage() { onClick={() => setActiveTab(tab.id)} className={`relative h-full flex items-center text-sm transition-colors cursor-pointer ${ isActive - ? 'font-bold text-[#F1F1F6]' - : 'font-medium text-[#9A95B0] hover:text-[#F1F1F6]' + ? 'font-bold text-slate-100' + : 'font-medium text-slate-400 hover:text-slate-100' }`} > {tab.label} {isActive && ( - + )} ); @@ -544,19 +550,19 @@ export function DashboardPage() {
{/* Search Input */}
- + 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 && ( @@ -569,9 +575,9 @@ export function DashboardPage() { onClick={() => refetchApplicantData()} title="Refresh projects" disabled={isRefreshingApplicantData} - className="p-2 rounded-xl bg-[#181622] border border-white/[0.06] text-[#9A95B0] hover:text-[#F1F1F6] transition-colors cursor-pointer disabled:opacity-50" + className="p-2 rounded-xl bg-slate-900/80 border border-white/10 text-slate-400 hover:text-emerald-400 hover:border-emerald-500/30 transition-colors cursor-pointer disabled:opacity-50" > - + {/* Notifications Bell */} @@ -582,12 +588,12 @@ export function DashboardPage() { setShowBellDropdown(!showBellDropdown); setShowUserDropdown(false); }} - className="relative p-2 rounded-xl bg-[#181622] border border-white/[0.06] text-[#9A95B0] hover:text-[#F1F1F6] transition-colors cursor-pointer" + className="relative p-2 rounded-xl bg-slate-900/80 border border-white/10 text-slate-400 hover:text-emerald-400 hover:border-emerald-500/30 transition-colors cursor-pointer" title="Notifications" > {unreadCount > 0 && ( - + {unreadCount > 9 ? '9+' : unreadCount} )} @@ -595,14 +601,14 @@ export function DashboardPage() { {/* Bell Dropdown */} {showBellDropdown && ( -
+
- Notifications + Notifications {unreadCount > 0 && ( @@ -610,7 +616,7 @@ export function DashboardPage() {
{top5Unread.length === 0 ? ( -
No unread notifications
+
No unread notifications
) : ( top5Unread.map(item => (
-

{item.subject}

-

{stripMarkdown(item.message)}

+

{item.subject}

+

{stripMarkdown(item.message)}

)) )} @@ -635,7 +641,7 @@ export function DashboardPage() { setActiveTab('messages'); setShowBellDropdown(false); }} - className="text-xs text-[#8B5CF6] hover:underline font-medium cursor-pointer" + className="text-xs text-emerald-400 hover:underline font-medium cursor-pointer" > View all messages @@ -652,22 +658,22 @@ export function DashboardPage() { setShowUserDropdown(!showUserDropdown); setShowBellDropdown(false); }} - className="flex items-center gap-2 p-1.5 pl-2 rounded-xl bg-[#181622] border border-white/[0.06] hover:border-white/[0.12] transition-colors cursor-pointer" + className="flex items-center gap-2 p-1.5 pl-2 rounded-xl bg-slate-900/80 border border-white/[0.06] hover:border-white/[0.12] transition-colors cursor-pointer" > -
+
{getInitials(user?.profile?.name || user?.profile?.email || 'Partner')}
- + {user?.profile?.name || 'Partner Account'} - + {showUserDropdown && ( -
+
-

{user?.profile?.name || 'Partner User'}

-

{user?.profile?.email || ''}

+

{user?.profile?.name || 'Partner User'}

+

{user?.profile?.email || ''}

{partnerSummary.primaryInvitedOrg && (

{partnerSummary.primaryInvitedOrg.name} @@ -696,8 +702,8 @@ export function DashboardPage() {

{isLoadingApplicantData ? (
- -

Loading your funding portfolio & projects...

+ +

Loading your funding portfolio & projects...

) : activeTab === 'dashboard' ? ( /* =================================================================== @@ -707,10 +713,10 @@ export function DashboardPage() { {/* Top Bar: Title & Sort Options */}
-

+

Ongoing Projects

-

+

Click “View Details” on any project to inspect its milestones, track progress, and fulfill activity evidence requirements.

@@ -720,14 +726,14 @@ export function DashboardPage() { - +
@@ -747,18 +753,18 @@ export function DashboardPage() { )) ) : ( /* Empty State */ -
+
-
+
-

+

{searchQuery ? 'No Matching Projects Found' : 'No Active Projects Assigned Yet'}

-

+

{searchQuery ? `We couldn't find any ongoing projects matching "${searchQuery}". Clear your search query to see your full workspace portfolio.` : "Welcome to your Partner Workspace! Your funding organisation hasn't assigned any active project records to your profile just yet. If your funding award was recently approved or updated, click below to sync and retrieve your latest project schedule."} @@ -769,7 +775,7 @@ export function DashboardPage() {

-
+
{inboxNotifications.length === 0 ? ( -
+
No new messages in your inbox yet. Messages sent by programme managers will be displayed here.
) : ( @@ -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" >
-
+
{getInitials(msg.subject || 'PFM')}
-

+

{msg.subject || 'Programme Notification'}

{isUnread && ( @@ -851,10 +857,10 @@ export function DashboardPage() { )}
-

{stripMarkdown(msg.message || '')}

+

{stripMarkdown(msg.message || '')}

- + {formatFullDate(msg.createdAt)}
@@ -869,15 +875,15 @@ export function DashboardPage() { =================================================================== */
-

Contracts & Approved Awards

-

+

Contracts & Approved Awards

+

Review your approved funding awards and download or sign grant agreements.

{partnerSummary.contractEntries.length === 0 ? ( -
+
No contracts have been issued to your organisation yet.
) : ( @@ -887,11 +893,11 @@ export function DashboardPage() { return (
- + Agreement v{contractVer}
-

+

{fundingOrganisation?.name || 'Grant Funding Agreement'}

-

Contract Value

-

+

Contract Value

+

{formatZAR(contract.totalValue || award?.amount || 0)}

-

Valid Period

+

Valid Period

{formatShortDate(contract.effectiveAt)} – {formatFullDate(contract.expiresAt)}

@@ -927,7 +933,7 @@ export function DashboardPage() {
-
+
From: {selectedNotification.destination?.split('|')[0] || 'Programme Management Office'} {formatFullDate(selectedNotification.createdAt)}
diff --git a/PostFundManagement.PartnerUI/src/components/dashboard/ProjectCard.tsx b/PostFundManagement.PartnerUI/src/components/dashboard/ProjectCard.tsx index bd8b88b..12d2c9d 100644 --- a/PostFundManagement.PartnerUI/src/components/dashboard/ProjectCard.tsx +++ b/PostFundManagement.PartnerUI/src/components/dashboard/ProjectCard.tsx @@ -18,7 +18,11 @@ import { getProjectEvidence, submitActivityForApproval } from '../../services/api'; -import { getEvidenceTypeLabel } from '../../lib/formatters'; +import { + getEvidenceTypeLabel, + getIndustrySectorLabel, + getProjectTypeLabel +} from '../../lib/formatters'; import { EvidenceUploadModal } from './EvidenceUploadModal'; import { FolderKanban, @@ -76,7 +80,7 @@ export const ProjectCard: React.FC = ({ const [submittingActivityId, setSubmittingActivityId] = useState(null); - // Evidence upload modal state (Requirement #3: Allow user to add evidence, once uploaded refresh data) + // Evidence upload modal state const [uploadTarget, setUploadTarget] = useState<{ project: Project; milestone?: Milestone | null; @@ -120,7 +124,7 @@ export const ProjectCard: React.FC = ({ } }; - // Date & Time formatter (Requirement #2: sorted by time, displaying time) + // Date & Time formatter const formatDateTime = (dateStr?: string | null): string => { if (!dateStr) return 'TBD'; try { @@ -131,12 +135,12 @@ export const ProjectCard: React.FC = ({ } }; - // Status Badge Helper + // Status Badge Helper using Landing Page emerald/teal/amber color palette const renderStatusBadge = (status?: any, fallback = 'Active') => { const num = Number(status); if (num === ApprovalStatus.Approved || String(status).toLowerCase().includes('approved')) { return ( - + Approved @@ -144,23 +148,21 @@ export const ProjectCard: React.FC = ({ } if (num === ApprovalStatus.Review || String(status).toLowerCase().includes('review')) { return ( - + Under Review ); } return ( - + + {String(status || fallback)} ); }; - // ========================================================================= - // ON-DEMAND FETCH: WHEN "View details" IS CLICKED, FETCH USING projectId ON HAND - // (Followup Requirement #1 & #2) - // ========================================================================= + // On-demand fetch when expanded const { data: projectDetails, isLoading: isLoadingDetails, @@ -195,12 +197,11 @@ export const ProjectCard: React.FC = ({ staleTime: 1000 * 20, }); - // Effective data sources (prioritize freshly fetched projectDetails, fallback to item arrays) const effectiveMilestones: Milestone[] = projectDetails?.milestones ?? (item.milestones?.length ? item.milestones : []); const effectiveActivities: Activity[] = projectDetails?.activities ?? (item.activities?.length ? item.activities : []); const effectiveEvidence: Evidence[] = projectDetails?.evidence ?? (item.evidence?.length ? item.evidence : []); - // 1. Sort Milestones according to Start Date (or createdAt) then Due Date (Requirement #5) + // Sort Milestones according to Start Date then Due Date const sortedMilestones = [...effectiveMilestones].sort((a, b) => { const startA = new Date(a.createdAt || a.dueAt || 0).getTime(); const startB = new Date(b.createdAt || b.dueAt || 0).getTime(); @@ -208,7 +209,6 @@ export const ProjectCard: React.FC = ({ return new Date(a.dueAt || 0).getTime() - new Date(b.dueAt || 0).getTime(); }); - // 2. Fetch project evidence, list existing evidence under matching activity by activityId sorted by time (Requirement #2) const getEvidenceForActivity = (activityId: string | number): Evidence[] => { const targetNum = toIntegerId(activityId); return effectiveEvidence @@ -216,7 +216,6 @@ export const ProjectCard: React.FC = ({ .sort((e1, e2) => new Date(e2.createdAt || 0).getTime() - new Date(e1.createdAt || 0).getTime()); }; - // 3. Nested activities matching MilestoneId sorted by date (Requirement #6) const getActivitiesForMilestone = (milestoneId: string | number): Activity[] => { const targetMilestoneNum = toIntegerId(milestoneId); return effectiveActivities @@ -228,7 +227,6 @@ export const ProjectCard: React.FC = ({ }); }; - // Unassigned activities const unassignedActivities = effectiveActivities .filter(a => !a.milestoneId || toIntegerId(a.milestoneId) === 0) .sort((a, b) => { @@ -237,7 +235,6 @@ export const ProjectCard: React.FC = ({ return dateA - dateB; }); - // Evidence fulfillment progress const totalActivitiesCount = effectiveActivities.length; const fulfilledActivitiesCount = effectiveActivities.filter(a => { const evs = getEvidenceForActivity(a.id); @@ -248,7 +245,6 @@ export const ProjectCard: React.FC = ({ ? Math.round((fulfilledActivitiesCount / totalActivitiesCount) * 100) : (effectiveMilestones.length > 0 ? 0 : 100); - // Handle submit activity for approval const handleSubmitActivity = async (activity: Activity, milestone?: Milestone | null) => { const evs = getEvidenceForActivity(activity.id); setSubmittingActivityId(activity.id); @@ -262,32 +258,38 @@ export const ProjectCard: React.FC = ({ } }; + // Enum String Descriptions (Requirement #3) + const sectorDescription = getIndustrySectorLabel(project.sector); + const projectTypeDescription = getProjectTypeLabel(project.projectType); + return ( -
+
{/* ========================================================================= - TOP PORTION: FULL-WIDTH HEADER, METADATA FROM APPLICANT DATA & METRICS + TOP PORTION: FULL-WIDTH HEADER, METADATA & METRICS (Landing Page Theme) ========================================================================= */}
{/* Row 1: Badges & Project Title */}
-
+
{hashedProjectId && ( - + #{hashedProjectId} )} {renderStatusBadge(project.status, 'Active Implementation')} - {project.sector && ( - - Sector: {project.sector} + + {/* Requirement #3: Display enum string description instead of integer value */} + {sectorDescription && ( + + Sector: {sectorDescription} )} - {project.projectType && ( - - Type: {project.projectType} + {projectTypeDescription && ( + + Type: {projectTypeDescription} )}
@@ -296,32 +298,32 @@ export const ProjectCard: React.FC = ({
{/* Row 2: Title and Description */}
-

+

{project.name || `Project #${hashedProjectId}`}

{project.description && ( -

+

{project.description}

)}
{/* Row 3: Rest of Applicant Data (Funder, Recipient Org, Linked Contract & Award) */} -
+
{/* Funder */}
- +
- Funding Organisation + Funding Organisation {fundingOrganisation?.name || 'Programme Office'} @@ -330,9 +332,9 @@ export const ProjectCard: React.FC = ({ {/* Recipient Organisation */}
- +
- Partner Recipient + Partner Recipient {invitedOrganisation?.name || 'Assigned Partner'} @@ -343,7 +345,7 @@ export const ProjectCard: React.FC = ({
- Grant Agreement + Grant Agreement {contract ? ( -
+
Fulfillment Only
@@ -477,18 +478,18 @@ export const ProjectCard: React.FC = ({ {/* LOADING STATE WHEN VIEW DETAILS IS FIRST CLICKED */} {isLoadingDetails ? ( -
- +
+ Fetching project milestones, activities, and evidence...
) : sortedMilestones.length === 0 ? ( -
+

No contractual milestones recorded for this project yet.

- {/* Submit for Approval Button */} {!isApproved && !isUnderReview && (
- {/* 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 && (
@@ -672,16 +671,16 @@ export const ProjectCard: React.FC = ({ {actEvidences.map((ev) => (
- +

{(ev as any).title || ev.fileName || `Evidence #${encodeHashIdSync(ev.id)}`}

-

- +

+ {getEvidenceTypeLabel(ev.type)} {' '} • {formatDateTime(ev.createdAt)} @@ -694,7 +693,7 @@ export const ProjectCard: React.FC = ({ @@ -820,9 +819,7 @@ export const ProjectCard: React.FC = ({

)} - {/* ========================================================================= - EVIDENCE UPLOAD MODAL (Requirement #3: Allow user to add evidence, once uploaded refresh data) - ========================================================================= */} + {/* EVIDENCE UPLOAD MODAL */} {uploadTarget && ( { - const opt = INDUSTRY_SECTOR_OPTIONS.find((o) => o.value === Number(sector)); - return opt ? opt.label : 'Industry Sector'; +export const getIndustrySectorLabel = (sector?: IndustrySector | number | string | null): string => { + if (sector === undefined || sector === null || sector === '') return ''; + const num = Number(sector); + if (!isNaN(num)) { + const opt = INDUSTRY_SECTOR_OPTIONS.find((o) => o.value === num); + if (opt) return opt.label; + } + const str = String(sector).trim().toLowerCase(); + for (const opt of INDUSTRY_SECTOR_OPTIONS) { + if (opt.label.toLowerCase() === str) return opt.label; + const enumKey = (IndustrySector as any)[opt.value]; + if (enumKey && String(enumKey).toLowerCase() === str) return opt.label; + } + return String(sector).replace(/([a-z])([A-Z])/g, '$1 $2'); }; -export const getProjectTypeLabel = (type: ProjectType | number): string => { - const opt = PROJECT_TYPE_OPTIONS.find((o) => o.value === Number(type)); - return opt ? opt.label : 'Project Type'; +export const getProjectTypeLabel = (type?: ProjectType | number | string | null): string => { + if (type === undefined || type === null || type === '') return ''; + const num = Number(type); + if (!isNaN(num)) { + const opt = PROJECT_TYPE_OPTIONS.find((o) => o.value === num); + if (opt) return opt.label; + } + const str = String(type).trim().toLowerCase(); + for (const opt of PROJECT_TYPE_OPTIONS) { + if (opt.label.toLowerCase() === str) return opt.label; + const enumKey = (ProjectType as any)[opt.value]; + if (enumKey && String(enumKey).toLowerCase() === str) return opt.label; + } + return String(type).replace(/([a-z])([A-Z])/g, '$1 $2'); }; export const getEvidenceTypeLabel = (type: EvidenceType | number): string => { diff --git a/PostFundManagement.PartnerUI/src/services/toolsService.ts b/PostFundManagement.PartnerUI/src/services/toolsService.ts index ca96b5f..a42fc2f 100644 --- a/PostFundManagement.PartnerUI/src/services/toolsService.ts +++ b/PostFundManagement.PartnerUI/src/services/toolsService.ts @@ -5,13 +5,17 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env ? import.meta.env : {}) as Record; +export const DEFAULT_HASHER_SALT = + 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=='; +export const DEFAULT_MIN_HASH_LENGTH = 11; + /** * Deterministic Hashids configuration matching pfm-api HasherSettings. * Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend. */ export const HASHER_SETTINGS = { - minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11, - salt: (env.VITE_HASHER_SALT as string) || '', + minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH, + salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT, }; let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength); @@ -21,9 +25,13 @@ export function updateHasherSettings(salt?: string, minLength?: number | string) if (salt && typeof salt === 'string' && salt.trim() !== '') { HASHER_SETTINGS.salt = salt.trim(); updated = true; + } else if (!HASHER_SETTINGS.salt) { + HASHER_SETTINGS.salt = DEFAULT_HASHER_SALT; + updated = true; } if (minLength !== undefined && minLength !== null && minLength !== '') { - const parsed = Number(minLength); + const cleanMin = String(minLength).replace(/[^0-9]/g, ''); + const parsed = Number(cleanMin); if (!isNaN(parsed) && parsed > 0) { HASHER_SETTINGS.minHashLength = parsed; updated = true; @@ -34,8 +42,9 @@ export function updateHasherSettings(salt?: string, minLength?: number | string) } } -export function isAlreadyHashId(candidate: string): boolean { +export function isAlreadyHashId(candidate: string | null | undefined): boolean { if ( + !candidate || typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength || /^\d+$/.test(candidate) @@ -47,20 +56,28 @@ export function isAlreadyHashId(candidate: string): boolean { if (!Array.isArray(decoded) || decoded.length === 0) { return false; } - return hasher.encode(decoded as number[]) === candidate; + return hasher.encode(decoded as any) === candidate; } catch { return false; } } -export function encodeHashIdSync(id: number | string | null | undefined): string { +export function encodeHashIdSync(id: number | string | bigint | null | undefined): string { if (id === undefined || id === null || id === '') { return ''; } + if (typeof id === 'bigint') { + if (id < 0n) return ''; + return hasher.encode(id); + } + if (typeof id === 'number') { if (isNaN(id) || !isFinite(id) || id < 0) return ''; - return hasher.encode(id); + if (id <= Number.MAX_SAFE_INTEGER) { + return hasher.encode(id); + } + return hasher.encode(BigInt(Math.trunc(id))); } const str = String(id).trim(); @@ -72,10 +89,8 @@ export function encodeHashIdSync(id: number | string | null | undefined): string if (/^\d+$/.test(str)) { try { - const num = parseInt(str, 10); - if (!isNaN(num) && num >= 0) { - return hasher.encode(num); - } + // JavaScript hashids v2 natively supports BigInt, matching C# Hashids.net EncodeLong without int64 precision loss + return hasher.encode(BigInt(str)); } catch { // Fall through } @@ -84,22 +99,37 @@ export function encodeHashIdSync(id: number | string | null | undefined): string return str; } -export function decodeHashIdSync(hashId: string | number | null | undefined): number | string { +export function decodeHashIdSync(hashId: string | number | bigint | null | undefined): number | string { if (hashId === undefined || hashId === null || hashId === '') return ''; if (typeof hashId === 'number') return hashId; + if (typeof hashId === 'bigint') return hashId.toString(); const str = String(hashId).trim(); if (!str) return ''; if (/^-?\d+$/.test(str)) { - const parsed = parseInt(str, 10); - return isNaN(parsed) ? str : parsed; + try { + const b = BigInt(str); + if (b <= BigInt(Number.MAX_SAFE_INTEGER) && b >= BigInt(Number.MIN_SAFE_INTEGER)) { + return Number(b); + } + return str; + } catch { + return str; + } } try { const decoded = hasher.decode(str); if (Array.isArray(decoded) && decoded.length > 0) { - return Number(decoded[0]); + const val = decoded[0]; + if (typeof val === 'bigint') { + if (val <= BigInt(Number.MAX_SAFE_INTEGER) && val >= BigInt(Number.MIN_SAFE_INTEGER)) { + return Number(val); + } + return val.toString(); + } + return Number(val); } } catch { // If decoding fails, return raw string @@ -108,11 +138,14 @@ export function decodeHashIdSync(hashId: string | number | null | undefined): nu return str; } -export function decodeToNumberSync(hashId: number | string | null | undefined): number { +export function decodeToNumberSync(hashId: number | string | bigint | null | undefined): number { if (hashId === null || hashId === undefined || hashId === '') return 0; if (typeof hashId === 'number') { return isNaN(hashId) ? 0 : Math.trunc(hashId); } + if (typeof hashId === 'bigint') { + return Number(hashId); + } const str = String(hashId).trim(); if (/^-?\d+$/.test(str)) { const parsed = parseInt(str, 10); @@ -129,19 +162,19 @@ export function decodeToNumberSync(hashId: number | string | null | undefined): } export const toolsService = { - encodeIdSync(id: number | string | null | undefined): string { + encodeIdSync(id: number | string | bigint | null | undefined): string { return encodeHashIdSync(id); }, - decodeIdSync(hashId: string | number | null | undefined): number | string { + decodeIdSync(hashId: string | number | bigint | null | undefined): number | string { return decodeHashIdSync(hashId); }, - decodeToNumberSync(hashId: number | string | null | undefined): number { + decodeToNumberSync(hashId: number | string | bigint | null | undefined): number { return decodeToNumberSync(hashId); }, - async encodeId(id: number | string | null | undefined, _context = 'general'): Promise { + async encodeId(id: number | string | bigint | null | undefined, _context = 'general'): Promise { return encodeHashIdSync(id); }, @@ -149,35 +182,35 @@ export const toolsService = { return decodeHashIdSync(hashId); }, - async resolveProjectId(projectId: number | string): Promise { + async resolveProjectId(projectId: number | string | bigint): Promise { return encodeHashIdSync(projectId); }, - async resolveOrganisationId(organisationId: number | string): Promise { + async resolveOrganisationId(organisationId: number | string | bigint): Promise { return encodeHashIdSync(organisationId); }, - async resolveProgrammeId(programmeId: number | string): Promise { + async resolveProgrammeId(programmeId: number | string | bigint): Promise { return encodeHashIdSync(programmeId); }, - async resolvePortfolioId(portfolioId: number | string): Promise { + async resolvePortfolioId(portfolioId: number | string | bigint): Promise { return encodeHashIdSync(portfolioId); }, - async resolveAwardId(awardId: number | string): Promise { + async resolveAwardId(awardId: number | string | bigint): Promise { return encodeHashIdSync(awardId); }, - async resolveContractId(contractId: number | string): Promise { + async resolveContractId(contractId: number | string | bigint): Promise { return encodeHashIdSync(contractId); }, - async resolveActivityId(activityId: number | string): Promise { + async resolveActivityId(activityId: number | string | bigint): Promise { return encodeHashIdSync(activityId); }, - async resolveMilestoneId(milestoneId: number | string): Promise { + async resolveMilestoneId(milestoneId: number | string | bigint): Promise { return encodeHashIdSync(milestoneId); }, @@ -200,7 +233,7 @@ export const toolsService = { }, }; -export function useHashId(id?: number | string | null, context = 'general') { +export function useHashId(id?: number | string | bigint | null, context = 'general') { return useQuery({ queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''], queryFn: () => Promise.resolve(encodeHashIdSync(id)), diff --git a/PostFundManagement.PartnerUI/src/utils/hashids.ts b/PostFundManagement.PartnerUI/src/utils/hashids.ts index 03fdd01..444a270 100644 --- a/PostFundManagement.PartnerUI/src/utils/hashids.ts +++ b/PostFundManagement.PartnerUI/src/utils/hashids.ts @@ -4,9 +4,13 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env ? import.meta.env : {}) as Record; +export const DEFAULT_HASHER_SALT = + 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=='; +export const DEFAULT_MIN_HASH_LENGTH = 11; + export const HASHER_SETTINGS = { - minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11, - salt: (env.VITE_HASHER_SALT as string) || '', + minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH, + salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT, }; let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength); @@ -16,9 +20,13 @@ export function updateHasherSettings(salt?: string, minLength?: number | string) if (salt && typeof salt === 'string' && salt.trim() !== '') { HASHER_SETTINGS.salt = salt.trim(); updated = true; + } else if (!HASHER_SETTINGS.salt) { + HASHER_SETTINGS.salt = DEFAULT_HASHER_SALT; + updated = true; } if (minLength !== undefined && minLength !== null && minLength !== '') { - const parsed = Number(minLength); + const cleanMin = String(minLength).replace(/[^0-9]/g, ''); + const parsed = Number(cleanMin); if (!isNaN(parsed) && parsed > 0) { HASHER_SETTINGS.minHashLength = parsed; updated = true; @@ -46,7 +54,7 @@ export function isHashId(candidate: string | null | undefined): boolean { if ( Array.isArray(decodedNumbers) && decodedNumbers.length > 0 && - hasher.encode(decodedNumbers as number[]) === candidate + hasher.encode(decodedNumbers as any) === candidate ) { return true; } @@ -149,23 +157,24 @@ export function toSafeInt64String(val: string | number | bigint | null | undefin /** * Deterministically encodes a numeric ID or 64-bit integer into a standard HashID string. + * Uses BigInt for full int64 range, interoperable with C# Hashids.net EncodeLong. */ export function encodeEntityId(id: string | number | bigint | null | undefined): string { if (id === undefined || id === null || id === '') return ''; - const str = typeof id === 'bigint' ? id.toString() : String(id).trim(); + if (typeof id === 'bigint') { + if (id < 0n) return ''; + return hasher.encode(id); + } + + const str = String(id).trim(); if (!str) return ''; if (isHashId(str)) return str; if (/^\d+$/.test(str)) { try { - const num = Number(str); - if (num <= Number.MAX_SAFE_INTEGER && num >= 0) { - return hasher.encode(num); - } - const bigHex = BigInt(str).toString(16); - return hasher.encodeHex(bigHex); + return hasher.encode(BigInt(str)); } catch { // Fall through } @@ -189,18 +198,9 @@ export function decodeEntityId(hashId: string | number | null | undefined): stri if (Array.isArray(decoded) && decoded.length > 0) { return decoded[0].toString(); } - const decodedHex = hasher.decodeHex(str); - if (decodedHex) { - try { - return BigInt('0x' + decodedHex).toString(); - } catch { - return decodedHex; - } - } } catch { // Return raw } return str; } -