Merge pull request 'Refactored UI and compose file' (#80) from garages3 into main
Reviewed-on: #80
This commit was merged in pull request #80.
This commit is contained in:
@@ -10,9 +10,14 @@ APP_URL="MY_APP_URL"
|
|||||||
|
|
||||||
# OIDC SecuritySettings
|
# OIDC SecuritySettings
|
||||||
VITE_OIDC_AUTHORITY="https://sts.security.khongisa.co.za"
|
VITE_OIDC_AUTHORITY="https://sts.security.khongisa.co.za"
|
||||||
VITE_OIDC_AUDIENCE="pfm-api-dev"
|
VITE_OIDC_AUDIENCE="change-me"
|
||||||
VITE_OIDC_CLIENT_ID="pfm-scalar-dev"
|
VITE_OIDC_CLIENT_ID="change-me"
|
||||||
VITE_OIDC_CLIENT_SECRET="secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f0276cb7431c8872d27"
|
VITE_OIDC_CLIENT_SECRET="change-me"
|
||||||
|
|
||||||
# PFM Backend API Base URL
|
# PFM Backend API Base URL
|
||||||
VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/"
|
VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/"
|
||||||
|
|
||||||
|
# HasherSettings for HashIds compatibility with backend pfm-api
|
||||||
|
VITE_HASHER_MIN_LENGTH="11"
|
||||||
|
VITE_HASHER_SALT="change-me"
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
build/
|
|
||||||
dist/
|
|
||||||
coverage/
|
|
||||||
.DS_Store
|
|
||||||
*.log
|
|
||||||
.env*
|
|
||||||
!.env.example
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<div align="center">
|
||||||
|
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
# Run and deploy your AI Studio app
|
||||||
|
|
||||||
|
This contains everything you need to run your app locally.
|
||||||
|
|
||||||
|
View your app in AI Studio: https://ai.studio/apps/b2519d4f-e797-44fe-be2e-0d30bec6814d
|
||||||
|
|
||||||
|
## Run Locally
|
||||||
|
|
||||||
|
**Prerequisites:** Node.js
|
||||||
|
|
||||||
|
|
||||||
|
1. Install dependencies:
|
||||||
|
`npm install`
|
||||||
|
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||||
|
3. Run the app:
|
||||||
|
`npm run dev`
|
||||||
+714
-3573
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"ApiSettings": {
|
||||||
|
"BaseUrl": ""
|
||||||
|
},
|
||||||
|
"SecuritySettings": {
|
||||||
|
"Authority": "",
|
||||||
|
"Audience": "",
|
||||||
|
"ClientId": "",
|
||||||
|
"ClientSecret": "",
|
||||||
|
"Scope": ""
|
||||||
|
},
|
||||||
|
"HasherSettings": {
|
||||||
|
"Salt": "",
|
||||||
|
"MinHashLength": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Development and fallback runtime configuration.
|
||||||
|
// In Docker / Kubernetes / Cloud Run deployments, this file is dynamically
|
||||||
|
// populated at container startup by /docker-entrypoint.sh with deployment environment variables.
|
||||||
|
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<defs>
|
||||||
|
<!-- Ambient Radial Glow -->
|
||||||
|
<radialGradient id="pfm-ambient-glow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.4" />
|
||||||
|
<stop offset="70%" stop-color="#2563eb" stop-opacity="0.1" />
|
||||||
|
<stop offset="100%" stop-color="#1e1b4b" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<!-- Top facet: Governance & Portfolios (Indigo / Radiant Violet) -->
|
||||||
|
<linearGradient id="pfm-grad-top" x1="12" y1="8" x2="52" y2="26" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#a5b4fc" />
|
||||||
|
<stop offset="35%" stop-color="#6366f1" />
|
||||||
|
<stop offset="100%" stop-color="#4338ca" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Left facet: Capital & Disbursement Vault (Electric Sapphire) -->
|
||||||
|
<linearGradient id="pfm-grad-left" x1="10" y1="22" x2="30" y2="56" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#38bdf8" />
|
||||||
|
<stop offset="45%" stop-color="#2563eb" />
|
||||||
|
<stop offset="100%" stop-color="#1e40af" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Right facet: Milestones, Evidence & Impact Delivery (Cyan to Emerald) -->
|
||||||
|
<linearGradient id="pfm-grad-right" x1="34" y1="28" x2="54" y2="56" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#22d3ee" />
|
||||||
|
<stop offset="50%" stop-color="#06b6d4" />
|
||||||
|
<stop offset="100%" stop-color="#10b981" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Center Radiant Diamond -->
|
||||||
|
<linearGradient id="pfm-grad-core" x1="27" y1="27" x2="37" y2="37" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#ffffff" />
|
||||||
|
<stop offset="40%" stop-color="#e0f2fe" />
|
||||||
|
<stop offset="100%" stop-color="#38bdf8" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Soft outer glow filter -->
|
||||||
|
<filter id="pfm-glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur" />
|
||||||
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Deep Contrast Foundation Disk for Dark & Light browser tabs -->
|
||||||
|
<circle cx="32" cy="32" r="31" fill="#090d16" />
|
||||||
|
<circle cx="32" cy="32" r="30" stroke="#1e293b" stroke-width="1" />
|
||||||
|
<circle cx="32" cy="32" r="28" fill="url(#pfm-ambient-glow)" />
|
||||||
|
|
||||||
|
<!-- Outer Hexagonal Structural Shield Outline -->
|
||||||
|
<path
|
||||||
|
d="M32 6.5 L53.5 19 C55 19.8 56 21.5 56 23.2 L56 40.8 C56 42.5 55 44.2 53.5 45 L32 57.5 C30.5 58.3 28.5 58.3 27 57.5 L5.5 45 C4 44.2 3 42.5 3 40.8 L3 23.2 C3 21.5 4 19.8 5.5 19 L27 6.5 C28.5 5.7 30.5 5.7 32 6.5 Z"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
stroke-opacity="0.3"
|
||||||
|
stroke-width="1.2"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Facet 1: Top Canopy (Governance / Oversight Architecture) -->
|
||||||
|
<path
|
||||||
|
d="M32 9 L51.5 20.2 L43 25.2 L32 18.8 L21 25.2 L12.5 20.2 Z"
|
||||||
|
fill="url(#pfm-grad-top)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Facet 2: Left Vault (Fund Management & Capital Allocation) -->
|
||||||
|
<path
|
||||||
|
d="M10 23 L19.2 28.4 L19.2 44.5 L30 50.8 L30 55.5 L10 43.8 Z"
|
||||||
|
fill="url(#pfm-grad-left)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Facet 3: Right Wing (Milestone Performance & Impact Assurance) -->
|
||||||
|
<path
|
||||||
|
d="M54 23 L54 43.8 L34 55.5 L34 50.8 L44.8 44.5 L44.8 28.4 Z"
|
||||||
|
fill="url(#pfm-grad-right)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Center Geometric Hub (The PFM Nexus Plate) -->
|
||||||
|
<path
|
||||||
|
d="M22 28.5 L32 22.8 L42 28.5 L42 35.5 L32 41.2 L22 35.5 Z"
|
||||||
|
fill="#0b1120"
|
||||||
|
stroke="#334155"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Kinetic Stepped Prisms -->
|
||||||
|
<path
|
||||||
|
d="M25 31.5 L32 27.5 L39 31.5 L32 35.5 Z"
|
||||||
|
fill="url(#pfm-grad-top)"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M25 37 L32 41 L39 37 L32 33 L25 37 Z"
|
||||||
|
fill="url(#pfm-grad-right)"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Center Radiant Milestone Diamond -->
|
||||||
|
<polygon
|
||||||
|
points="32,27 36.5,32 32,37 27.5,32"
|
||||||
|
fill="url(#pfm-grad-core)"
|
||||||
|
filter="url(#pfm-glow)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Center Pure White Optical Star Point -->
|
||||||
|
<circle cx="32" cy="32" r="1.5" fill="#ffffff" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
+27
@@ -0,0 +1,27 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Post Fund Management Console</title>
|
||||||
|
<meta name="description" content="Enterprise backoffice portal for Funders to manage portfolios, programmes, projects, grants, disbursements, and evidence workflows." />
|
||||||
|
<meta property="og:title" content="Post Fund Management Console" />
|
||||||
|
<meta property="og:description" content="Enterprise backoffice portal for Funders to manage portfolios, programmes, projects, grants, disbursements, and evidence workflows." />
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="alternate icon" href="/favicon.svg" />
|
||||||
|
<link rel="mask-icon" href="/favicon.svg" color="#2563eb" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<script type="module" crossorigin src="/assets/index-COrccDuP.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-BVskQUfz.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!-- Runtime Environment Configuration (Populated dynamically during deployment) -->
|
||||||
|
<script src="/env-config.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
|
||||||
pfm-console:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: pfm-console
|
|
||||||
ports:
|
|
||||||
- "8076:3000"
|
|
||||||
environment:
|
|
||||||
# OIDC Identity Provider Configuration
|
|
||||||
- OIDC_AUTHORITY=${OIDC_AUTHORITY}
|
|
||||||
- OIDC_AUDIENCE=${OIDC_AUDIENCE}
|
|
||||||
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
|
|
||||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
|
|
||||||
# Backend API Configuration
|
|
||||||
- API_BASE_URL=${API_BASE_URL}
|
|
||||||
restart: unless-stopped
|
|
||||||
@@ -1,4 +1,102 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# Target directory for Nginx static assets in container
|
||||||
|
TARGET_DIR="/usr/share/nginx/html"
|
||||||
|
|
||||||
|
# If not in standard nginx container, fallback to ./dist or ./public if present
|
||||||
|
if [ ! -d "$TARGET_DIR" ]; then
|
||||||
|
if [ -d "./dist" ]; then
|
||||||
|
TARGET_DIR="./dist"
|
||||||
|
elif [ -d "./public" ]; then
|
||||||
|
TARGET_DIR="./public"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Prioritize deployment environment variables from all common naming patterns:
|
||||||
|
# 1. API Base URL
|
||||||
|
API_BASE_URL="${VITE_API_BASE_URL:-${API_BASE_URL:-${ApiSettings__BaseUrl:-${ApiSettings_BaseUrl:-${PFM_API_BASE_URL:-}}}}}"
|
||||||
|
|
||||||
|
# 2. OIDC Security Authority
|
||||||
|
OIDC_AUTHORITY="${VITE_OIDC_AUTHORITY:-${OIDC_AUTHORITY:-${SecuritySettings__Authority:-${SecuritySettings_Authority:-${AUTHORITY:-}}}}}"
|
||||||
|
|
||||||
|
# 3. OIDC Audience
|
||||||
|
OIDC_AUDIENCE="${VITE_OIDC_AUDIENCE:-${OIDC_AUDIENCE:-${SecuritySettings__Audience:-${SecuritySettings_Audience:-${AUDIENCE:-}}}}}"
|
||||||
|
|
||||||
|
# 4. OIDC Client ID
|
||||||
|
OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__ClientId:-${SecuritySettings_ClientId:-${CLIENT_ID:-}}}}}"
|
||||||
|
|
||||||
|
# 5. OIDC Client Secret
|
||||||
|
OIDC_CLIENT_SECRET="${VITE_OIDC_CLIENT_SECRET:-${OIDC_CLIENT_SECRET:-${SecuritySettings__ClientSecret:-${SecuritySettings_ClientSecret:-${CLIENT_SECRET:-}}}}}"
|
||||||
|
|
||||||
|
# 6. OIDC Scope
|
||||||
|
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
|
||||||
|
|
||||||
|
# 7. Hasher Settings
|
||||||
|
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-}}}}}"
|
||||||
|
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
|
||||||
|
|
||||||
|
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
|
||||||
|
cat <<EOF > "$TARGET_DIR/env-config.js"
|
||||||
|
// Generated dynamically at container startup from deployment environment variables
|
||||||
|
window.__RUNTIME_CONFIG__ = {
|
||||||
|
API_BASE_URL: "${API_BASE_URL}",
|
||||||
|
VITE_API_BASE_URL: "${API_BASE_URL}",
|
||||||
|
OIDC_AUTHORITY: "${OIDC_AUTHORITY}",
|
||||||
|
VITE_OIDC_AUTHORITY: "${OIDC_AUTHORITY}",
|
||||||
|
OIDC_AUDIENCE: "${OIDC_AUDIENCE}",
|
||||||
|
VITE_OIDC_AUDIENCE: "${OIDC_AUDIENCE}",
|
||||||
|
OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
|
||||||
|
VITE_OIDC_CLIENT_ID: "${OIDC_CLIENT_ID}",
|
||||||
|
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}",
|
||||||
|
VITE_OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET}",
|
||||||
|
OIDC_SCOPE: "${OIDC_SCOPE}",
|
||||||
|
VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
|
||||||
|
HASHER_SALT: "${HASHER_SALT}",
|
||||||
|
VITE_HASHER_SALT: "${HASHER_SALT}",
|
||||||
|
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||||
|
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
|
||||||
|
ApiSettings: {
|
||||||
|
BaseUrl: "${API_BASE_URL}"
|
||||||
|
},
|
||||||
|
SecuritySettings: {
|
||||||
|
Authority: "${OIDC_AUTHORITY}",
|
||||||
|
Audience: "${OIDC_AUDIENCE}",
|
||||||
|
ClientId: "${OIDC_CLIENT_ID}",
|
||||||
|
ClientSecret: "${OIDC_CLIENT_SECRET}",
|
||||||
|
Scope: "${OIDC_SCOPE}"
|
||||||
|
},
|
||||||
|
HasherSettings: {
|
||||||
|
Salt: "${HASHER_SALT}",
|
||||||
|
MinHashLength: "${HASHER_MIN_LENGTH}"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Generate config.json for REST-based runtime retrieval
|
||||||
|
cat <<EOF > "$TARGET_DIR/config.json"
|
||||||
|
{
|
||||||
|
"ApiSettings": {
|
||||||
|
"BaseUrl": "${API_BASE_URL}"
|
||||||
|
},
|
||||||
|
"SecuritySettings": {
|
||||||
|
"Authority": "${OIDC_AUTHORITY}",
|
||||||
|
"Audience": "${OIDC_AUDIENCE}",
|
||||||
|
"ClientId": "${OIDC_CLIENT_ID}",
|
||||||
|
"ClientSecret": "${OIDC_CLIENT_SECRET}",
|
||||||
|
"Scope": "${OIDC_SCOPE}"
|
||||||
|
},
|
||||||
|
"HasherSettings": {
|
||||||
|
"Salt": "${HASHER_SALT}",
|
||||||
|
"MinHashLength": "${HASHER_MIN_LENGTH}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "[docker-entrypoint] Successfully wrote $TARGET_DIR/env-config.js and $TARGET_DIR/config.json."
|
||||||
|
fi
|
||||||
|
|
||||||
exec "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<defs>
|
||||||
|
<!-- Ambient Radial Glow -->
|
||||||
|
<radialGradient id="pfm-ambient-glow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.4" />
|
||||||
|
<stop offset="70%" stop-color="#2563eb" stop-opacity="0.1" />
|
||||||
|
<stop offset="100%" stop-color="#1e1b4b" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<!-- Top facet: Governance & Portfolios (Indigo / Radiant Violet) -->
|
||||||
|
<linearGradient id="pfm-grad-top" x1="12" y1="8" x2="52" y2="26" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#a5b4fc" />
|
||||||
|
<stop offset="35%" stop-color="#6366f1" />
|
||||||
|
<stop offset="100%" stop-color="#4338ca" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Left facet: Capital & Disbursement Vault (Electric Sapphire) -->
|
||||||
|
<linearGradient id="pfm-grad-left" x1="10" y1="22" x2="30" y2="56" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#38bdf8" />
|
||||||
|
<stop offset="45%" stop-color="#2563eb" />
|
||||||
|
<stop offset="100%" stop-color="#1e40af" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Right facet: Milestones, Evidence & Impact Delivery (Cyan to Emerald) -->
|
||||||
|
<linearGradient id="pfm-grad-right" x1="34" y1="28" x2="54" y2="56" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#22d3ee" />
|
||||||
|
<stop offset="50%" stop-color="#06b6d4" />
|
||||||
|
<stop offset="100%" stop-color="#10b981" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Center Radiant Diamond -->
|
||||||
|
<linearGradient id="pfm-grad-core" x1="27" y1="27" x2="37" y2="37" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#ffffff" />
|
||||||
|
<stop offset="40%" stop-color="#e0f2fe" />
|
||||||
|
<stop offset="100%" stop-color="#38bdf8" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Soft outer glow filter -->
|
||||||
|
<filter id="pfm-glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur" />
|
||||||
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Deep Contrast Foundation Disk for Dark & Light browser tabs -->
|
||||||
|
<circle cx="32" cy="32" r="31" fill="#090d16" />
|
||||||
|
<circle cx="32" cy="32" r="30" stroke="#1e293b" stroke-width="1" />
|
||||||
|
<circle cx="32" cy="32" r="28" fill="url(#pfm-ambient-glow)" />
|
||||||
|
|
||||||
|
<!-- Outer Hexagonal Structural Shield Outline -->
|
||||||
|
<path
|
||||||
|
d="M32 6.5 L53.5 19 C55 19.8 56 21.5 56 23.2 L56 40.8 C56 42.5 55 44.2 53.5 45 L32 57.5 C30.5 58.3 28.5 58.3 27 57.5 L5.5 45 C4 44.2 3 42.5 3 40.8 L3 23.2 C3 21.5 4 19.8 5.5 19 L27 6.5 C28.5 5.7 30.5 5.7 32 6.5 Z"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
stroke-opacity="0.3"
|
||||||
|
stroke-width="1.2"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Facet 1: Top Canopy (Governance / Oversight Architecture) -->
|
||||||
|
<path
|
||||||
|
d="M32 9 L51.5 20.2 L43 25.2 L32 18.8 L21 25.2 L12.5 20.2 Z"
|
||||||
|
fill="url(#pfm-grad-top)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Facet 2: Left Vault (Fund Management & Capital Allocation) -->
|
||||||
|
<path
|
||||||
|
d="M10 23 L19.2 28.4 L19.2 44.5 L30 50.8 L30 55.5 L10 43.8 Z"
|
||||||
|
fill="url(#pfm-grad-left)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Facet 3: Right Wing (Milestone Performance & Impact Assurance) -->
|
||||||
|
<path
|
||||||
|
d="M54 23 L54 43.8 L34 55.5 L34 50.8 L44.8 44.5 L44.8 28.4 Z"
|
||||||
|
fill="url(#pfm-grad-right)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Center Geometric Hub (The PFM Nexus Plate) -->
|
||||||
|
<path
|
||||||
|
d="M22 28.5 L32 22.8 L42 28.5 L42 35.5 L32 41.2 L22 35.5 Z"
|
||||||
|
fill="#0b1120"
|
||||||
|
stroke="#334155"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Kinetic Stepped Prisms -->
|
||||||
|
<path
|
||||||
|
d="M25 31.5 L32 27.5 L39 31.5 L32 35.5 Z"
|
||||||
|
fill="url(#pfm-grad-top)"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M25 37 L32 41 L39 37 L32 33 L25 37 Z"
|
||||||
|
fill="url(#pfm-grad-right)"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Center Radiant Milestone Diamond -->
|
||||||
|
<polygon
|
||||||
|
points="32,27 36.5,32 32,37 27.5,32"
|
||||||
|
fill="url(#pfm-grad-core)"
|
||||||
|
filter="url(#pfm-glow)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Center Pure White Optical Star Point -->
|
||||||
|
<circle cx="32" cy="32" r="1.5" fill="#ffffff" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1,6 +1,6 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Post Fund Management Console</title>
|
<title>Post Fund Management Console</title>
|
||||||
@@ -9,10 +9,18 @@
|
|||||||
<meta property="og:description" content="Enterprise backoffice portal for Funders to manage portfolios, programmes, projects, grants, disbursements, and evidence workflows." />
|
<meta property="og:description" content="Enterprise backoffice portal for Funders to manage portfolios, programmes, projects, grants, disbursements, and evidence workflows." />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
</head>
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<body>
|
<link rel="alternate icon" href="/favicon.svg" />
|
||||||
|
<link rel="mask-icon" href="/favicon.svg" color="#2563eb" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
<!-- Runtime Environment Configuration (Populated dynamically during deployment) -->
|
||||||
|
<script src="/env-config.js"></script>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
"name": "Post Fund Management Console",
|
"name": "Post Fund Management Console",
|
||||||
"description": "Enterprise backoffice portal for Funders to manage portfolios, programmes, projects, grants, disbursements, and evidence workflows.",
|
"description": "Enterprise backoffice portal for Funders to manage portfolios, programmes, projects, grants, disbursements, and evidence workflows.",
|
||||||
"requestFramePermissions": [],
|
"requestFramePermissions": [],
|
||||||
"majorCapabilities": [ "MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API" ]
|
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,17 +12,17 @@ server {
|
|||||||
gzip_proxied expired no-cache no-store private auth;
|
gzip_proxied expired no-cache no-store private auth;
|
||||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/json image/svg+xml;
|
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/json image/svg+xml;
|
||||||
|
|
||||||
# Cache control for static assets
|
# Never cache dynamic runtime configuration files or index.html
|
||||||
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc)$ {
|
location ~* ^/(?:config\.json|env-config\.js|index\.html)$ {
|
||||||
expires 1y;
|
expires -1;
|
||||||
access_log off;
|
add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0";
|
||||||
add_header Cache-Control "public";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Disable caching for appsettings.json so changes take effect immediately
|
# Cache control for static assets (CSS, JS, fonts, images)
|
||||||
location = /appsettings.json {
|
location ~* \.(?:css|js|jpg|jpeg|gif|png|ico|cur|gz|svg|svgz|mp4|ogg|ogv|webm|htc|woff|woff2|ttf|eot)$ {
|
||||||
expires -1;
|
expires 1y;
|
||||||
add_header Cache-Control "no-store, no-cache, must-revalidate, post-check=0, pre-check=0";
|
access_log off;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
}
|
}
|
||||||
|
|
||||||
# HTML5 SPA History Mode Support
|
# HTML5 SPA History Mode Support
|
||||||
|
|||||||
@@ -13,12 +13,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^2.4.0",
|
"@google/genai": "^2.4.0",
|
||||||
"@hookform/resolvers": "^5.9.1",
|
"@hookform/resolvers": "^5.9.1",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.1.14",
|
||||||
"@tanstack/react-query": "^5.102.8",
|
"@tanstack/react-query": "^5.102.8",
|
||||||
"@vitejs/plugin-react": "^5.0.4",
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
"axios": "^1.20.0",
|
"axios": "^1.20.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"hashids": "^2.3.0",
|
||||||
"lucide-react": "^0.546.0",
|
"lucide-react": "^0.546.0",
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"oidc-client-ts": "^3.5.0",
|
"oidc-client-ts": "^3.5.0",
|
||||||
@@ -26,19 +27,16 @@
|
|||||||
"react-dom": "^19.0.1",
|
"react-dom": "^19.0.1",
|
||||||
"react-hook-form": "^7.87.0",
|
"react-hook-form": "^7.87.0",
|
||||||
"react-router-dom": "^7.18.3",
|
"react-router-dom": "^7.18.3",
|
||||||
|
"tailwindcss": "^4.1.14",
|
||||||
"vite": "^6.2.3",
|
"vite": "^6.2.3",
|
||||||
"zod": "^4.5.4"
|
"zod": "^4.5.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
"@vitejs/plugin-basic-ssl": "^2.3.0",
|
"autoprefixer": "^10.4.21",
|
||||||
"autoprefixer": "^10.5.6",
|
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"postcss": "^8.5.28",
|
|
||||||
"tailwindcss": "^4.3.3",
|
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2"
|
||||||
"vite": "^6.2.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"ApiSettings": {
|
||||||
|
"BaseUrl": ""
|
||||||
|
},
|
||||||
|
"SecuritySettings": {
|
||||||
|
"Authority": "",
|
||||||
|
"Audience": "",
|
||||||
|
"ClientId": "",
|
||||||
|
"ClientSecret": "",
|
||||||
|
"Scope": ""
|
||||||
|
},
|
||||||
|
"HasherSettings": {
|
||||||
|
"Salt": "",
|
||||||
|
"MinHashLength": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// Development and fallback runtime configuration.
|
||||||
|
// In Docker / Kubernetes / Cloud Run deployments, this file is dynamically
|
||||||
|
// populated at container startup by /docker-entrypoint.sh with deployment environment variables.
|
||||||
|
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
|
||||||
|
<defs>
|
||||||
|
<!-- Ambient Radial Glow -->
|
||||||
|
<radialGradient id="pfm-ambient-glow" cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stop-color="#38bdf8" stop-opacity="0.4" />
|
||||||
|
<stop offset="70%" stop-color="#2563eb" stop-opacity="0.1" />
|
||||||
|
<stop offset="100%" stop-color="#1e1b4b" stop-opacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
<!-- Top facet: Governance & Portfolios (Indigo / Radiant Violet) -->
|
||||||
|
<linearGradient id="pfm-grad-top" x1="12" y1="8" x2="52" y2="26" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#a5b4fc" />
|
||||||
|
<stop offset="35%" stop-color="#6366f1" />
|
||||||
|
<stop offset="100%" stop-color="#4338ca" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Left facet: Capital & Disbursement Vault (Electric Sapphire) -->
|
||||||
|
<linearGradient id="pfm-grad-left" x1="10" y1="22" x2="30" y2="56" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#38bdf8" />
|
||||||
|
<stop offset="45%" stop-color="#2563eb" />
|
||||||
|
<stop offset="100%" stop-color="#1e40af" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Right facet: Milestones, Evidence & Impact Delivery (Cyan to Emerald) -->
|
||||||
|
<linearGradient id="pfm-grad-right" x1="34" y1="28" x2="54" y2="56" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#22d3ee" />
|
||||||
|
<stop offset="50%" stop-color="#06b6d4" />
|
||||||
|
<stop offset="100%" stop-color="#10b981" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Center Radiant Diamond -->
|
||||||
|
<linearGradient id="pfm-grad-core" x1="27" y1="27" x2="37" y2="37" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stop-color="#ffffff" />
|
||||||
|
<stop offset="40%" stop-color="#e0f2fe" />
|
||||||
|
<stop offset="100%" stop-color="#38bdf8" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
<!-- Soft outer glow filter -->
|
||||||
|
<filter id="pfm-glow" x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur" />
|
||||||
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Deep Contrast Foundation Disk for Dark & Light browser tabs -->
|
||||||
|
<circle cx="32" cy="32" r="31" fill="#090d16" />
|
||||||
|
<circle cx="32" cy="32" r="30" stroke="#1e293b" stroke-width="1" />
|
||||||
|
<circle cx="32" cy="32" r="28" fill="url(#pfm-ambient-glow)" />
|
||||||
|
|
||||||
|
<!-- Outer Hexagonal Structural Shield Outline -->
|
||||||
|
<path
|
||||||
|
d="M32 6.5 L53.5 19 C55 19.8 56 21.5 56 23.2 L56 40.8 C56 42.5 55 44.2 53.5 45 L32 57.5 C30.5 58.3 28.5 58.3 27 57.5 L5.5 45 C4 44.2 3 42.5 3 40.8 L3 23.2 C3 21.5 4 19.8 5.5 19 L27 6.5 C28.5 5.7 30.5 5.7 32 6.5 Z"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
stroke-opacity="0.3"
|
||||||
|
stroke-width="1.2"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Facet 1: Top Canopy (Governance / Oversight Architecture) -->
|
||||||
|
<path
|
||||||
|
d="M32 9 L51.5 20.2 L43 25.2 L32 18.8 L21 25.2 L12.5 20.2 Z"
|
||||||
|
fill="url(#pfm-grad-top)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Facet 2: Left Vault (Fund Management & Capital Allocation) -->
|
||||||
|
<path
|
||||||
|
d="M10 23 L19.2 28.4 L19.2 44.5 L30 50.8 L30 55.5 L10 43.8 Z"
|
||||||
|
fill="url(#pfm-grad-left)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Facet 3: Right Wing (Milestone Performance & Impact Assurance) -->
|
||||||
|
<path
|
||||||
|
d="M54 23 L54 43.8 L34 55.5 L34 50.8 L44.8 44.5 L44.8 28.4 Z"
|
||||||
|
fill="url(#pfm-grad-right)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Center Geometric Hub (The PFM Nexus Plate) -->
|
||||||
|
<path
|
||||||
|
d="M22 28.5 L32 22.8 L42 28.5 L42 35.5 L32 41.2 L22 35.5 Z"
|
||||||
|
fill="#0b1120"
|
||||||
|
stroke="#334155"
|
||||||
|
stroke-width="1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Kinetic Stepped Prisms -->
|
||||||
|
<path
|
||||||
|
d="M25 31.5 L32 27.5 L39 31.5 L32 35.5 Z"
|
||||||
|
fill="url(#pfm-grad-top)"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M25 37 L32 41 L39 37 L32 33 L25 37 Z"
|
||||||
|
fill="url(#pfm-grad-right)"
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Center Radiant Milestone Diamond -->
|
||||||
|
<polygon
|
||||||
|
points="32,27 36.5,32 32,37 27.5,32"
|
||||||
|
fill="url(#pfm-grad-core)"
|
||||||
|
filter="url(#pfm-glow)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Center Pure White Optical Star Point -->
|
||||||
|
<circle cx="32" cy="32" r="1.5" fill="#ffffff" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.8 KiB |
@@ -1,8 +1,9 @@
|
|||||||
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
||||||
import { UserManager, WebStorageStateStore, User as OidcUser } from 'oidc-client-ts';
|
import { UserManager, WebStorageStateStore, User as OidcUser } from 'oidc-client-ts';
|
||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { getSecuritySettings, loadRuntimeConfig } from '../config';
|
import { getSecuritySettings, loadRuntimeConfig, onConfigChanged } from '../config';
|
||||||
import { parseJwtClaims } from './jwt';
|
import { parseJwtClaims } from './jwt';
|
||||||
|
import { BrandLogo } from '../components/ui/BrandLogo';
|
||||||
|
|
||||||
export interface AuthUserProfile {
|
export interface AuthUserProfile {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,6 +12,7 @@ export interface AuthUserProfile {
|
|||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
organisationName?: string;
|
organisationName?: string;
|
||||||
|
tenant?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,7 +60,16 @@ export function getCurrentUserClaims(): UserClaims {
|
|||||||
|
|
||||||
let userManagerInstance: UserManager | null = null;
|
let userManagerInstance: UserManager | null = null;
|
||||||
|
|
||||||
function getUserManager(): UserManager {
|
export function resetUserManager(): void {
|
||||||
|
userManagerInstance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatically reset UserManager when runtime configuration is updated
|
||||||
|
onConfigChanged(() => {
|
||||||
|
resetUserManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
export function getUserManager(): UserManager {
|
||||||
if (!userManagerInstance && typeof window !== 'undefined') {
|
if (!userManagerInstance && typeof window !== 'undefined') {
|
||||||
const sec = getSecuritySettings();
|
const sec = getSecuritySettings();
|
||||||
const origin = window.location.origin;
|
const origin = window.location.origin;
|
||||||
@@ -70,7 +81,7 @@ function getUserManager(): UserManager {
|
|||||||
redirect_uri: `${origin}/dashboard`,
|
redirect_uri: `${origin}/dashboard`,
|
||||||
post_logout_redirect_uri: `${origin}/`,
|
post_logout_redirect_uri: `${origin}/`,
|
||||||
response_type: 'code',
|
response_type: 'code',
|
||||||
scope: 'openid profile roles email address pfm-api-dev',
|
scope: sec.Scope || 'openid profile roles email address pfm-api-dev',
|
||||||
extraQueryParams: {
|
extraQueryParams: {
|
||||||
audience: sec.Audience,
|
audience: sec.Audience,
|
||||||
},
|
},
|
||||||
@@ -120,6 +131,8 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
localStorage.setItem(LOCAL_STORAGE_SUB_KEY, userSub);
|
localStorage.setItem(LOCAL_STORAGE_SUB_KEY, userSub);
|
||||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
|
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(mergedClaims));
|
||||||
|
|
||||||
|
const tenantClaim = (mergedClaims.tenant as string) || (oidcUser.profile?.tenant as string) || undefined;
|
||||||
|
|
||||||
const profile: AuthUserProfile = {
|
const profile: AuthUserProfile = {
|
||||||
id: userSub || 'user-1',
|
id: userSub || 'user-1',
|
||||||
sub: userSub,
|
sub: userSub,
|
||||||
@@ -127,6 +140,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
email: oidcUser.profile.email || (mergedClaims.email as string) || '',
|
email: oidcUser.profile.email || (mergedClaims.email as string) || '',
|
||||||
role: (oidcUser.profile.role as string) || (mergedClaims.role as string) || (Array.isArray(mergedClaims.roles) ? mergedClaims.roles[0] : 'Programme Sponsor'),
|
role: (oidcUser.profile.role as string) || (mergedClaims.role as string) || (Array.isArray(mergedClaims.roles) ? mergedClaims.roles[0] : 'Programme Sponsor'),
|
||||||
organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined,
|
organisationName: (mergedClaims.organisation as string) || (mergedClaims.org as string) || undefined,
|
||||||
|
tenant: tenantClaim,
|
||||||
};
|
};
|
||||||
|
|
||||||
setUser(profile);
|
setUser(profile);
|
||||||
@@ -152,7 +166,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
if (storedClaimsRaw) {
|
if (storedClaimsRaw) {
|
||||||
try {
|
try {
|
||||||
parsedClaims = JSON.parse(storedClaimsRaw);
|
parsedClaims = JSON.parse(storedClaimsRaw);
|
||||||
} catch { }
|
} catch {}
|
||||||
} else {
|
} else {
|
||||||
parsedClaims = parseJwtClaims(storedToken);
|
parsedClaims = parseJwtClaims(storedToken);
|
||||||
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(parsedClaims));
|
localStorage.setItem(LOCAL_STORAGE_CLAIMS_KEY, JSON.stringify(parsedClaims));
|
||||||
@@ -169,8 +183,12 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
|
|
||||||
if (storedUser) {
|
if (storedUser) {
|
||||||
try {
|
try {
|
||||||
setUser(JSON.parse(storedUser));
|
const parsedUser = JSON.parse(storedUser);
|
||||||
} catch { }
|
if (!parsedUser.tenant && parsedClaims.tenant) {
|
||||||
|
parsedUser.tenant = parsedClaims.tenant;
|
||||||
|
}
|
||||||
|
setUser(parsedUser);
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -187,14 +205,16 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
const login = async () => {
|
const login = async () => {
|
||||||
try {
|
try {
|
||||||
setAuthError(null);
|
setAuthError(null);
|
||||||
|
await loadRuntimeConfig();
|
||||||
|
resetUserManager();
|
||||||
const mgr = getUserManager();
|
const mgr = getUserManager();
|
||||||
await mgr.signinRedirect();
|
await mgr.signinRedirect();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.warn('OIDC Redirect encountered:', err);
|
console.warn('OIDC Redirect encountered:', err);
|
||||||
// In sandboxed preview iframe, navigation to third-party STS domain can be blocked by CSP/frame-ancestors.
|
// In sandboxed preview iframe, navigation to external identity provider domain can be blocked by CSP/frame-ancestors.
|
||||||
if (typeof window !== 'undefined' && window.self !== window.top) {
|
if (typeof window !== 'undefined' && window.self !== window.top) {
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
'Identity Provider redirect was restricted inside this preview frame. For testing with Duende STS (https://sts.security.khongisa.co.za), open the application in a new tab or Docker container.';
|
'Identity Provider redirect was restricted inside this preview frame. To complete single sign-on authentication, open the application in a new browser tab.';
|
||||||
setAuthError(errorMsg);
|
setAuthError(errorMsg);
|
||||||
} else {
|
} else {
|
||||||
setAuthError(err instanceof Error ? err.message : 'Identity Provider redirect error');
|
setAuthError(err instanceof Error ? err.message : 'Identity Provider redirect error');
|
||||||
@@ -258,10 +278,13 @@ export const ProtectedRoute: React.FC<{ children: ReactNode }> = ({ children })
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full items-center justify-center bg-[#0d0d0d]">
|
<div className="flex h-screen w-full items-center justify-center bg-[#090d16]">
|
||||||
<div className="flex flex-col items-center gap-3">
|
<div className="flex flex-col items-center gap-4">
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent"></div>
|
<div className="relative flex items-center justify-center">
|
||||||
<p className="text-xs font-medium text-gray-400">Verifying PFM Console Session...</p>
|
<BrandLogo size={52} showGlow animateOnHover={false} />
|
||||||
|
<div className="absolute -inset-2.5 rounded-full border-2 border-blue-500/20 border-t-blue-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-medium text-slate-400 tracking-wide">Verifying PFM Console Session...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import {
|
|||||||
X,
|
X,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Layers,
|
|
||||||
Database,
|
Database,
|
||||||
Home,
|
Home,
|
||||||
User,
|
User,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../../auth/AuthProvider';
|
import { useAuth } from '../../auth/AuthProvider';
|
||||||
|
import { BrandLogo } from '../ui/BrandLogo';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
@@ -77,11 +77,21 @@ export const AppLayout: React.FC = () => {
|
|||||||
(claims.upn as string) ||
|
(claims.upn as string) ||
|
||||||
(sub ? `sub:${sub}` : '');
|
(sub ? `sub:${sub}` : '');
|
||||||
|
|
||||||
|
// Extract tenant name directly from the user claim named 'tenant'
|
||||||
|
const tenantName =
|
||||||
|
(claims?.tenant as string) ||
|
||||||
|
user?.tenant ||
|
||||||
|
(claims?.['tenant'] as string) ||
|
||||||
|
user?.organisationName ||
|
||||||
|
(claims?.organisation as string) ||
|
||||||
|
'Department of Science, Innovation & Technology (DSI)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen w-full overflow-hidden bg-[#090d16] font-sans text-slate-100 selection:bg-blue-600 selection:text-white">
|
<div className="flex h-screen w-full overflow-hidden bg-[#090d16] font-sans text-slate-100 selection:bg-blue-600 selection:text-white">
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside
|
<aside
|
||||||
className={`${sidebarOpen ? 'w-64' : 'w-20'
|
className={`${
|
||||||
|
sidebarOpen ? 'w-64' : 'w-20'
|
||||||
} shrink-0 bg-[#0b101b]/95 text-slate-200 border-r border-slate-800/80 transition-all duration-300 flex flex-col z-30 shadow-2xl backdrop-blur-md`}
|
} shrink-0 bg-[#0b101b]/95 text-slate-200 border-r border-slate-800/80 transition-all duration-300 flex flex-col z-30 shadow-2xl backdrop-blur-md`}
|
||||||
>
|
>
|
||||||
{/* Brand Header — Clickable link navigating to auth-aware Home page */}
|
{/* Brand Header — Clickable link navigating to auth-aware Home page */}
|
||||||
@@ -92,12 +102,10 @@ export const AppLayout: React.FC = () => {
|
|||||||
title="Go to Home page"
|
title="Go to Home page"
|
||||||
className="flex items-center gap-3 overflow-hidden group cursor-pointer"
|
className="flex items-center gap-3 overflow-hidden group cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-blue-600 to-indigo-700 font-bold text-white shadow-md shadow-blue-500/20 border border-blue-400/20 group-hover:scale-105 transition">
|
<BrandLogo size={36} showGlow animateOnHover />
|
||||||
<Layers className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
<div className="flex flex-col min-w-0">
|
<div className="flex flex-col min-w-0">
|
||||||
<span className="font-semibold text-sm tracking-tight text-white truncate group-hover:text-blue-400 transition">
|
<span className="font-bold text-sm tracking-tight text-white truncate group-hover:text-blue-400 transition">
|
||||||
PFM Console
|
PFM Console
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-blue-400 font-medium tracking-tight">Enterprise Management</span>
|
<span className="text-[11px] text-blue-400 font-medium tracking-tight">Enterprise Management</span>
|
||||||
@@ -122,7 +130,8 @@ export const AppLayout: React.FC = () => {
|
|||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all ${isActive
|
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all ${
|
||||||
|
isActive
|
||||||
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
|
||||||
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
|
||||||
}`
|
}`
|
||||||
@@ -147,7 +156,7 @@ export const AppLayout: React.FC = () => {
|
|||||||
{tokenUserName}
|
{tokenUserName}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-slate-400 truncate font-mono" title={tokenUserEmail}>
|
<p className="text-[11px] text-slate-400 truncate font-mono" title={tokenUserEmail}>
|
||||||
{tokenUserEmail || 'STS Token User'}
|
{tokenUserEmail || 'Authenticated User'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -155,7 +164,7 @@ export const AppLayout: React.FC = () => {
|
|||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
id="sidebar-logout-btn"
|
id="sidebar-logout-btn"
|
||||||
className="rounded-lg p-1.5 text-slate-400 hover:text-rose-400 hover:bg-rose-950/30 transition cursor-pointer"
|
className="rounded-lg p-1.5 text-slate-400 hover:text-rose-400 hover:bg-rose-950/30 transition cursor-pointer"
|
||||||
title="Sign Out via Identity Server"
|
title="Sign Out of Session"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
@@ -166,8 +175,23 @@ export const AppLayout: React.FC = () => {
|
|||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden relative">
|
||||||
{/* Top Header */}
|
{/* Top Header */}
|
||||||
<header className="h-16 bg-[#0d121f]/90 border-b border-slate-800/80 backdrop-blur-md px-6 md:px-8 flex items-center justify-between shrink-0 z-20">
|
<header className="h-16 bg-[#0d121f]/90 border-b border-slate-800/80 backdrop-blur-md px-4 sm:px-6 md:px-8 flex items-center justify-between shrink-0 z-20">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Top Bar Brand Icon & Direct Portal Link */}
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
id="header-brand-logo-link"
|
||||||
|
className="flex items-center gap-2 group cursor-pointer"
|
||||||
|
title="PFM Console Home"
|
||||||
|
>
|
||||||
|
<BrandLogo size={28} showGlow={false} animateOnHover />
|
||||||
|
<span className="font-bold text-xs tracking-tight text-white hidden md:inline-block group-hover:text-blue-400 transition">
|
||||||
|
PFM
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<span className="h-4 w-px bg-slate-800 hidden md:block" />
|
||||||
|
|
||||||
{/* Clickable Home Link */}
|
{/* Clickable Home Link */}
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
@@ -183,9 +207,12 @@ export const AppLayout: React.FC = () => {
|
|||||||
|
|
||||||
<div className="hidden sm:flex items-center gap-2 text-xs font-medium text-slate-300">
|
<div className="hidden sm:flex items-center gap-2 text-xs font-medium text-slate-300">
|
||||||
<span className="text-slate-400">Tenant:</span>
|
<span className="text-slate-400">Tenant:</span>
|
||||||
<span className="bg-slate-900/80 text-slate-200 px-2.5 py-1 rounded-lg border border-slate-800 flex items-center gap-1.5 font-medium shadow-xs">
|
<span
|
||||||
<Database className="w-3.5 h-3.5 text-blue-400" />
|
className="bg-slate-900/80 text-slate-200 px-2.5 py-1 rounded-lg border border-slate-800 flex items-center gap-1.5 font-medium shadow-xs max-w-[280px] truncate"
|
||||||
Department of Science, Innovation & Technology (DSI)
|
title={tenantName}
|
||||||
|
>
|
||||||
|
<Database className="w-3.5 h-3.5 text-blue-400 shrink-0" />
|
||||||
|
<span className="truncate">{tenantName}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -244,7 +271,7 @@ export const AppLayout: React.FC = () => {
|
|||||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-rose-400 hover:bg-rose-950/30 rounded-xl transition cursor-pointer"
|
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-rose-400 hover:bg-rose-950/30 rounded-xl transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<LogOut className="w-3.5 h-3.5" />
|
<LogOut className="w-3.5 h-3.5" />
|
||||||
<span>Sign Out via Identity Server</span>
|
<span>Sign Out of Session</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -271,7 +298,10 @@ export const AppLayout: React.FC = () => {
|
|||||||
{/* Bottom Status Footer */}
|
{/* Bottom Status Footer */}
|
||||||
<footer className="h-10 px-6 md:px-8 border-t border-slate-800/80 bg-[#0d121f]/90 flex items-center justify-between text-[11px] text-slate-400 shrink-0">
|
<footer className="h-10 px-6 md:px-8 border-t border-slate-800/80 bg-[#0d121f]/90 flex items-center justify-between text-[11px] text-slate-400 shrink-0">
|
||||||
<span>Project Financial Management (PFM) System</span>
|
<span>Project Financial Management (PFM) System</span>
|
||||||
<span className="text-slate-400 font-medium">Duende STS OIDC Secure Session</span>
|
<span className="text-slate-400 font-medium flex items-center gap-1.5">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 inline-block"></span>
|
||||||
|
Enterprise Secure Session
|
||||||
|
</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import React, { useId } from 'react';
|
||||||
|
|
||||||
|
export interface BrandLogoProps {
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | number;
|
||||||
|
variant?: 'icon-only' | 'full';
|
||||||
|
showGlow?: boolean;
|
||||||
|
className?: string;
|
||||||
|
animateOnHover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
xs: 20,
|
||||||
|
sm: 28,
|
||||||
|
md: 36,
|
||||||
|
lg: 44,
|
||||||
|
xl: 56,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modern, stylized SVG brand emblem representing Post Fund Management (PFM).
|
||||||
|
* Features an isometric kinetic nexus with three interlocking governance facets:
|
||||||
|
* 1. Top Canopy: Governance, Strategy & Portfolios (Radiant Indigo / Violet)
|
||||||
|
* 2. Left Vault: Capital, Grants & Disbursement Assurance (Electric Sapphire)
|
||||||
|
* 3. Right Wing: Milestones, Evidence & Impact Delivery (Cyan to Emerald)
|
||||||
|
* 4. Center Core: Luminous Floating Diamond Milestone Star
|
||||||
|
*/
|
||||||
|
export const BrandLogo: React.FC<BrandLogoProps> = ({
|
||||||
|
size = 'md',
|
||||||
|
variant = 'icon-only',
|
||||||
|
showGlow = true,
|
||||||
|
className = '',
|
||||||
|
animateOnHover = true,
|
||||||
|
}) => {
|
||||||
|
const uniqueId = useId().replace(/:/g, '');
|
||||||
|
const pixelSize = typeof size === 'number' ? size : sizeMap[size] || 36;
|
||||||
|
|
||||||
|
const glowId = `pfm-ambient-${uniqueId}`;
|
||||||
|
const topGradId = `pfm-top-${uniqueId}`;
|
||||||
|
const leftGradId = `pfm-left-${uniqueId}`;
|
||||||
|
const rightGradId = `pfm-right-${uniqueId}`;
|
||||||
|
const coreGradId = `pfm-core-${uniqueId}`;
|
||||||
|
const filterId = `pfm-filter-${uniqueId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`inline-flex items-center gap-3 select-none ${
|
||||||
|
animateOnHover ? 'group cursor-pointer' : ''
|
||||||
|
} ${className}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{ width: pixelSize, height: pixelSize }}
|
||||||
|
className={`relative shrink-0 flex items-center justify-center transition-transform duration-300 ${
|
||||||
|
animateOnHover ? 'group-hover:scale-105' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{showGlow && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 rounded-2xl bg-blue-500/20 blur-md pointer-events-none transition-opacity duration-300 group-hover:opacity-100 opacity-60"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 64 64"
|
||||||
|
width={pixelSize}
|
||||||
|
height={pixelSize}
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="relative z-10 w-full h-full drop-shadow-sm"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
{/* Ambient Radial Glow */}
|
||||||
|
<radialGradient id={glowId} cx="50%" cy="50%" r="50%">
|
||||||
|
<stop offset="0%" stopColor="#38bdf8" stopOpacity="0.4" />
|
||||||
|
<stop offset="70%" stopColor="#2563eb" stopOpacity="0.12" />
|
||||||
|
<stop offset="100%" stopColor="#1e1b4b" stopOpacity="0" />
|
||||||
|
</radialGradient>
|
||||||
|
|
||||||
|
{/* Top facet: Governance & Portfolios (Indigo / Radiant Violet) */}
|
||||||
|
<linearGradient id={topGradId} x1="12" y1="8" x2="52" y2="26" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stopColor="#a5b4fc" />
|
||||||
|
<stop offset="35%" stopColor="#6366f1" />
|
||||||
|
<stop offset="100%" stopColor="#4338ca" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
{/* Left facet: Capital & Disbursement Vault (Electric Sapphire) */}
|
||||||
|
<linearGradient id={leftGradId} x1="10" y1="22" x2="30" y2="56" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stopColor="#38bdf8" />
|
||||||
|
<stop offset="45%" stopColor="#2563eb" />
|
||||||
|
<stop offset="100%" stopColor="#1e40af" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
{/* Right facet: Milestones, Evidence & Impact Delivery (Cyan to Emerald) */}
|
||||||
|
<linearGradient id={rightGradId} x1="34" y1="28" x2="54" y2="56" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stopColor="#22d3ee" />
|
||||||
|
<stop offset="50%" stopColor="#06b6d4" />
|
||||||
|
<stop offset="100%" stopColor="#10b981" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
{/* Center Radiant Diamond */}
|
||||||
|
<linearGradient id={coreGradId} x1="27" y1="27" x2="37" y2="37" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop offset="0%" stopColor="#ffffff" />
|
||||||
|
<stop offset="40%" stopColor="#e0f2fe" />
|
||||||
|
<stop offset="100%" stopColor="#38bdf8" />
|
||||||
|
</linearGradient>
|
||||||
|
|
||||||
|
{/* Core luminous glow filter */}
|
||||||
|
<filter id={filterId} x="-20%" y="-20%" width="140%" height="140%">
|
||||||
|
<feGaussianBlur stdDeviation="1.5" result="blur" />
|
||||||
|
<feComposite in="SourceGraphic" in2="blur" operator="over" />
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
{/* Deep Contrast Foundation Disk */}
|
||||||
|
<circle cx="32" cy="32" r="31" fill="#090d16" />
|
||||||
|
<circle cx="32" cy="32" r="30" stroke="#1e293b" strokeWidth="1" />
|
||||||
|
<circle cx="32" cy="32" r="28" fill={`url(#${glowId})`} />
|
||||||
|
|
||||||
|
{/* Outer Hexagonal Structural Shield Outline */}
|
||||||
|
<path
|
||||||
|
d="M32 6.5 L53.5 19 C55 19.8 56 21.5 56 23.2 L56 40.8 C56 42.5 55 44.2 53.5 45 L32 57.5 C30.5 58.3 28.5 58.3 27 57.5 L5.5 45 C4 44.2 3 42.5 3 40.8 L3 23.2 C3 21.5 4 19.8 5.5 19 L27 6.5 C28.5 5.7 30.5 5.7 32 6.5 Z"
|
||||||
|
stroke="#3b82f6"
|
||||||
|
strokeOpacity="0.35"
|
||||||
|
strokeWidth="1.2"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Facet 1: Top Canopy (Governance / Oversight Architecture) */}
|
||||||
|
<path
|
||||||
|
d="M32 9 L51.5 20.2 L43 25.2 L32 18.8 L21 25.2 L12.5 20.2 Z"
|
||||||
|
fill={`url(#${topGradId})`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Facet 2: Left Vault (Fund Management & Capital Allocation) */}
|
||||||
|
<path
|
||||||
|
d="M10 23 L19.2 28.4 L19.2 44.5 L30 50.8 L30 55.5 L10 43.8 Z"
|
||||||
|
fill={`url(#${leftGradId})`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Facet 3: Right Wing (Milestone Performance & Impact Assurance) */}
|
||||||
|
<path
|
||||||
|
d="M54 23 L54 43.8 L34 55.5 L34 50.8 L44.8 44.5 L44.8 28.4 Z"
|
||||||
|
fill={`url(#${rightGradId})`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Center Geometric Hub (The PFM Nexus Plate) */}
|
||||||
|
<path
|
||||||
|
d="M22 28.5 L32 22.8 L42 28.5 L42 35.5 L32 41.2 L22 35.5 Z"
|
||||||
|
fill="#0b1120"
|
||||||
|
stroke="#334155"
|
||||||
|
strokeWidth="1"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Kinetic Stepped Prisms */}
|
||||||
|
<path
|
||||||
|
d="M25 31.5 L32 27.5 L39 31.5 L32 35.5 Z"
|
||||||
|
fill={`url(#${topGradId})`}
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M25 37 L32 41 L39 37 L32 33 L25 37 Z"
|
||||||
|
fill={`url(#${rightGradId})`}
|
||||||
|
opacity="0.9"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Center Radiant Milestone Diamond */}
|
||||||
|
<polygon
|
||||||
|
points="32,27 36.5,32 32,37 27.5,32"
|
||||||
|
fill={`url(#${coreGradId})`}
|
||||||
|
filter={`url(#${filterId})`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Center Pure White Optical Star Point */}
|
||||||
|
<circle cx="32" cy="32" r="1.5" fill="#ffffff" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{variant === 'full' && (
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<span className="font-bold text-sm tracking-tight text-white truncate group-hover:text-blue-400 transition">
|
||||||
|
PFM Console
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-blue-400 font-medium tracking-tight">
|
||||||
|
Post-Fund Governance
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -43,7 +43,8 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
|
|||||||
{toasts.map((t) => (
|
{toasts.map((t) => (
|
||||||
<div
|
<div
|
||||||
key={t.id}
|
key={t.id}
|
||||||
className={`pointer-events-auto flex items-start gap-3 p-4 rounded-xl border shadow-2xl transition-all duration-200 bg-[#141414] ${t.type === 'success'
|
className={`pointer-events-auto flex items-start gap-3 p-4 rounded-xl border shadow-2xl transition-all duration-200 bg-[#141414] ${
|
||||||
|
t.type === 'success'
|
||||||
? 'border-green-500/30 text-gray-200'
|
? 'border-green-500/30 text-gray-200'
|
||||||
: t.type === 'error'
|
: t.type === 'error'
|
||||||
? 'border-rose-500/30 text-gray-200'
|
? 'border-rose-500/30 text-gray-200'
|
||||||
|
|||||||
@@ -1,36 +1,367 @@
|
|||||||
|
import { updateHasherSettings } from './services/toolsService';
|
||||||
|
|
||||||
export interface SecuritySettings {
|
export interface SecuritySettings {
|
||||||
Authority: string;
|
Authority: string;
|
||||||
Audience: string;
|
Audience: string;
|
||||||
ClientId: string;
|
ClientId: string;
|
||||||
ClientSecret: string;
|
ClientSecret: string;
|
||||||
|
Scope?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiSettings {
|
export interface ApiSettings {
|
||||||
BaseUrl: string;
|
BaseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HasherSettings {
|
||||||
|
Salt: string;
|
||||||
|
MinHashLength: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConfigSource =
|
||||||
|
| 'runtime-injected'
|
||||||
|
| 'runtime-fetched'
|
||||||
|
| 'build-env'
|
||||||
|
| 'fallback-defaults';
|
||||||
|
|
||||||
export interface AppConfig {
|
export interface AppConfig {
|
||||||
SecuritySettings: SecuritySettings;
|
SecuritySettings: SecuritySettings;
|
||||||
ApiSettings: ApiSettings;
|
ApiSettings: ApiSettings;
|
||||||
|
HasherSettings: HasherSettings;
|
||||||
|
ConfigSource: ConfigSource;
|
||||||
|
LoadedAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fallback presets (used only when no runtime or build-time variable is provided)
|
||||||
export const defaultSecuritySettings: SecuritySettings = {
|
export const defaultSecuritySettings: SecuritySettings = {
|
||||||
Authority: (import.meta.env.VITE_OIDC_AUTHORITY as string) || 'https://sts.security.khongisa.co.za',
|
Authority: 'https://sts.security.khongisa.co.za',
|
||||||
Audience: (import.meta.env.VITE_OIDC_AUDIENCE as string) || 'pfm-api-dev',
|
Audience: 'pfm-api-dev',
|
||||||
ClientId: (import.meta.env.VITE_OIDC_CLIENT_ID as string) || 'pfm-console-dev',
|
ClientId: 'pfm-console-dev',
|
||||||
ClientSecret: (import.meta.env.VITE_OIDC_CLIENT_SECRET as string) || 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
|
ClientSecret: 'secret_dcaeaa28097d9ba5b06ec324d9eac9d9c81de96e0a203cf2c6154c6dd7c89e94',
|
||||||
|
Scope: 'openid profile roles email address pfm-api-dev',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultApiSettings: ApiSettings = {
|
export const defaultApiSettings: ApiSettings = {
|
||||||
BaseUrl: (import.meta.env.VITE_API_BASE_URL as string) || 'https://api.ifri.khongisa.co.za/',
|
BaseUrl: 'https://api.ifri.khongisa.co.za/',
|
||||||
};
|
};
|
||||||
|
|
||||||
const runtimeConfig: AppConfig = {
|
export const defaultHasherSettings: HasherSettings = {
|
||||||
SecuritySettings: defaultSecuritySettings,
|
Salt: 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||||
ApiSettings: defaultApiSettings,
|
MinHashLength: 11,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts first non-empty, trimmed string from candidate values.
|
||||||
|
*/
|
||||||
|
function pickFirstNonEmpty(...candidates: any[]): string | undefined {
|
||||||
|
for (const c of candidates) {
|
||||||
|
if (c !== undefined && c !== null) {
|
||||||
|
const s = String(c).trim();
|
||||||
|
if (s !== '' && s !== 'undefined' && s !== 'null') {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes an API base URL ensuring proper protocol and single trailing slash.
|
||||||
|
*/
|
||||||
|
function normalizeBaseUrl(url: string): string {
|
||||||
|
const trimmed = url.trim();
|
||||||
|
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads any runtime config object injected into window.
|
||||||
|
* Supports: window.__RUNTIME_CONFIG__, window.APP_CONFIG, window._env_
|
||||||
|
*/
|
||||||
|
function getWindowRuntimeConfig(): Record<string, any> {
|
||||||
|
if (typeof window === 'undefined') return {};
|
||||||
|
const win = window as any;
|
||||||
|
return win.__RUNTIME_CONFIG__ || win.APP_CONFIG || win._env_ || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses and maps configuration from a given source object.
|
||||||
|
* Checks flat keys, nested keys, and ASP.NET double-underscore conventions.
|
||||||
|
*/
|
||||||
|
function extractSourceConfig(src: Record<string, any>): {
|
||||||
|
authority?: string;
|
||||||
|
audience?: string;
|
||||||
|
clientId?: string;
|
||||||
|
clientSecret?: string;
|
||||||
|
scope?: string;
|
||||||
|
baseUrl?: string;
|
||||||
|
hasherSalt?: string;
|
||||||
|
hasherMinLength?: number;
|
||||||
|
} {
|
||||||
|
if (!src || typeof src !== 'object') return {};
|
||||||
|
|
||||||
|
const authority = pickFirstNonEmpty(
|
||||||
|
src.VITE_OIDC_AUTHORITY,
|
||||||
|
src.OIDC_AUTHORITY,
|
||||||
|
src.SecuritySettings?.Authority,
|
||||||
|
src.SecuritySettings__Authority,
|
||||||
|
src.SecuritySettings_Authority,
|
||||||
|
src.AUTHORITY,
|
||||||
|
src.STS_AUTHORITY
|
||||||
|
);
|
||||||
|
|
||||||
|
const audience = pickFirstNonEmpty(
|
||||||
|
src.VITE_OIDC_AUDIENCE,
|
||||||
|
src.OIDC_AUDIENCE,
|
||||||
|
src.SecuritySettings?.Audience,
|
||||||
|
src.SecuritySettings__Audience,
|
||||||
|
src.SecuritySettings_Audience,
|
||||||
|
src.AUDIENCE
|
||||||
|
);
|
||||||
|
|
||||||
|
const clientId = pickFirstNonEmpty(
|
||||||
|
src.VITE_OIDC_CLIENT_ID,
|
||||||
|
src.OIDC_CLIENT_ID,
|
||||||
|
src.SecuritySettings?.ClientId,
|
||||||
|
src.SecuritySettings__ClientId,
|
||||||
|
src.SecuritySettings_ClientId,
|
||||||
|
src.CLIENT_ID
|
||||||
|
);
|
||||||
|
|
||||||
|
const clientSecret = pickFirstNonEmpty(
|
||||||
|
src.VITE_OIDC_CLIENT_SECRET,
|
||||||
|
src.OIDC_CLIENT_SECRET,
|
||||||
|
src.SecuritySettings?.ClientSecret,
|
||||||
|
src.SecuritySettings__ClientSecret,
|
||||||
|
src.SecuritySettings_ClientSecret,
|
||||||
|
src.CLIENT_SECRET
|
||||||
|
);
|
||||||
|
|
||||||
|
const scope = pickFirstNonEmpty(
|
||||||
|
src.VITE_OIDC_SCOPE,
|
||||||
|
src.OIDC_SCOPE,
|
||||||
|
src.SecuritySettings?.Scope,
|
||||||
|
src.SecuritySettings__Scope,
|
||||||
|
src.SecuritySettings_Scope,
|
||||||
|
src.SCOPE
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseUrl = pickFirstNonEmpty(
|
||||||
|
src.VITE_API_BASE_URL,
|
||||||
|
src.API_BASE_URL,
|
||||||
|
src.ApiSettings?.BaseUrl,
|
||||||
|
src.ApiSettings__BaseUrl,
|
||||||
|
src.ApiSettings_BaseUrl,
|
||||||
|
src.PFM_API_BASE_URL
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasherSalt = pickFirstNonEmpty(
|
||||||
|
src.VITE_HASHER_SALT,
|
||||||
|
src.HASHER_SALT,
|
||||||
|
src.HasherSettings?.Salt,
|
||||||
|
src.HasherSettings__Salt,
|
||||||
|
src.HasherSettings_Salt
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawMinLength = pickFirstNonEmpty(
|
||||||
|
src.VITE_HASHER_MIN_LENGTH,
|
||||||
|
src.HASHER_MIN_LENGTH,
|
||||||
|
src.HasherSettings?.MinHashLength,
|
||||||
|
src.HasherSettings__MinLength,
|
||||||
|
src.HasherSettings_MinLength
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasherMinLength = rawMinLength ? Number(rawMinLength) : undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
authority,
|
||||||
|
audience,
|
||||||
|
clientId,
|
||||||
|
clientSecret,
|
||||||
|
scope,
|
||||||
|
baseUrl: baseUrl ? normalizeBaseUrl(baseUrl) : undefined,
|
||||||
|
hasherSalt,
|
||||||
|
hasherMinLength: hasherMinLength && !isNaN(hasherMinLength) && hasherMinLength > 0 ? hasherMinLength : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the prioritized configuration by evaluating:
|
||||||
|
* Tier 1: Injected Runtime Config (window.__RUNTIME_CONFIG__ from docker-entrypoint.sh)
|
||||||
|
* Tier 2: Fetched Runtime Config (/config.json from server)
|
||||||
|
* Tier 3: Vite Build-Time Variables (import.meta.env.VITE_*)
|
||||||
|
* Tier 4: Fallback Defaults
|
||||||
|
*/
|
||||||
|
function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
|
||||||
|
const windowConfig = extractSourceConfig(getWindowRuntimeConfig());
|
||||||
|
const fetchedConfig = fetchedJson ? extractSourceConfig(fetchedJson) : {};
|
||||||
|
const buildEnvConfig = extractSourceConfig(
|
||||||
|
(typeof import.meta !== 'undefined' && import.meta.env ? import.meta.env : {}) as Record<string, any>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if any runtime deployment override was provided
|
||||||
|
const hasInjected = Object.values(windowConfig).some((v) => v !== undefined);
|
||||||
|
const hasFetched = Object.values(fetchedConfig).some((v) => v !== undefined);
|
||||||
|
const hasBuildEnv = Object.values(buildEnvConfig).some((v) => v !== undefined);
|
||||||
|
|
||||||
|
let source: ConfigSource = 'fallback-defaults';
|
||||||
|
if (hasInjected) {
|
||||||
|
source = 'runtime-injected';
|
||||||
|
} else if (hasFetched) {
|
||||||
|
source = 'runtime-fetched';
|
||||||
|
} else if (hasBuildEnv) {
|
||||||
|
source = 'build-env';
|
||||||
|
}
|
||||||
|
|
||||||
|
const authority = pickFirstNonEmpty(
|
||||||
|
windowConfig.authority,
|
||||||
|
fetchedConfig.authority,
|
||||||
|
buildEnvConfig.authority,
|
||||||
|
defaultSecuritySettings.Authority
|
||||||
|
)!;
|
||||||
|
|
||||||
|
const audience = pickFirstNonEmpty(
|
||||||
|
windowConfig.audience,
|
||||||
|
fetchedConfig.audience,
|
||||||
|
buildEnvConfig.audience,
|
||||||
|
defaultSecuritySettings.Audience
|
||||||
|
)!;
|
||||||
|
|
||||||
|
const clientId = pickFirstNonEmpty(
|
||||||
|
windowConfig.clientId,
|
||||||
|
fetchedConfig.clientId,
|
||||||
|
buildEnvConfig.clientId,
|
||||||
|
defaultSecuritySettings.ClientId
|
||||||
|
)!;
|
||||||
|
|
||||||
|
const clientSecret = pickFirstNonEmpty(
|
||||||
|
windowConfig.clientSecret,
|
||||||
|
fetchedConfig.clientSecret,
|
||||||
|
buildEnvConfig.clientSecret,
|
||||||
|
defaultSecuritySettings.ClientSecret
|
||||||
|
)!;
|
||||||
|
|
||||||
|
const scope = pickFirstNonEmpty(
|
||||||
|
windowConfig.scope,
|
||||||
|
fetchedConfig.scope,
|
||||||
|
buildEnvConfig.scope,
|
||||||
|
defaultSecuritySettings.Scope
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseUrl = pickFirstNonEmpty(
|
||||||
|
windowConfig.baseUrl,
|
||||||
|
fetchedConfig.baseUrl,
|
||||||
|
buildEnvConfig.baseUrl,
|
||||||
|
defaultApiSettings.BaseUrl
|
||||||
|
)!;
|
||||||
|
|
||||||
|
const salt = pickFirstNonEmpty(
|
||||||
|
windowConfig.hasherSalt,
|
||||||
|
fetchedConfig.hasherSalt,
|
||||||
|
buildEnvConfig.hasherSalt,
|
||||||
|
defaultHasherSettings.Salt
|
||||||
|
)!;
|
||||||
|
|
||||||
|
const minHashLength =
|
||||||
|
windowConfig.hasherMinLength ||
|
||||||
|
fetchedConfig.hasherMinLength ||
|
||||||
|
buildEnvConfig.hasherMinLength ||
|
||||||
|
defaultHasherSettings.MinHashLength;
|
||||||
|
|
||||||
|
return {
|
||||||
|
SecuritySettings: {
|
||||||
|
Authority: authority,
|
||||||
|
Audience: audience,
|
||||||
|
ClientId: clientId,
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
Scope: scope,
|
||||||
|
},
|
||||||
|
ApiSettings: {
|
||||||
|
BaseUrl: normalizeBaseUrl(baseUrl),
|
||||||
|
},
|
||||||
|
HasherSettings: {
|
||||||
|
Salt: salt,
|
||||||
|
MinHashLength: minHashLength,
|
||||||
|
},
|
||||||
|
ConfigSource: source,
|
||||||
|
LoadedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial synchronous computation from window.__RUNTIME_CONFIG__ and build-time env
|
||||||
|
let runtimeConfig: AppConfig = resolveConfig();
|
||||||
|
|
||||||
|
// Immediately apply deterministic hasher settings
|
||||||
|
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||||
|
|
||||||
|
const configListeners: Array<(config: AppConfig) => void> = [];
|
||||||
|
|
||||||
|
export function onConfigChanged(listener: (config: AppConfig) => void): () => void {
|
||||||
|
configListeners.push(listener);
|
||||||
|
return () => {
|
||||||
|
const idx = configListeners.indexOf(listener);
|
||||||
|
if (idx !== -1) configListeners.splice(idx, 1);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let loadPromise: Promise<AppConfig> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Asynchronously loads and verifies runtime configuration.
|
||||||
|
* Fetches /config.json with cache-busting to ensure latest deployment variables
|
||||||
|
* are prioritized over static build artifacts.
|
||||||
|
*/
|
||||||
export async function loadRuntimeConfig(): Promise<AppConfig> {
|
export async function loadRuntimeConfig(): Promise<AppConfig> {
|
||||||
|
if (loadPromise) return loadPromise;
|
||||||
|
|
||||||
|
loadPromise = (async () => {
|
||||||
|
let fetchedJson: Record<string, any> | undefined;
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
try {
|
||||||
|
// Fetch /config.json with cache-busting to bypass any intermediary caching
|
||||||
|
const res = await fetch(`/config.json?_t=${Date.now()}`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
fetchedJson = data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Fetching /config.json is non-blocking; window.__RUNTIME_CONFIG__ or env fallback will be used
|
||||||
|
console.debug('[Config] Note: Remote /config.json fetch bypassed:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeConfig = resolveConfig(fetchedJson);
|
||||||
|
|
||||||
|
// Synchronize deterministic hasher settings
|
||||||
|
updateHasherSettings(runtimeConfig.HasherSettings.Salt, runtimeConfig.HasherSettings.MinHashLength);
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`[Config] Active runtime configuration: source=${runtimeConfig.ConfigSource}, API=${runtimeConfig.ApiSettings.BaseUrl}, STS=${runtimeConfig.SecuritySettings.Authority}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notify registered listeners
|
||||||
|
configListeners.forEach((fn) => {
|
||||||
|
try {
|
||||||
|
fn(runtimeConfig);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Config] Listener error:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return runtimeConfig;
|
||||||
|
})();
|
||||||
|
|
||||||
|
return loadPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppConfig(): AppConfig {
|
||||||
return runtimeConfig;
|
return runtimeConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,3 +372,11 @@ export function getSecuritySettings(): SecuritySettings {
|
|||||||
export function getApiBaseUrl(): string {
|
export function getApiBaseUrl(): string {
|
||||||
return runtimeConfig.ApiSettings.BaseUrl;
|
return runtimeConfig.ApiSettings.BaseUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getHasherSettings(): HasherSettings {
|
||||||
|
return runtimeConfig.HasherSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfigSource(): ConfigSource {
|
||||||
|
return runtimeConfig.ConfigSource;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,14 +1,31 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
--font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background-color: #0a0a0a;
|
background-color: #090d16;
|
||||||
color: #e5e7eb;
|
color: #e2e8f0;
|
||||||
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, select, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-mono, code, kbd, samp, pre {
|
||||||
|
font-family: var(--font-mono) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Form control color scheme */
|
/* Form control color scheme */
|
||||||
@@ -21,16 +38,13 @@
|
|||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #0a0a0a;
|
background: #0a0a0a;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #262626;
|
background: #262626;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
background: #3f3f46;
|
background: #3f3f46;
|
||||||
}
|
}
|
||||||
@@ -220,10 +234,10 @@ textarea {
|
|||||||
border-color: #383838 !important;
|
border-color: #383838 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
input::placeholder,
|
input::placeholder,
|
||||||
textarea::placeholder {
|
textarea::placeholder {
|
||||||
color: #94a3b8 !important;
|
color: #94a3b8 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Table header text high contrast */
|
/* Table header text high contrast */
|
||||||
th {
|
th {
|
||||||
@@ -254,3 +268,4 @@ th {
|
|||||||
.border-emerald-600 {
|
.border-emerald-600 {
|
||||||
border-color: #3b82f6 !important;
|
border-color: #3b82f6 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ import { StrictMode } from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import App from './App.tsx';
|
import App from './App.tsx';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
import { loadRuntimeConfig } from './config';
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
// Load runtime environment configuration before mounting the application
|
||||||
|
loadRuntimeConfig().finally(() => {
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
Executive Portfolio Dashboard
|
Executive Portfolio Dashboard
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
|
<span className="text-[11px] font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-0.5 rounded-full border border-blue-500/30">
|
||||||
Live STS Feed
|
Live Portfolio Feed
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
<p className="text-xs text-slate-400 mt-1">
|
||||||
@@ -604,7 +604,8 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
<td className="py-3.5 px-4 text-slate-300 font-medium">{p.programme}</td>
|
<td className="py-3.5 px-4 text-slate-300 font-medium">{p.programme}</td>
|
||||||
<td className="py-3.5 px-4">
|
<td className="py-3.5 px-4">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${p.status === 'At Risk'
|
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${
|
||||||
|
p.status === 'At Risk'
|
||||||
? 'bg-rose-500/10 text-rose-400 border-rose-500/30'
|
? 'bg-rose-500/10 text-rose-400 border-rose-500/30'
|
||||||
: p.status === 'Pending'
|
: p.status === 'Pending'
|
||||||
? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
|
? 'bg-amber-500/10 text-amber-400 border-amber-500/30'
|
||||||
@@ -616,7 +617,8 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-center">
|
<td className="py-3.5 px-4 text-center">
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center justify-center font-bold px-2.5 py-0.5 rounded-md text-[10px] shadow-xs ${p.risk === 'RED'
|
className={`inline-flex items-center justify-center font-bold px-2.5 py-0.5 rounded-md text-[10px] shadow-xs ${
|
||||||
|
p.risk === 'RED'
|
||||||
? 'bg-rose-600 text-white'
|
? 'bg-rose-600 text-white'
|
||||||
: p.risk === 'AMBER'
|
: p.risk === 'AMBER'
|
||||||
? 'bg-amber-500 text-white'
|
? 'bg-amber-500 text-white'
|
||||||
@@ -628,7 +630,8 @@ export const ExecutiveDashboard: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-4 text-right">
|
<td className="py-3.5 px-4 text-right">
|
||||||
<span
|
<span
|
||||||
className={`font-mono font-bold text-xs ${p.varianceType === 'negative' ? 'text-rose-400' : 'text-emerald-400'
|
className={`font-mono font-bold text-xs ${
|
||||||
|
p.varianceType === 'negative' ? 'text-rose-400' : 'text-emerald-400'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{p.financialVariance}
|
{p.financialVariance}
|
||||||
|
|||||||
@@ -13,17 +13,23 @@ import {
|
|||||||
Layers,
|
Layers,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Search,
|
Search,
|
||||||
|
Coins,
|
||||||
|
Eye,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getOrganisations,
|
getOrganisations,
|
||||||
createOrganisation,
|
createOrganisation,
|
||||||
updateOrganisation,
|
updateOrganisation,
|
||||||
getAllPortfolios,
|
getAllPortfolios,
|
||||||
|
getPortfolio,
|
||||||
createPortfolio,
|
createPortfolio,
|
||||||
updatePortfolio,
|
updatePortfolio,
|
||||||
getAllProgrammes,
|
getAllProgrammes,
|
||||||
|
getProgramme,
|
||||||
createProgramme,
|
createProgramme,
|
||||||
updateProgramme,
|
updateProgramme,
|
||||||
|
getInstruments,
|
||||||
|
getPortfolioProgrammes,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
import { Modal } from '../components/ui/Modal';
|
import { Modal } from '../components/ui/Modal';
|
||||||
import { useToast } from '../components/ui/Toast';
|
import { useToast } from '../components/ui/Toast';
|
||||||
@@ -32,6 +38,7 @@ import {
|
|||||||
Organisation,
|
Organisation,
|
||||||
Portfolio,
|
Portfolio,
|
||||||
Programme,
|
Programme,
|
||||||
|
Instrument,
|
||||||
OrganisationType,
|
OrganisationType,
|
||||||
ActivityStatus,
|
ActivityStatus,
|
||||||
ApprovalStatus,
|
ApprovalStatus,
|
||||||
@@ -95,6 +102,13 @@ export const GovernancePage: React.FC = () => {
|
|||||||
const [isProgrammeModalOpen, setIsProgrammeModalOpen] = useState(false);
|
const [isProgrammeModalOpen, setIsProgrammeModalOpen] = useState(false);
|
||||||
const [editingProgramme, setEditingProgramme] = useState<Programme | null>(null);
|
const [editingProgramme, setEditingProgramme] = useState<Programme | null>(null);
|
||||||
|
|
||||||
|
// Inspection states
|
||||||
|
const [selectedProgrammeForInstruments, setSelectedProgrammeForInstruments] = useState<Programme | null>(null);
|
||||||
|
const [isInstrumentsModalOpen, setIsInstrumentsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const [selectedPortfolioForProgrammes, setSelectedPortfolioForProgrammes] = useState<Portfolio | null>(null);
|
||||||
|
const [isPortfolioProgrammesModalOpen, setIsPortfolioProgrammesModalOpen] = useState(false);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: organisations = [], isLoading: loadingOrgs } = useQuery({
|
const { data: organisations = [], isLoading: loadingOrgs } = useQuery({
|
||||||
queryKey: ['organisations'],
|
queryKey: ['organisations'],
|
||||||
@@ -111,6 +125,18 @@ export const GovernancePage: React.FC = () => {
|
|||||||
queryFn: () => getAllProgrammes(),
|
queryFn: () => getAllProgrammes(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: programmeInstruments = [], isLoading: loadingInstruments } = useQuery({
|
||||||
|
queryKey: ['instruments', selectedProgrammeForInstruments?.id],
|
||||||
|
queryFn: () => (selectedProgrammeForInstruments ? getInstruments(selectedProgrammeForInstruments.id) : Promise.resolve([])),
|
||||||
|
enabled: !!selectedProgrammeForInstruments && isInstrumentsModalOpen,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: portfolioProgrammesList = [], isLoading: loadingPortfolioProgrammes } = useQuery({
|
||||||
|
queryKey: ['portfolioProgrammes', selectedPortfolioForProgrammes?.id],
|
||||||
|
queryFn: () => (selectedPortfolioForProgrammes ? getPortfolioProgrammes(selectedPortfolioForProgrammes.id) : Promise.resolve([])),
|
||||||
|
enabled: !!selectedPortfolioForProgrammes && isPortfolioProgrammesModalOpen,
|
||||||
|
});
|
||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
const orgForm = useForm<OrganisationFormValues>({
|
const orgForm = useForm<OrganisationFormValues>({
|
||||||
resolver: zodResolver(organisationSchema) as any,
|
resolver: zodResolver(organisationSchema) as any,
|
||||||
@@ -350,7 +376,8 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<div className="flex border-b border-slate-800/80 bg-[#0d121f]/80 px-4 rounded-2xl backdrop-blur-sm">
|
<div className="flex border-b border-slate-800/80 bg-[#0d121f]/80 px-4 rounded-2xl backdrop-blur-sm">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('organisations')}
|
onClick={() => setActiveTab('organisations')}
|
||||||
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${activeTab === 'organisations'
|
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${
|
||||||
|
activeTab === 'organisations'
|
||||||
? 'border-blue-500 text-blue-400'
|
? 'border-blue-500 text-blue-400'
|
||||||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
@@ -360,7 +387,8 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('portfolios')}
|
onClick={() => setActiveTab('portfolios')}
|
||||||
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${activeTab === 'portfolios'
|
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${
|
||||||
|
activeTab === 'portfolios'
|
||||||
? 'border-blue-500 text-blue-400'
|
? 'border-blue-500 text-blue-400'
|
||||||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
@@ -370,7 +398,8 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('programmes')}
|
onClick={() => setActiveTab('programmes')}
|
||||||
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${activeTab === 'programmes'
|
className={`py-3.5 px-4 text-xs font-semibold border-b-2 transition-colors flex items-center gap-2 cursor-pointer ${
|
||||||
|
activeTab === 'programmes'
|
||||||
? 'border-blue-500 text-blue-400'
|
? 'border-blue-500 text-blue-400'
|
||||||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
@@ -498,7 +527,20 @@ export const GovernancePage: React.FC = () => {
|
|||||||
<span className="text-slate-400 font-medium truncate max-w-[190px]">
|
<span className="text-slate-400 font-medium truncate max-w-[190px]">
|
||||||
Owner: <span className="text-slate-200 font-mono text-[11px]">{getUserEmail(p.ownedBy)}</span>
|
Owner: <span className="text-slate-200 font-mono text-[11px]">{getUserEmail(p.ownedBy)}</span>
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedPortfolioForProgrammes(p);
|
||||||
|
setIsPortfolioProgrammesModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
<Layers className="w-3 h-3" />
|
||||||
|
Programmes
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => openEditPortfolio(p)}
|
onClick={() => openEditPortfolio(p)}
|
||||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -508,6 +550,7 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
@@ -559,13 +602,28 @@ export const GovernancePage: React.FC = () => {
|
|||||||
{getUserEmail(pr.ownedBy)}
|
{getUserEmail(pr.ownedBy)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-5 text-right">
|
<td className="py-3.5 px-5 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedProgrammeForInstruments(pr);
|
||||||
|
setIsInstrumentsModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
|
title="View Funding Instruments"
|
||||||
|
>
|
||||||
|
<Coins className="w-3 h-3" />
|
||||||
|
Instruments
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => openEditProgramme(pr)}
|
onClick={() => openEditProgramme(pr)}
|
||||||
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-3 h-3" />
|
<Edit2 className="w-3 h-3" />
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -855,6 +913,125 @@ export const GovernancePage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal: View Funding Instruments */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isInstrumentsModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsInstrumentsModalOpen(false);
|
||||||
|
setSelectedProgrammeForInstruments(null);
|
||||||
|
}}
|
||||||
|
title={`Funding Instruments: ${selectedProgrammeForInstruments?.name || ''}`}
|
||||||
|
subtitle={`Authoritative funding instruments linked to Programme #${selectedProgrammeForInstruments?.id || ''}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{loadingInstruments ? (
|
||||||
|
<div className="py-8 text-center text-xs text-slate-400">Loading funding instruments...</div>
|
||||||
|
) : programmeInstruments.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-xs text-slate-500">
|
||||||
|
No funding instruments registered under this programme.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5 max-h-[400px] overflow-y-auto">
|
||||||
|
{programmeInstruments.map((inst: Instrument) => {
|
||||||
|
const statusBadge = getActivityStatusBadge(inst.status);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={inst.id}
|
||||||
|
className="p-3.5 rounded-xl border border-slate-800 bg-[#0d121f] flex flex-col gap-1.5"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-xs font-bold text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded border border-blue-500/20">
|
||||||
|
{inst.code || `INST-${inst.id}`}
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-xs text-white">{inst.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
||||||
|
{statusBadge.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{inst.description && (
|
||||||
|
<p className="text-xs text-slate-400 leading-relaxed">{inst.description}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t border-slate-800/80 text-[10px] text-slate-500 font-mono">
|
||||||
|
<span>Created by: {getUserEmail(inst.createdBy)}</span>
|
||||||
|
<span>{formatDate(inst.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-2 border-t border-slate-800 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsInstrumentsModalOpen(false);
|
||||||
|
setSelectedProgrammeForInstruments(null);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Modal: View Portfolio Programmes */}
|
||||||
|
<Modal
|
||||||
|
isOpen={isPortfolioProgrammesModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsPortfolioProgrammesModalOpen(false);
|
||||||
|
setSelectedPortfolioForProgrammes(null);
|
||||||
|
}}
|
||||||
|
title={`Portfolio Programmes: ${selectedPortfolioForProgrammes?.name || ''}`}
|
||||||
|
subtitle={`Programmes allocated under Portfolio #${selectedPortfolioForProgrammes?.id || ''}`}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{loadingPortfolioProgrammes ? (
|
||||||
|
<div className="py-8 text-center text-xs text-slate-400">Loading portfolio programmes...</div>
|
||||||
|
) : portfolioProgrammesList.length === 0 ? (
|
||||||
|
<div className="py-8 text-center text-xs text-slate-500">
|
||||||
|
No programmes registered under this portfolio.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5 max-h-[400px] overflow-y-auto">
|
||||||
|
{portfolioProgrammesList.map((pr: Programme) => {
|
||||||
|
const statusBadge = getApprovalStatusBadge(pr.status);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={pr.id}
|
||||||
|
className="p-3.5 rounded-xl border border-slate-800 bg-[#0d121f] flex items-center justify-between"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span className="font-semibold text-xs text-white block">{pr.name}</span>
|
||||||
|
<span className="text-[10px] text-slate-500 font-mono">
|
||||||
|
ID: {pr.id} • Owner: {getUserEmail(pr.ownedBy)} • {formatDate(pr.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
|
||||||
|
{statusBadge.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-2 border-t border-slate-800 flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setIsPortfolioProgrammesModalOpen(false);
|
||||||
|
setSelectedPortfolioForProgrammes(null);
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-xs font-semibold text-slate-300 bg-slate-800 hover:bg-slate-700 rounded-xl cursor-pointer"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,16 +18,26 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
FileCheck2,
|
FileCheck2,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
Link as LinkIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getAwards,
|
getAwards,
|
||||||
|
getAward,
|
||||||
createAward,
|
createAward,
|
||||||
getContracts,
|
getContracts,
|
||||||
|
getContract,
|
||||||
createContract,
|
createContract,
|
||||||
|
updateContract,
|
||||||
|
uploadContractFile,
|
||||||
|
getContractSecureLink,
|
||||||
executeContract,
|
executeContract,
|
||||||
getBudgets,
|
getBudgets,
|
||||||
|
getBudget,
|
||||||
createBudget,
|
createBudget,
|
||||||
getDisbursements,
|
getDisbursements,
|
||||||
|
getDisbursement,
|
||||||
createDisbursement,
|
createDisbursement,
|
||||||
approveDisbursement,
|
approveDisbursement,
|
||||||
getOrganisations,
|
getOrganisations,
|
||||||
@@ -301,6 +311,40 @@ export const GrantsPage: React.FC = () => {
|
|||||||
setIsExecuteModalOpen(true);
|
setIsExecuteModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGetContractSecureLink = async (contractId: string | number) => {
|
||||||
|
try {
|
||||||
|
const url = await getContractSecureLink(contractId);
|
||||||
|
if (url) {
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
success('Secure URL Retrieved', 'Temporary signed download link opened.');
|
||||||
|
} else {
|
||||||
|
toastError('Download Error', 'No active secure download link generated.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
toastError('Link Error', err?.message || 'Failed to fetch contract secure link.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContractFileUpload = async (contractId: string | number, file: File) => {
|
||||||
|
try {
|
||||||
|
await uploadContractFile(contractId, file);
|
||||||
|
success('Document Attached', `Contract #${contractId} document uploaded successfully.`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||||
|
} catch (err: any) {
|
||||||
|
toastError('Upload Failed', err?.message || 'Contract file upload failed.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateContractStatus = async (contractId: string | number, status: ContractStatus) => {
|
||||||
|
try {
|
||||||
|
await updateContract({ contractId, status });
|
||||||
|
success('Contract Updated', `Contract status updated to ${getContractStatusBadge(status).label}.`);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['contracts'] });
|
||||||
|
} catch (err: any) {
|
||||||
|
toastError('Update Failed', err?.message || 'Failed to update contract status.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Summary calculations
|
// Summary calculations
|
||||||
const totalAwardValue = awards.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
const totalAwardValue = awards.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
|
||||||
const totalDisbursedValue = disbursements
|
const totalDisbursedValue = disbursements
|
||||||
@@ -385,7 +429,8 @@ export const GrantsPage: React.FC = () => {
|
|||||||
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
|
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('awards')}
|
onClick={() => setActiveTab('awards')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'awards'
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
||||||
|
activeTab === 'awards'
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||||
}`}
|
}`}
|
||||||
@@ -395,7 +440,8 @@ export const GrantsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('contracts')}
|
onClick={() => setActiveTab('contracts')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'contracts'
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
||||||
|
activeTab === 'contracts'
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||||
}`}
|
}`}
|
||||||
@@ -405,7 +451,8 @@ export const GrantsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('budgets')}
|
onClick={() => setActiveTab('budgets')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'budgets'
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
||||||
|
activeTab === 'budgets'
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||||
}`}
|
}`}
|
||||||
@@ -415,7 +462,8 @@ export const GrantsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('disbursements')}
|
onClick={() => setActiveTab('disbursements')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'disbursements'
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
||||||
|
activeTab === 'disbursements'
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||||
}`}
|
}`}
|
||||||
@@ -527,20 +575,59 @@ export const GrantsPage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3.5 px-5 text-right">
|
<td className="py-3.5 px-5 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleGetContractSecureLink(c.id)}
|
||||||
|
title="Fetch Secure Download URL"
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:text-white bg-slate-900/60 hover:bg-slate-800 border border-slate-700/60 transition cursor-pointer"
|
||||||
|
>
|
||||||
|
<LinkIcon className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label
|
||||||
|
title="Upload Executed Contract Document"
|
||||||
|
className="p-1.5 rounded-lg text-slate-400 hover:text-white bg-slate-900/60 hover:bg-slate-800 border border-slate-700/60 transition cursor-pointer inline-flex items-center"
|
||||||
|
>
|
||||||
|
<Upload className="w-3.5 h-3.5" />
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleContractFileUpload(c.id, file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={c.status}
|
||||||
|
onChange={(e) => handleUpdateContractStatus(c.id, Number(e.target.value) as ContractStatus)}
|
||||||
|
className="text-[10px] bg-slate-900/80 border border-slate-700/80 rounded-lg px-2 py-1 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 [&>option]:bg-slate-900 [&>option]:text-white cursor-pointer"
|
||||||
|
title="Update Status"
|
||||||
|
>
|
||||||
|
<option value={ContractStatus.Draft}>Draft</option>
|
||||||
|
<option value={ContractStatus.PendingApproval}>Pending Approval</option>
|
||||||
|
<option value={ContractStatus.Active}>Active / Executed</option>
|
||||||
|
<option value={ContractStatus.Terminated}>Terminated</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
{isExecuted ? (
|
{isExecuted ? (
|
||||||
<span className="inline-flex items-center gap-1.5 text-[11px] text-emerald-400 font-semibold bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20">
|
<span className="inline-flex items-center gap-1 text-[11px] text-emerald-400 font-semibold bg-emerald-500/10 px-2 py-1 rounded-lg border border-emerald-500/20">
|
||||||
<Lock className="w-3.5 h-3.5" />
|
<Lock className="w-3 h-3" />
|
||||||
Locked
|
Locked
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={() => openExecuteModal(c)}
|
onClick={() => openExecuteModal(c)}
|
||||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-lg shadow-md shadow-blue-500/20 transition"
|
className="inline-flex items-center gap-1 px-2.5 py-1 text-[11px] font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-lg shadow-xs shadow-blue-500/20 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<FileCheck2 className="w-3.5 h-3.5" />
|
<FileCheck2 className="w-3 h-3" />
|
||||||
Execute Contract
|
Execute
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Lock,
|
Lock,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Layers,
|
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
Coins,
|
Coins,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
@@ -16,6 +15,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '../auth/AuthProvider';
|
import { useAuth } from '../auth/AuthProvider';
|
||||||
|
import { BrandLogo } from '../components/ui/BrandLogo';
|
||||||
|
|
||||||
export const LandingPage: React.FC = () => {
|
export const LandingPage: React.FC = () => {
|
||||||
const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading } = useAuth();
|
const { isAuthenticated, user, claims, sub, login, logout, authError, isLoading } = useAuth();
|
||||||
@@ -53,14 +53,12 @@ export const LandingPage: React.FC = () => {
|
|||||||
{/* Top Header */}
|
{/* Top Header */}
|
||||||
<header className="relative z-10 border-b border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 flex items-center justify-between shadow-lg shadow-black/20">
|
<header className="relative z-10 border-b border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 flex items-center justify-between shadow-lg shadow-black/20">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-blue-600 to-indigo-700 flex items-center justify-center font-bold text-white shadow-md shadow-blue-500/20 border border-blue-400/20">
|
<BrandLogo size={40} showGlow animateOnHover />
|
||||||
<Layers className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-base font-bold tracking-tight text-white flex items-center gap-2">
|
<h1 className="text-base font-bold tracking-tight text-white flex items-center gap-2">
|
||||||
PFM Console
|
PFM Console
|
||||||
<span className="text-[10px] font-semibold uppercase bg-blue-500/10 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded font-mono">
|
<span className="text-[10px] font-semibold uppercase bg-blue-500/10 text-blue-400 border border-blue-500/30 px-2 py-0.5 rounded font-mono">
|
||||||
Enterprise STS
|
Enterprise SSO
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-xs text-slate-400">Post-Fund Management, Performance & Impact System</p>
|
<p className="text-xs text-slate-400">Post-Fund Management, Performance & Impact System</p>
|
||||||
@@ -80,7 +78,7 @@ export const LandingPage: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2 text-xs text-slate-400 font-medium bg-slate-900/60 border border-slate-800 px-3 py-1.5 rounded-full">
|
<div className="flex items-center gap-2 text-xs text-slate-400 font-medium bg-slate-900/60 border border-slate-800 px-3 py-1.5 rounded-full">
|
||||||
<span className="h-2 w-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
<span className="h-2 w-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||||
<span>Duende STS Ready</span>
|
<span>Single Sign-On Ready</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -159,7 +157,7 @@ export const LandingPage: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-300 border border-emerald-500/30 px-2 py-0.5 rounded">
|
<span className="text-[10px] font-mono bg-emerald-500/10 text-emerald-300 border border-emerald-500/30 px-2 py-0.5 rounded">
|
||||||
STS Token Active
|
Session Active
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -181,12 +179,21 @@ export const LandingPage: React.FC = () => {
|
|||||||
<span className="font-mono text-slate-300 truncate max-w-[200px]">{sub}</span>
|
<span className="font-mono text-slate-300 truncate max-w-[200px]">{sub}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{((claims.tenant as string) || user?.tenant) && (
|
||||||
|
<div className="pt-2 border-t border-slate-800/80 text-[11px] flex items-center justify-between text-slate-400">
|
||||||
|
<span>Assigned Tenant:</span>
|
||||||
|
<span className="font-semibold text-blue-300 truncate max-w-[200px]">
|
||||||
|
{(claims.tenant as string) || user?.tenant}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Guidance Message */}
|
{/* Guidance Message */}
|
||||||
<div className="p-4 rounded-xl bg-blue-950/30 border border-blue-800/40 text-xs text-blue-200 leading-relaxed">
|
<div className="p-4 rounded-xl bg-blue-950/30 border border-blue-800/40 text-xs text-blue-200 leading-relaxed">
|
||||||
<p className="font-semibold text-white mb-1">Session Verified</p>
|
<p className="font-semibold text-white mb-1">Session Verified</p>
|
||||||
You are authenticated through Duende Identity Server. Your token claims are validated and active. You can proceed to the PFM Console to manage projects, reviews, and disbursements, or sign out through the identity server.
|
You are authenticated with validated enterprise security credentials. Your token claims are active and verified. You can proceed to the PFM Console to manage projects, reviews, and disbursements, or sign out of your session.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation and Sign Out Buttons */}
|
{/* Navigation and Sign Out Buttons */}
|
||||||
@@ -206,16 +213,19 @@ export const LandingPage: React.FC = () => {
|
|||||||
className="w-full py-2.5 px-4 rounded-xl bg-slate-900 hover:bg-rose-950/40 hover:text-rose-300 text-slate-300 border border-slate-800 hover:border-rose-800/60 font-medium text-xs flex items-center justify-center gap-2 transition cursor-pointer"
|
className="w-full py-2.5 px-4 rounded-xl bg-slate-900 hover:bg-rose-950/40 hover:text-rose-300 text-slate-300 border border-slate-800 hover:border-rose-800/60 font-medium text-xs flex items-center justify-center gap-2 transition cursor-pointer"
|
||||||
>
|
>
|
||||||
<LogOut className="w-3.5 h-3.5" />
|
<LogOut className="w-3.5 h-3.5" />
|
||||||
<span>Sign Out via Identity Server</span>
|
<span>Sign Out of Session</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 border-t border-slate-800/80 text-[11px] text-slate-500 flex items-center justify-between">
|
<div className="pt-2 border-t border-slate-800/80 text-[11px] text-slate-500 flex items-center justify-between">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1.5">
|
||||||
<KeyRound className="w-3.5 h-3.5 text-slate-400" />
|
<KeyRound className="w-3.5 h-3.5 text-blue-400" />
|
||||||
Duende STS
|
<span>OAuth2 / OpenID Connect</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-emerald-400 font-medium text-[11px] flex items-center gap-1">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400 inline-block"></span>
|
||||||
|
Verified Session
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-slate-400 text-[10px]">Audience: pfm-api-dev</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -223,11 +233,14 @@ export const LandingPage: React.FC = () => {
|
|||||||
<div className="bg-[#0f1523]/90 border border-slate-800 rounded-2xl p-8 shadow-2xl relative overflow-hidden space-y-6 backdrop-blur-md">
|
<div className="bg-[#0f1523]/90 border border-slate-800 rounded-2xl p-8 shadow-2xl relative overflow-hidden space-y-6 backdrop-blur-md">
|
||||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 via-indigo-500 to-teal-400" />
|
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-blue-500 via-indigo-500 to-teal-400" />
|
||||||
|
|
||||||
<div className="text-center space-y-2 pt-2">
|
<div className="text-center space-y-3 pt-2">
|
||||||
<div className="mx-auto h-12 w-12 rounded-xl bg-blue-500/10 border border-blue-500/30 flex items-center justify-center text-blue-400 shadow-inner">
|
<div className="flex justify-center">
|
||||||
<Lock className="w-6 h-6" />
|
<BrandLogo size={48} showGlow animateOnHover={false} />
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
<h3 className="text-xl font-bold text-white tracking-tight">Sign In</h3>
|
<h3 className="text-xl font-bold text-white tracking-tight">Sign In</h3>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Enterprise Identity & Access Management</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{authError && (
|
{authError && (
|
||||||
@@ -249,11 +262,11 @@ export const LandingPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-2 border-t border-slate-800/80 text-[11px] text-slate-500 flex items-center justify-between">
|
<div className="pt-2 border-t border-slate-800/80 text-[11px] text-slate-500 flex items-center justify-between">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1.5">
|
||||||
<KeyRound className="w-3.5 h-3.5 text-slate-400" />
|
<KeyRound className="w-3.5 h-3.5 text-slate-400" />
|
||||||
Duende STS OIDC
|
<span>Single Sign-On (SSO)</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-slate-400 text-[10px]">pfm-api-dev</span>
|
<span className="text-slate-400 text-[11px]">Enterprise Security</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -262,8 +275,12 @@ export const LandingPage: React.FC = () => {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="relative z-10 border-t border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 text-center text-xs text-slate-500">
|
<footer className="relative z-10 border-t border-slate-800/80 bg-[#0d121f]/80 backdrop-blur-md px-6 py-4 flex flex-col sm:flex-row items-center justify-between gap-3 text-xs text-slate-500">
|
||||||
<p>Post-Fund Management, Performance & Impact System • Enterprise Governance Console</p>
|
<div className="flex items-center gap-2.5">
|
||||||
|
<BrandLogo size={20} showGlow={false} animateOnHover={false} />
|
||||||
|
<span className="text-slate-400 font-medium">Post-Fund Management, Performance & Impact System</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-slate-500">Enterprise Governance & Audit Console</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,12 +18,15 @@ import {
|
|||||||
Paperclip,
|
Paperclip,
|
||||||
Check,
|
Check,
|
||||||
Filter,
|
Filter,
|
||||||
|
Link as LinkIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
getAllIndicators,
|
getAllIndicators,
|
||||||
createIndicator,
|
createIndicator,
|
||||||
recordIndicatorActual,
|
recordIndicatorActual,
|
||||||
getAllEvidence,
|
getAllEvidence,
|
||||||
|
getMilestoneEvidence,
|
||||||
|
getEvidenceSecureLink,
|
||||||
uploadEvidenceFile,
|
uploadEvidenceFile,
|
||||||
updateEvidence,
|
updateEvidence,
|
||||||
getAllRisks,
|
getAllRisks,
|
||||||
@@ -93,6 +96,7 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
|
|
||||||
// Figure 10 Reviewer commentary
|
// Figure 10 Reviewer commentary
|
||||||
const [reviewerComment, setReviewerComment] = useState('');
|
const [reviewerComment, setReviewerComment] = useState('');
|
||||||
|
const [selectedMilestoneEvidenceFilter, setSelectedMilestoneEvidenceFilter] = useState<string | number | 'all'>('all');
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: indicators = [], isLoading: loadingIndicators } = useQuery({
|
const { data: indicators = [], isLoading: loadingIndicators } = useQuery({
|
||||||
@@ -101,8 +105,11 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: evidenceList = [], isLoading: loadingEvidence } = useQuery({
|
const { data: evidenceList = [], isLoading: loadingEvidence } = useQuery({
|
||||||
queryKey: ['evidence'],
|
queryKey: ['evidence', selectedMilestoneEvidenceFilter],
|
||||||
queryFn: () => getAllEvidence(),
|
queryFn: () =>
|
||||||
|
selectedMilestoneEvidenceFilter !== 'all'
|
||||||
|
? getMilestoneEvidence(selectedMilestoneEvidenceFilter)
|
||||||
|
: getAllEvidence(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: risks = [], isLoading: loadingRisks } = useQuery({
|
const { data: risks = [], isLoading: loadingRisks } = useQuery({
|
||||||
@@ -232,6 +239,20 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGetEvidenceSecureLink = async (evidenceId: string | number) => {
|
||||||
|
try {
|
||||||
|
const url = await getEvidenceSecureLink(evidenceId);
|
||||||
|
if (url) {
|
||||||
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
success('Secure URL Retrieved', 'Signed evidence download link opened.');
|
||||||
|
} else {
|
||||||
|
toastError('Download Error', 'No active secure download link generated.');
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
toastError('Link Error', err?.message || 'Failed to retrieve evidence secure link.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Indicator thresholds from SRS 9.6:
|
// Indicator thresholds from SRS 9.6:
|
||||||
// Green >= 90%, Amber 70-89%, Red < 70%
|
// Green >= 90%, Amber 70-89%, Red < 70%
|
||||||
const getIndicatorStatus = (actual: number, target: number) => {
|
const getIndicatorStatus = (actual: number, target: number) => {
|
||||||
@@ -281,7 +302,8 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
|
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('evidence')}
|
onClick={() => setActiveTab('evidence')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'evidence'
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
||||||
|
activeTab === 'evidence'
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||||
}`}
|
}`}
|
||||||
@@ -291,7 +313,8 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('indicators')}
|
onClick={() => setActiveTab('indicators')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'indicators'
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
||||||
|
activeTab === 'indicators'
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||||
}`}
|
}`}
|
||||||
@@ -301,7 +324,8 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('risks')}
|
onClick={() => setActiveTab('risks')}
|
||||||
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${activeTab === 'risks'
|
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
|
||||||
|
activeTab === 'risks'
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
|
||||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
|
||||||
}`}
|
}`}
|
||||||
@@ -402,10 +426,27 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
|
|
||||||
{/* Attached Evidence List */}
|
{/* Attached Evidence List */}
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
<h4 className="text-sm font-bold text-white">Attached Evidence (v2)</h4>
|
<h4 className="text-sm font-bold text-white">Attached Evidence (v2)</h4>
|
||||||
<span className="text-[11px] text-slate-400 font-mono">{evidenceList.length} files loaded</span>
|
<span className="text-[11px] text-slate-400 font-mono">{evidenceList.length} files loaded</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-slate-400">Milestone:</span>
|
||||||
|
<select
|
||||||
|
value={selectedMilestoneEvidenceFilter}
|
||||||
|
onChange={(e) => setSelectedMilestoneEvidenceFilter(e.target.value === 'all' ? 'all' : e.target.value)}
|
||||||
|
className="text-xs bg-slate-900 border border-slate-700/80 rounded-xl px-2.5 py-1 text-white focus:outline-none focus:ring-1 focus:ring-blue-500 max-w-[200px] [&>option]:bg-slate-900 [&>option]:text-white"
|
||||||
|
>
|
||||||
|
<option value="all">All Milestones</option>
|
||||||
|
{milestones.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name || `Milestone #${m.id}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-2.5">
|
||||||
{evidenceList.map((ev) => (
|
{evidenceList.map((ev) => (
|
||||||
@@ -429,6 +470,14 @@ export const MonitoringPage: React.FC = () => {
|
|||||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20">
|
||||||
Under Review
|
Under Review
|
||||||
</span>
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleGetEvidenceSecureLink(ev.id)}
|
||||||
|
className="p-1 rounded text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
|
||||||
|
title="Fetch Secure Signed Link"
|
||||||
|
>
|
||||||
|
<LinkIcon className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
{ev.documentUrl && (
|
{ev.documentUrl && (
|
||||||
<a
|
<a
|
||||||
href={ev.documentUrl}
|
href={ev.documentUrl}
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ import {
|
|||||||
getMilestones,
|
getMilestones,
|
||||||
createMilestone,
|
createMilestone,
|
||||||
getAllProgrammes,
|
getAllProgrammes,
|
||||||
|
getProgrammeProjects,
|
||||||
|
getActivity,
|
||||||
|
getMilestone,
|
||||||
getProjectIndicators,
|
getProjectIndicators,
|
||||||
getProjectRisks,
|
getProjectRisks,
|
||||||
} from '../services/api';
|
} from '../services/api';
|
||||||
@@ -86,6 +89,7 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
|
|
||||||
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
const [selectedProject, setSelectedProject] = useState<Project | null>(null);
|
||||||
const [detailTab, setDetailTab] = useState<'activities' | 'milestones' | 'indicators' | 'risks'>('activities');
|
const [detailTab, setDetailTab] = useState<'activities' | 'milestones' | 'indicators' | 'risks'>('activities');
|
||||||
|
const [selectedProgrammeFilter, setSelectedProgrammeFilter] = useState<string | number | 'all'>('all');
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||||
@@ -93,16 +97,19 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
const [isMilestoneModalOpen, setIsMilestoneModalOpen] = useState(false);
|
const [isMilestoneModalOpen, setIsMilestoneModalOpen] = useState(false);
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: projects = [], isLoading: loadingProjects } = useQuery({
|
|
||||||
queryKey: ['projects'],
|
|
||||||
queryFn: () => getProjects(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: programmes = [] } = useQuery({
|
const { data: programmes = [] } = useQuery({
|
||||||
queryKey: ['programmes'],
|
queryKey: ['programmes'],
|
||||||
queryFn: () => getAllProgrammes(),
|
queryFn: () => getAllProgrammes(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: projects = [], isLoading: loadingProjects } = useQuery({
|
||||||
|
queryKey: ['projects', selectedProgrammeFilter],
|
||||||
|
queryFn: () =>
|
||||||
|
selectedProgrammeFilter !== 'all'
|
||||||
|
? getProgrammeProjects(selectedProgrammeFilter)
|
||||||
|
: getProjects(),
|
||||||
|
});
|
||||||
|
|
||||||
// Details Queries (for currently selected project)
|
// Details Queries (for currently selected project)
|
||||||
const activeProjectId = selectedProject ? selectedProject.id : null;
|
const activeProjectId = selectedProject ? selectedProject.id : null;
|
||||||
|
|
||||||
@@ -251,9 +258,23 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
{/* Left Column: Project Directory List */}
|
{/* Left Column: Project Directory List */}
|
||||||
<div className="lg:col-span-5 space-y-3">
|
<div className="lg:col-span-5 space-y-3">
|
||||||
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider px-1">
|
<div className="flex items-center justify-between gap-2 px-1">
|
||||||
|
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider">
|
||||||
Projects Directory ({projects.length})
|
Projects Directory ({projects.length})
|
||||||
</h3>
|
</h3>
|
||||||
|
<select
|
||||||
|
value={selectedProgrammeFilter}
|
||||||
|
onChange={(e) => setSelectedProgrammeFilter(e.target.value === 'all' ? 'all' : e.target.value)}
|
||||||
|
className="text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl px-2.5 py-1 text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 max-w-[190px] [&>option]:bg-slate-900 [&>option]:text-white"
|
||||||
|
>
|
||||||
|
<option value="all">All Programmes</option>
|
||||||
|
{programmes.map((pr) => (
|
||||||
|
<option key={pr.id} value={pr.id}>
|
||||||
|
{pr.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{loadingProjects ? (
|
{loadingProjects ? (
|
||||||
<div className="bg-[#0f1523]/80 p-6 rounded-2xl border border-slate-800/80 text-center text-xs text-slate-400">
|
<div className="bg-[#0f1523]/80 p-6 rounded-2xl border border-slate-800/80 text-center text-xs text-slate-400">
|
||||||
@@ -268,7 +289,8 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
<div
|
<div
|
||||||
key={p.id}
|
key={p.id}
|
||||||
onClick={() => setSelectedProject(p)}
|
onClick={() => setSelectedProject(p)}
|
||||||
className={`p-4 rounded-2xl border transition-all cursor-pointer bg-[#0f1523]/80 backdrop-blur-sm text-left ${isSelected
|
className={`p-4 rounded-2xl border transition-all cursor-pointer bg-[#0f1523]/80 backdrop-blur-sm text-left ${
|
||||||
|
isSelected
|
||||||
? 'border-blue-500 ring-2 ring-blue-500/20 shadow-lg shadow-blue-500/10'
|
? 'border-blue-500 ring-2 ring-blue-500/20 shadow-lg shadow-blue-500/10'
|
||||||
: 'border-slate-800/80 hover:border-slate-700 hover:shadow-md'
|
: 'border-slate-800/80 hover:border-slate-700 hover:shadow-md'
|
||||||
}`}
|
}`}
|
||||||
@@ -359,7 +381,8 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
<div className="flex border-b border-slate-800/80 px-4 bg-[#0d121f]/60">
|
<div className="flex border-b border-slate-800/80 px-4 bg-[#0d121f]/60">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDetailTab('activities')}
|
onClick={() => setDetailTab('activities')}
|
||||||
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${detailTab === 'activities'
|
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
|
||||||
|
detailTab === 'activities'
|
||||||
? 'border-blue-500 text-blue-400'
|
? 'border-blue-500 text-blue-400'
|
||||||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
@@ -369,7 +392,8 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDetailTab('milestones')}
|
onClick={() => setDetailTab('milestones')}
|
||||||
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${detailTab === 'milestones'
|
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
|
||||||
|
detailTab === 'milestones'
|
||||||
? 'border-blue-500 text-blue-400'
|
? 'border-blue-500 text-blue-400'
|
||||||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
@@ -379,7 +403,8 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDetailTab('indicators')}
|
onClick={() => setDetailTab('indicators')}
|
||||||
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${detailTab === 'indicators'
|
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
|
||||||
|
detailTab === 'indicators'
|
||||||
? 'border-blue-500 text-blue-400'
|
? 'border-blue-500 text-blue-400'
|
||||||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
@@ -389,7 +414,8 @@ export const ProjectsPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setDetailTab('risks')}
|
onClick={() => setDetailTab('risks')}
|
||||||
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${detailTab === 'risks'
|
className={`py-3 px-3 text-xs font-semibold border-b-2 transition flex items-center gap-1.5 cursor-pointer ${
|
||||||
|
detailTab === 'risks'
|
||||||
? 'border-blue-500 text-blue-400'
|
? 'border-blue-500 text-blue-400'
|
||||||
: 'border-transparent text-slate-400 hover:text-slate-200'
|
: 'border-transparent text-slate-400 hover:text-slate-200'
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@ import {
|
|||||||
Programme,
|
Programme,
|
||||||
CreateProgrammeRequest,
|
CreateProgrammeRequest,
|
||||||
UpdateProgrammeRequest,
|
UpdateProgrammeRequest,
|
||||||
|
Instrument,
|
||||||
Project,
|
Project,
|
||||||
CreateProjectRequest,
|
CreateProjectRequest,
|
||||||
UpdateProjectRequest,
|
UpdateProjectRequest,
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
CreateAwardRequest,
|
CreateAwardRequest,
|
||||||
Contract,
|
Contract,
|
||||||
CreateContractRequest,
|
CreateContractRequest,
|
||||||
|
UpdateContractRequest,
|
||||||
ExecuteContractRequest,
|
ExecuteContractRequest,
|
||||||
Budget,
|
Budget,
|
||||||
CreateBudgetRequest,
|
CreateBudgetRequest,
|
||||||
@@ -46,6 +48,7 @@ import {
|
|||||||
InviteUserRequest,
|
InviteUserRequest,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
ProblemDetails,
|
ProblemDetails,
|
||||||
|
ActivityStatus,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
// Helper to extract ProblemDetails from Axios error
|
// Helper to extract ProblemDetails from Axios error
|
||||||
@@ -116,8 +119,24 @@ export const getPortfolios = async (organisationId: string | number): Promise<Po
|
|||||||
return res.data || [];
|
return res.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPortfolio = async (portfolioId: string | number): Promise<Portfolio> => {
|
||||||
|
const hashedPortId = await toolsService.resolvePortfolioId(portfolioId);
|
||||||
|
const res = await apiClient.get<Portfolio | Portfolio[]>(`/v1/api/governance/portfolios/${hashedPortId}`);
|
||||||
|
return Array.isArray(res.data) ? res.data[0] : res.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const getAllPortfolios = async (): Promise<Portfolio[]> => {
|
export const getAllPortfolios = async (): Promise<Portfolio[]> => {
|
||||||
try {
|
try {
|
||||||
|
const orgs = await getOrganisations(1, 10);
|
||||||
|
if (orgs.length > 0) {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
orgs.map((org) => getPortfolios(org.id))
|
||||||
|
);
|
||||||
|
const combined = results
|
||||||
|
.filter((r): r is PromiseFulfilledResult<Portfolio[]> => r.status === 'fulfilled')
|
||||||
|
.flatMap((r) => r.value);
|
||||||
|
if (combined.length > 0) return combined;
|
||||||
|
}
|
||||||
const res = await apiClient.get<Portfolio[]>('/v1/api/governance/organisations/portfolio');
|
const res = await apiClient.get<Portfolio[]>('/v1/api/governance/organisations/portfolio');
|
||||||
return res.data || [];
|
return res.data || [];
|
||||||
} catch {
|
} catch {
|
||||||
@@ -125,6 +144,12 @@ export const getAllPortfolios = async (): Promise<Portfolio[]> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getPortfolioProgrammes = async (portfolioId: string | number): Promise<Programme[]> => {
|
||||||
|
const hashedPortId = await toolsService.resolvePortfolioId(portfolioId);
|
||||||
|
const res = await apiClient.get<Programme[]>(`/v1/api/governance/portfolios/${hashedPortId}/programmes`);
|
||||||
|
return res.data || [];
|
||||||
|
};
|
||||||
|
|
||||||
export const createPortfolio = async (payload: CreatePortfolioRequest): Promise<Portfolio> => {
|
export const createPortfolio = async (payload: CreatePortfolioRequest): Promise<Portfolio> => {
|
||||||
const userSub = getCurrentUserSub();
|
const userSub = getCurrentUserSub();
|
||||||
const res = await apiClient.post('/v1/api/governance/organisations/portfolio', {
|
const res = await apiClient.post('/v1/api/governance/organisations/portfolio', {
|
||||||
@@ -151,18 +176,30 @@ export const getProgrammes = async (portfolioId: string | number): Promise<Progr
|
|||||||
return res.data || [];
|
return res.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllProgrammes = async (): Promise<Programme[]> => {
|
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
|
||||||
try {
|
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
||||||
const res = await apiClient.get<Programme[]>('/v1/api/projects/programmes');
|
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
|
||||||
|
return res.data || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 3. Project & Milestone Management APIs
|
||||||
|
// ==========================================
|
||||||
|
export const getAllProgrammes = async (page = 1, pageSize = 100): Promise<Programme[]> => {
|
||||||
|
const res = await apiClient.get<Programme[]>(`/v1/api/projects/programme/${page}/${pageSize}`);
|
||||||
|
return res.data || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProgramme = async (programmeId: string | number): Promise<Programme> => {
|
||||||
|
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
||||||
|
const res = await apiClient.get<Programme>(`/v1/api/projects/programme/${hashedProgId}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProgrammeProjects = async (programmeId: string | number): Promise<Project[]> => {
|
||||||
|
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
|
||||||
|
const res = await apiClient.get<Project[]>(`/v1/api/projects/${hashedProgId}/programme`);
|
||||||
return res.data || [];
|
return res.data || [];
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
const resFallback = await apiClient.get<Programme[]>('/v1/api/governance/programmes');
|
|
||||||
return resFallback.data || [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const createProgramme = async (payload: CreateProgrammeRequest): Promise<Programme> => {
|
export const createProgramme = async (payload: CreateProgrammeRequest): Promise<Programme> => {
|
||||||
@@ -185,9 +222,6 @@ export const updateProgramme = async (payload: UpdateProgrammeRequest): Promise<
|
|||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 3. Project & Milestone Management APIs
|
|
||||||
// ==========================================
|
|
||||||
export const getProjects = async (page = 1, pageSize = 100): Promise<Project[]> => {
|
export const getProjects = async (page = 1, pageSize = 100): Promise<Project[]> => {
|
||||||
const res = await apiClient.get<Project[]>('/v1/api/projects', {
|
const res = await apiClient.get<Project[]>('/v1/api/projects', {
|
||||||
params: { page, pageSize },
|
params: { page, pageSize },
|
||||||
@@ -233,6 +267,12 @@ export const getActivities = async (projectId: string | number): Promise<Activit
|
|||||||
return res.data || [];
|
return res.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getActivity = async (activityId: string | number): Promise<Activity> => {
|
||||||
|
const hashedActivityId = await toolsService.encodeId(activityId);
|
||||||
|
const res = await apiClient.get<Activity>(`/v1/api/projects/activities/${hashedActivityId}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const createActivity = async (payload: CreateActivityRequest): Promise<Activity> => {
|
export const createActivity = async (payload: CreateActivityRequest): Promise<Activity> => {
|
||||||
const userSub = getCurrentUserSub();
|
const userSub = getCurrentUserSub();
|
||||||
const res = await apiClient.post('/v1/api/projects/activities', {
|
const res = await apiClient.post('/v1/api/projects/activities', {
|
||||||
@@ -253,6 +293,12 @@ export const getAllMilestones = async (page = 1, pageSize = 100): Promise<Milest
|
|||||||
return res.data || [];
|
return res.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getMilestone = async (milestoneId: string | number): Promise<Milestone> => {
|
||||||
|
const hashedMilestoneId = await toolsService.encodeId(milestoneId);
|
||||||
|
const res = await apiClient.get<Milestone>(`/v1/api/projects/milestones/${hashedMilestoneId}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const createMilestone = async (payload: CreateMilestoneRequest): Promise<Milestone> => {
|
export const createMilestone = async (payload: CreateMilestoneRequest): Promise<Milestone> => {
|
||||||
const userSub = getCurrentUserSub();
|
const userSub = getCurrentUserSub();
|
||||||
const res = await apiClient.post('/v1/api/evidence/milestones', {
|
const res = await apiClient.post('/v1/api/evidence/milestones', {
|
||||||
@@ -270,6 +316,18 @@ export const updateMilestone = async (payload: UpdateMilestoneRequest): Promise<
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getProjectIndicators = async (projectId: string | number): Promise<Indicator[]> => {
|
||||||
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
||||||
|
const res = await apiClient.get<Indicator[]>(`/v1/api/projects/indicators/project/${hashedProjectId}`);
|
||||||
|
return res.data || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getProjectRisks = async (projectId: string | number): Promise<Risk[]> => {
|
||||||
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
||||||
|
const res = await apiClient.get<Risk[]>(`/v1/api/projects/${hashedProjectId}/risks`);
|
||||||
|
return res.data || [];
|
||||||
|
};
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 4. Grant & Financial Management APIs
|
// 4. Grant & Financial Management APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -281,7 +339,8 @@ export const getAwards = async (page = 1, pageSize = 100): Promise<Award[]> => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getAward = async (awardId: string | number): Promise<Award> => {
|
export const getAward = async (awardId: string | number): Promise<Award> => {
|
||||||
const res = await apiClient.get<Award>(`/v1/api/grants/awards/${awardId}`);
|
const hashedAwardId = await toolsService.resolveAwardId(awardId);
|
||||||
|
const res = await apiClient.get<Award>(`/v1/api/grants/awards/${hashedAwardId}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -302,7 +361,8 @@ export const getContracts = async (page = 1, pageSize = 100): Promise<Contract[]
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getContract = async (contractId: string | number): Promise<Contract> => {
|
export const getContract = async (contractId: string | number): Promise<Contract> => {
|
||||||
const res = await apiClient.get<Contract>(`/v1/api/grants/contracts/${contractId}`);
|
const hashedContractId = await toolsService.resolveContractId(contractId);
|
||||||
|
const res = await apiClient.get<Contract>(`/v1/api/grants/contracts/${hashedContractId}`);
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -315,6 +375,30 @@ export const createContract = async (payload: CreateContractRequest): Promise<Co
|
|||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const updateContract = async (payload: UpdateContractRequest): Promise<void> => {
|
||||||
|
await apiClient.put('/v1/api/grants/contracts', payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const uploadContractFile = async (
|
||||||
|
contractId: string | number,
|
||||||
|
file: File | FormData
|
||||||
|
): Promise<void> => {
|
||||||
|
const hashedContractId = await toolsService.resolveContractId(contractId);
|
||||||
|
const formData = file instanceof FormData ? file : new FormData();
|
||||||
|
if (file instanceof File) {
|
||||||
|
formData.append('file', file);
|
||||||
|
}
|
||||||
|
await apiClient.post(`/v1/api/grants/contracts/${hashedContractId}`, formData, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getContractSecureLink = async (contractId: string | number): Promise<string> => {
|
||||||
|
const hashedContractId = await toolsService.resolveContractId(contractId);
|
||||||
|
const res = await apiClient.get<string>(`/v1/api/grants/contracts/${hashedContractId}/securelink`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const executeContract = async (payload: ExecuteContractRequest): Promise<void> => {
|
export const executeContract = async (payload: ExecuteContractRequest): Promise<void> => {
|
||||||
const userSub = getCurrentUserSub();
|
const userSub = getCurrentUserSub();
|
||||||
await apiClient.post('/v1/api/grants/contracts/execute', {
|
await apiClient.post('/v1/api/grants/contracts/execute', {
|
||||||
@@ -330,6 +414,12 @@ export const getBudgets = async (page = 1, pageSize = 100): Promise<Budget[]> =>
|
|||||||
return res.data || [];
|
return res.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getBudget = async (budgetId: string | number): Promise<Budget> => {
|
||||||
|
const hashedBudgetId = await toolsService.encodeId(budgetId);
|
||||||
|
const res = await apiClient.get<Budget>(`/v1/api/grants/budgets/${hashedBudgetId}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const createBudget = async (payload: CreateBudgetRequest): Promise<Budget> => {
|
export const createBudget = async (payload: CreateBudgetRequest): Promise<Budget> => {
|
||||||
const userSub = getCurrentUserSub();
|
const userSub = getCurrentUserSub();
|
||||||
const res = await apiClient.post('/v1/api/grants/budgets', {
|
const res = await apiClient.post('/v1/api/grants/budgets', {
|
||||||
@@ -339,6 +429,9 @@ export const createBudget = async (payload: CreateBudgetRequest): Promise<Budget
|
|||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 5. Execution (Disbursements & Risks) APIs
|
||||||
|
// ==========================================
|
||||||
export const getDisbursements = async (page = 1, pageSize = 100): Promise<Disbursement[]> => {
|
export const getDisbursements = async (page = 1, pageSize = 100): Promise<Disbursement[]> => {
|
||||||
const res = await apiClient.get<Disbursement[]>('/v1/api/execution/disbursements', {
|
const res = await apiClient.get<Disbursement[]>('/v1/api/execution/disbursements', {
|
||||||
params: { page, pageSize },
|
params: { page, pageSize },
|
||||||
@@ -346,6 +439,12 @@ export const getDisbursements = async (page = 1, pageSize = 100): Promise<Disbur
|
|||||||
return res.data || [];
|
return res.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getDisbursement = async (disbursementId: string | number): Promise<Disbursement> => {
|
||||||
|
const hashedDisbId = await toolsService.encodeId(disbursementId);
|
||||||
|
const res = await apiClient.get<Disbursement>(`/v1/api/execution/disbursements/${hashedDisbId}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const createDisbursement = async (payload: CreateDisbursementRequest): Promise<Disbursement> => {
|
export const createDisbursement = async (payload: CreateDisbursementRequest): Promise<Disbursement> => {
|
||||||
const userSub = getCurrentUserSub();
|
const userSub = getCurrentUserSub();
|
||||||
const res = await apiClient.post('/v1/api/execution/disbursements', {
|
const res = await apiClient.post('/v1/api/execution/disbursements', {
|
||||||
@@ -364,26 +463,57 @@ export const updateDisbursement = async (payload: UpdateDisbursementRequest): Pr
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const approveDisbursement = async (disbursementId: string | number): Promise<void> => {
|
export const approveDisbursement = async (disbursementId: string | number): Promise<void> => {
|
||||||
|
const hashedDisbId = await toolsService.encodeId(disbursementId);
|
||||||
const userSub = getCurrentUserSub();
|
const userSub = getCurrentUserSub();
|
||||||
await apiClient.post(`/v1/api/execution/disbursements/${disbursementId}/approve`, {
|
await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {
|
||||||
approvedBy: userSub || undefined,
|
approvedBy: userSub || undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==========================================
|
export const getAllRisks = async (page = 1, pageSize = 100): Promise<Risk[]> => {
|
||||||
// 5. M&E, Evidence & Risk Management APIs
|
const res = await apiClient.get<Risk[]>('/v1/api/execution/risks', {
|
||||||
// ==========================================
|
params: { page, pageSize },
|
||||||
export const getProjectIndicators = async (projectId: string | number): Promise<Indicator[]> => {
|
});
|
||||||
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
|
||||||
const res = await apiClient.get<Indicator[]>(`/v1/api/projects/indicators/project/${hashedProjectId}`);
|
|
||||||
return res.data || [];
|
return res.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getRisk = async (riskId: string | number): Promise<Risk> => {
|
||||||
|
const hashedRiskId = await toolsService.encodeId(riskId);
|
||||||
|
const res = await apiClient.get<Risk>(`/v1/api/execution/risks/${hashedRiskId}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createRisk = async (payload: CreateRiskRequest): Promise<Risk> => {
|
||||||
|
const userSub = getCurrentUserSub();
|
||||||
|
const res = await apiClient.post('/v1/api/execution/risks', {
|
||||||
|
...payload,
|
||||||
|
createdBy: userSub || undefined,
|
||||||
|
});
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateRisk = async (payload: UpdateRiskRequest): Promise<void> => {
|
||||||
|
const userSub = getCurrentUserSub();
|
||||||
|
await apiClient.put('/v1/api/execution/risks', {
|
||||||
|
...payload,
|
||||||
|
updatedBy: userSub || undefined,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 6. Evidence & M&E APIs
|
||||||
|
// ==========================================
|
||||||
export const getAllIndicators = async (page = 1, pageSize = 100): Promise<Indicator[]> => {
|
export const getAllIndicators = async (page = 1, pageSize = 100): Promise<Indicator[]> => {
|
||||||
const res = await apiClient.get<Indicator[]>(`/v1/api/evidence/indicators/${page}/${pageSize}`);
|
const res = await apiClient.get<Indicator[]>(`/v1/api/evidence/indicators/${page}/${pageSize}`);
|
||||||
return res.data || [];
|
return res.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getIndicator = async (indicatorId: string | number): Promise<Indicator> => {
|
||||||
|
const hashedIndId = await toolsService.encodeId(indicatorId);
|
||||||
|
const res = await apiClient.get<Indicator>(`/v1/api/evidence/indicators/${hashedIndId}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
export const createIndicator = async (payload: CreateIndicatorRequest): Promise<Indicator> => {
|
export const createIndicator = async (payload: CreateIndicatorRequest): Promise<Indicator> => {
|
||||||
const userSub = getCurrentUserSub();
|
const userSub = getCurrentUserSub();
|
||||||
const res = await apiClient.post('/v1/api/evidence/indicators', {
|
const res = await apiClient.post('/v1/api/evidence/indicators', {
|
||||||
@@ -394,27 +524,46 @@ export const createIndicator = async (payload: CreateIndicatorRequest): Promise<
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const recordIndicatorActual = async (indicatorId: string | number, actualAmount: number): Promise<void> => {
|
export const recordIndicatorActual = async (indicatorId: string | number, actualAmount: number): Promise<void> => {
|
||||||
|
const hashedIndId = await toolsService.encodeId(indicatorId);
|
||||||
const userSub = getCurrentUserSub();
|
const userSub = getCurrentUserSub();
|
||||||
await apiClient.post(`/v1/api/evidence/indicators/${indicatorId}/actuals/${actualAmount}`, {
|
await apiClient.post(`/v1/api/evidence/indicators/${hashedIndId}/actuals/${actualAmount}`, {
|
||||||
recordedBy: userSub || undefined,
|
recordedBy: userSub || undefined,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getEvidenceFiles = async (page = 1, pageSize = 100): Promise<Evidence[]> => {
|
||||||
|
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/files/${page}/${pageSize}`);
|
||||||
|
return res.data || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getAllEvidence = async (page = 1, pageSize = 100): Promise<Evidence[]> => {
|
||||||
|
return getEvidenceFiles(page, pageSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEvidence = async (evidenceId: string | number): Promise<Evidence> => {
|
||||||
|
const hashedEvId = await toolsService.encodeId(evidenceId);
|
||||||
|
const res = await apiClient.get<Evidence>(`/v1/api/evidence/${hashedEvId}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEvidenceSecureLink = async (evidenceId: string | number): Promise<string> => {
|
||||||
|
const hashedEvId = await toolsService.encodeId(evidenceId);
|
||||||
|
const res = await apiClient.get<string>(`/v1/api/evidence/${hashedEvId}/securelink`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getMilestoneEvidence = async (milestoneId: string | number): Promise<Evidence[]> => {
|
||||||
|
const hashedMId = await toolsService.encodeId(milestoneId);
|
||||||
|
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/milestone/${hashedMId}`);
|
||||||
|
return res.data || [];
|
||||||
|
};
|
||||||
|
|
||||||
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
|
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
|
||||||
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
||||||
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${hashedProjectId}`);
|
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${hashedProjectId}`);
|
||||||
return res.data || [];
|
return res.data || [];
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getAllEvidence = async (): Promise<Evidence[]> => {
|
|
||||||
try {
|
|
||||||
const res = await apiClient.get<Evidence[]>('/v1/api/evidence');
|
|
||||||
return res.data || [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const uploadEvidenceFile = async (formData: FormData): Promise<Evidence> => {
|
export const uploadEvidenceFile = async (formData: FormData): Promise<Evidence> => {
|
||||||
const userSub = getCurrentUserSub();
|
const userSub = getCurrentUserSub();
|
||||||
if (userSub && !formData.get('userId')) {
|
if (userSub && !formData.get('userId')) {
|
||||||
@@ -434,38 +583,8 @@ export const updateEvidence = async (payload: UpdateEvidenceRequest): Promise<vo
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getProjectRisks = async (projectId: string | number): Promise<Risk[]> => {
|
|
||||||
const hashedProjectId = await toolsService.resolveProjectId(projectId);
|
|
||||||
const res = await apiClient.get<Risk[]>(`/v1/api/projects/${hashedProjectId}/risks`);
|
|
||||||
return res.data || [];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getAllRisks = async (page = 1, pageSize = 100): Promise<Risk[]> => {
|
|
||||||
const res = await apiClient.get<Risk[]>('/v1/api/execution/risks', {
|
|
||||||
params: { page, pageSize },
|
|
||||||
});
|
|
||||||
return res.data || [];
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createRisk = async (payload: CreateRiskRequest): Promise<Risk> => {
|
|
||||||
const userSub = getCurrentUserSub();
|
|
||||||
const res = await apiClient.post('/v1/api/execution/risks', {
|
|
||||||
...payload,
|
|
||||||
createdBy: userSub || undefined,
|
|
||||||
});
|
|
||||||
return res.data;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateRisk = async (payload: UpdateRiskRequest): Promise<void> => {
|
|
||||||
const userSub = getCurrentUserSub();
|
|
||||||
await apiClient.put('/v1/api/execution/risks', {
|
|
||||||
...payload,
|
|
||||||
updatedBy: userSub || undefined,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// 6. User Directory & Communications APIs
|
// 7. Users & Background Integration APIs
|
||||||
// ==========================================
|
// ==========================================
|
||||||
export const getUsers = async (
|
export const getUsers = async (
|
||||||
page = 1,
|
page = 1,
|
||||||
@@ -484,34 +603,56 @@ export const getUser = async (userId: string): Promise<User> => {
|
|||||||
export const getCurrentUser = async (): Promise<User> => {
|
export const getCurrentUser = async (): Promise<User> => {
|
||||||
const userSub = getCurrentUserSub();
|
const userSub = getCurrentUserSub();
|
||||||
if (userSub) {
|
if (userSub) {
|
||||||
return getUser(userSub);
|
try {
|
||||||
|
return await getUser(userSub);
|
||||||
|
} catch {
|
||||||
|
// Fallback
|
||||||
}
|
}
|
||||||
const res = await apiClient.get<User>('/v1/api/users/current');
|
}
|
||||||
return res.data;
|
return {
|
||||||
|
id: userSub || 'user-1',
|
||||||
|
email: null,
|
||||||
|
status: ActivityStatus.Active,
|
||||||
|
lastLoginAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const updateUserStatus = async (userId: string, status: number): Promise<void> => {
|
export const updateUserStatus = async (userId: string, status: ActivityStatus | number): Promise<void> => {
|
||||||
await apiClient.get(`/v1/api/users/${userId}/status/${status}`);
|
await apiClient.get(`/v1/api/users/${userId}/status/${status}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const syncIntegration = async (): Promise<void> => {
|
||||||
|
await apiClient.post('/v1/api/integration/sync');
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 8. Communications & Invites APIs
|
||||||
|
// ==========================================
|
||||||
export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
|
export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
|
||||||
const userSub = getCurrentUserSub();
|
const userSub = getCurrentUserSub();
|
||||||
const res = await apiClient.post('/v1/api/communications/invite', {
|
const res = await apiClient.post<Invite>('/v1/api/communications/invite', {
|
||||||
...payload,
|
...payload,
|
||||||
createdBy: userSub || undefined,
|
createdBy: userSub || undefined,
|
||||||
});
|
});
|
||||||
return res.data;
|
return res.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getInvites = async (): Promise<Invite[]> => {
|
export const getInvites = async (page = 1, pageSize = 100): Promise<Invite[]> => {
|
||||||
try {
|
const res = await apiClient.get<Invite[]>(`/v1/api/communications/invites/${page}/${pageSize}`);
|
||||||
const res = await apiClient.get<Invite[]>('/v1/api/communications/invite');
|
|
||||||
return res.data || [];
|
return res.data || [];
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getApplicantInvite = async (applicantUserId: string): Promise<Invite> => {
|
||||||
|
const res = await apiClient.get<Invite>(`/v1/api/communications/invites/applicant/${applicantUserId}`);
|
||||||
|
return res.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getApplicantInvites = async (applicantUserId: string): Promise<Invite[]> => {
|
||||||
|
const res = await apiClient.get<Invite[]>(`/v1/api/communications/invites/${applicantUserId}`);
|
||||||
|
return res.data || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Legacy compatibility helpers
|
||||||
export const inviteUser = async (payload: InviteUserRequest): Promise<User> => {
|
export const inviteUser = async (payload: InviteUserRequest): Promise<User> => {
|
||||||
const userSub = getCurrentUserSub();
|
const userSub = getCurrentUserSub();
|
||||||
const res = await apiClient.post('/v1/api/governance/users/invite', {
|
const res = await apiClient.post('/v1/api/governance/users/invite', {
|
||||||
|
|||||||
@@ -1,164 +1,179 @@
|
|||||||
|
import Hashids from 'hashids';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { apiClient } from './httpClient';
|
|
||||||
import { queryClient } from '../lib/queryClient';
|
const env = (typeof import.meta !== 'undefined' && import.meta.env
|
||||||
|
? import.meta.env
|
||||||
|
: {}) as Record<string, string | undefined>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deterministic fallback encoder for offline/preview sessions
|
* Deterministic Hashids configuration matching pfm-api HasherSettings.
|
||||||
* Generates an obfuscated, unique HashID string with salt
|
* Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend.
|
||||||
*/
|
*/
|
||||||
function generateDeterministicHashId(id: number | string, context: string): string {
|
export const HASHER_SETTINGS = {
|
||||||
const str = `${context}:${id}:pfm-salt-2026`;
|
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
|
||||||
let hash = 5381;
|
salt:
|
||||||
for (let i = 0; i < str.length; i++) {
|
(env.VITE_HASHER_SALT as string) ||
|
||||||
hash = ((hash << 5) + hash) + str.charCodeAt(i);
|
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
|
||||||
hash = hash & hash; // Convert to 32bit integer
|
};
|
||||||
|
|
||||||
|
// Singleton Hashids instance initialized with salt & minHashLength
|
||||||
|
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dynamically reconfigures the deterministic Hashids instance from runtime configuration.
|
||||||
|
*/
|
||||||
|
export function updateHasherSettings(salt?: string, minLength?: number | string): void {
|
||||||
|
let updated = false;
|
||||||
|
if (salt && typeof salt === 'string' && salt.trim() !== '') {
|
||||||
|
HASHER_SETTINGS.salt = salt.trim();
|
||||||
|
updated = true;
|
||||||
}
|
}
|
||||||
const positiveHash = Math.abs(hash);
|
if (minLength !== undefined && minLength !== null && minLength !== '') {
|
||||||
const base62Chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
const parsed = Number(minLength);
|
||||||
let encoded = '';
|
if (!isNaN(parsed) && parsed > 0) {
|
||||||
let num = positiveHash;
|
HASHER_SETTINGS.minHashLength = parsed;
|
||||||
while (num > 0) {
|
updated = true;
|
||||||
encoded = base62Chars[num % 62] + encoded;
|
}
|
||||||
num = Math.floor(num / 62);
|
}
|
||||||
|
if (updated) {
|
||||||
|
hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
|
||||||
}
|
}
|
||||||
const prefix = context ? context.slice(0, 3).toLowerCase() : 'id';
|
|
||||||
return `${prefix}_${encoded.padStart(8, 'x')}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invokes the backend tools encoding endpoint to retrieve the designated HashID string
|
* Checks whether an incoming string is already a valid Hashid matching the configured salt and min length.
|
||||||
|
* Prevents double-encoding already-hashed route parameters or IDs.
|
||||||
*/
|
*/
|
||||||
async function fetchHashIdFromEndpoint(id: number | string, context = 'general'): Promise<string> {
|
function isAlreadyHashId(candidate: string): boolean {
|
||||||
|
if (typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const decoded = hasher.decode(candidate);
|
||||||
|
return Array.isArray(decoded) && decoded.length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministically encodes a numeric ID or numeric string into a HashID string.
|
||||||
|
* Synchronous and does not perform HTTP network round trips.
|
||||||
|
*/
|
||||||
|
export function encodeHashIdSync(id: number | string | null | undefined): string {
|
||||||
if (id === undefined || id === null || id === '') {
|
if (id === undefined || id === null || id === '') {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// If already a number
|
||||||
// Attempt standard tools encoding endpoint variations
|
if (typeof id === 'number') {
|
||||||
const response = await apiClient.get<string | { hashId?: string; encoded?: string; result?: string; value?: string }>(
|
if (isNaN(id) || !isFinite(id) || id < 0) return '';
|
||||||
`/v1/api/tools/encode/${id}`,
|
return hasher.encode(id);
|
||||||
{
|
|
||||||
params: { context, type: context },
|
|
||||||
timeout: 4000,
|
|
||||||
}
|
}
|
||||||
);
|
|
||||||
|
|
||||||
if (typeof response.data === 'string' && response.data.trim()) {
|
const str = String(id).trim();
|
||||||
return response.data.trim();
|
if (!str) return '';
|
||||||
|
|
||||||
|
// Check if string is already a valid HashID
|
||||||
|
if (isAlreadyHashId(str)) {
|
||||||
|
return str;
|
||||||
}
|
}
|
||||||
if (response.data && typeof response.data === 'object') {
|
|
||||||
const candidate = response.data.hashId || response.data.encoded || response.data.result || response.data.value;
|
// Check if string is pure digits (e.g. "123")
|
||||||
if (candidate) return candidate;
|
if (/^\d+$/.test(str)) {
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Attempt query param variant
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<string | { hashId?: string; encoded?: string; result?: string; value?: string }>(
|
const num = parseInt(str, 10);
|
||||||
'/v1/api/tools/encode',
|
if (!isNaN(num) && num >= 0) {
|
||||||
{
|
return hasher.encode(num);
|
||||||
params: { id, context, type: context },
|
|
||||||
timeout: 4000,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (typeof response.data === 'string' && response.data.trim()) {
|
|
||||||
return response.data.trim();
|
|
||||||
}
|
|
||||||
if (response.data && typeof response.data === 'object') {
|
|
||||||
const candidate = response.data.hashId || response.data.encoded || response.data.result || response.data.value;
|
|
||||||
if (candidate) return candidate;
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback to deterministic HashID generator
|
// Fall through
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful fallback guaranteeing valid HashID output during preview
|
// Non-numeric identifiers (UUIDs, codes) returned as-is
|
||||||
return generateDeterministicHashId(id, context);
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invokes the backend tools decoding endpoint
|
* Deterministically decodes a HashID string back to original numeric ID or string.
|
||||||
|
* Synchronous and does not perform HTTP network round trips.
|
||||||
*/
|
*/
|
||||||
async function fetchDecodedIdFromEndpoint(hashId: string, context = 'general'): Promise<number | string> {
|
export function decodeHashIdSync(hashId: string | null | undefined): number | string {
|
||||||
if (!hashId) return '';
|
if (!hashId) return '';
|
||||||
|
const str = String(hashId).trim();
|
||||||
|
if (!str) return '';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<{ id?: number | string; decoded?: number | string } | number | string>(
|
const decoded = hasher.decode(str);
|
||||||
`/v1/api/tools/decode/${encodeURIComponent(hashId)}`,
|
if (Array.isArray(decoded) && decoded.length > 0) {
|
||||||
{ params: { context }, timeout: 4000 }
|
return Number(decoded[0]);
|
||||||
);
|
|
||||||
if (typeof response.data === 'number' || typeof response.data === 'string') {
|
|
||||||
return response.data;
|
|
||||||
}
|
|
||||||
if (response.data && typeof response.data === 'object') {
|
|
||||||
if (response.data.id !== undefined) return response.data.id;
|
|
||||||
if (response.data.decoded !== undefined) return response.data.decoded;
|
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Passthrough or fallback
|
// If decoding fails, return raw string
|
||||||
}
|
}
|
||||||
return hashId;
|
|
||||||
|
return str;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const toolsService = {
|
export const toolsService = {
|
||||||
/**
|
/**
|
||||||
* Encodes a raw ID to a HashID, cached client-side in TanStack Query for the active session
|
* Synchronous encoding
|
||||||
*/
|
*/
|
||||||
async encodeId(id: number | string, context = 'general'): Promise<string> {
|
encodeIdSync(id: number | string | null | undefined): string {
|
||||||
if (id === undefined || id === null || id === '') return '';
|
return encodeHashIdSync(id);
|
||||||
|
},
|
||||||
|
|
||||||
const idStr = String(id);
|
/**
|
||||||
return queryClient.fetchQuery({
|
* Synchronous decoding
|
||||||
queryKey: ['hashId', context, idStr],
|
*/
|
||||||
queryFn: () => fetchHashIdFromEndpoint(id, context),
|
decodeIdSync(hashId: string | null | undefined): number | string {
|
||||||
staleTime: Infinity, // Caches client-side during the active session to eliminate redundant round trips
|
return decodeHashIdSync(hashId);
|
||||||
gcTime: 1000 * 60 * 60 * 24, // Keep in memory
|
},
|
||||||
});
|
|
||||||
|
/**
|
||||||
|
* Encodes a raw ID to a HashID (returns Promise for backward compatibility with existing callers)
|
||||||
|
*/
|
||||||
|
async encodeId(id: number | string | null | undefined, _context = 'general'): Promise<string> {
|
||||||
|
return encodeHashIdSync(id);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decodes a HashID string back to original identifier
|
* Decodes a HashID string back to original identifier
|
||||||
*/
|
*/
|
||||||
async decodeId(hashId: string, context = 'general'): Promise<number | string> {
|
async decodeId(hashId: string | null | undefined, _context = 'general'): Promise<number | string> {
|
||||||
if (!hashId) return '';
|
return decodeHashIdSync(hashId);
|
||||||
|
|
||||||
return queryClient.fetchQuery({
|
|
||||||
queryKey: ['decodeHashId', context, hashId],
|
|
||||||
queryFn: () => fetchDecodedIdFromEndpoint(hashId, context),
|
|
||||||
staleTime: Infinity,
|
|
||||||
gcTime: 1000 * 60 * 60 * 24,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Specific convenience resolvers for domain entities
|
* Domain-specific convenience resolvers for entities
|
||||||
*/
|
*/
|
||||||
async resolveProjectId(projectId: number | string): Promise<string> {
|
async resolveProjectId(projectId: number | string): Promise<string> {
|
||||||
return this.encodeId(projectId, 'project');
|
return encodeHashIdSync(projectId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolveOrganisationId(organisationId: number | string): Promise<string> {
|
async resolveOrganisationId(organisationId: number | string): Promise<string> {
|
||||||
return this.encodeId(organisationId, 'organisation');
|
return encodeHashIdSync(organisationId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolveProgrammeId(programmeId: number | string): Promise<string> {
|
async resolveProgrammeId(programmeId: number | string): Promise<string> {
|
||||||
return this.encodeId(programmeId, 'programme');
|
return encodeHashIdSync(programmeId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
|
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
|
||||||
return this.encodeId(portfolioId, 'portfolio');
|
return encodeHashIdSync(portfolioId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolveAwardId(awardId: number | string): Promise<string> {
|
async resolveAwardId(awardId: number | string): Promise<string> {
|
||||||
return this.encodeId(awardId, 'award');
|
return encodeHashIdSync(awardId);
|
||||||
},
|
},
|
||||||
|
|
||||||
async resolveContractId(contractId: number | string): Promise<string> {
|
async resolveContractId(contractId: number | string): Promise<string> {
|
||||||
return this.encodeId(contractId, 'contract');
|
return encodeHashIdSync(contractId);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves query parameters or path variables encoded as HashIDs
|
* Resolves query parameters or path variables encoded as HashIDs before performing API calls
|
||||||
* before performing API calls
|
|
||||||
*/
|
*/
|
||||||
async resolveHashedParams<T extends Record<string, any>>(
|
async resolveHashedParams<T extends Record<string, any>>(
|
||||||
params: T,
|
params: T,
|
||||||
@@ -168,18 +183,17 @@ export const toolsService = {
|
|||||||
for (const key of keysToHash) {
|
for (const key of keysToHash) {
|
||||||
const val = params[key];
|
const val = params[key];
|
||||||
if (val !== undefined && val !== null) {
|
if (val !== undefined && val !== null) {
|
||||||
const contextStr = String(key).replace(/Id$/i, '').toLowerCase();
|
resolved[key] = encodeHashIdSync(val);
|
||||||
resolved[key] = await this.encodeId(val, contextStr);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return resolved;
|
return resolved;
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-fetches and primes cache for a collection of IDs
|
* Compatibility cache primer (now a no-op as Hashids calculation is instantaneous client-side)
|
||||||
*/
|
*/
|
||||||
async primeCache(items: { id: number | string; context: string }[]): Promise<void> {
|
async primeCache(_items: { id: number | string; context: string }[]): Promise<void> {
|
||||||
await Promise.all(items.map((item) => this.encodeId(item.id, item.context)));
|
// No-op - instant deterministic computation
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,7 +203,7 @@ export const toolsService = {
|
|||||||
export function useHashId(id?: number | string | null, context = 'general') {
|
export function useHashId(id?: number | string | 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: () => (id !== undefined && id !== null ? toolsService.encodeId(id, context) : Promise.resolve('')),
|
queryFn: () => Promise.resolve(encodeHashIdSync(id)),
|
||||||
enabled: id !== undefined && id !== null && id !== '',
|
enabled: id !== undefined && id !== null && id !== '',
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -516,6 +516,11 @@ export interface CreateContractRequest {
|
|||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateContractRequest {
|
||||||
|
contractId: number | string;
|
||||||
|
status: ContractStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExecuteContractRequest {
|
export interface ExecuteContractRequest {
|
||||||
contractId: number | string;
|
contractId: number | string;
|
||||||
externalSignatureId: string;
|
externalSignatureId: string;
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import basicSsl from '@vitejs/plugin-basic-ssl';
|
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { defineConfig } from 'vite';
|
import {defineConfig} from 'vite';
|
||||||
|
|
||||||
export default defineConfig(() => {
|
export default defineConfig(() => {
|
||||||
return {
|
return {
|
||||||
plugins: [react(), tailwindcss(), basicSsl()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, '.'),
|
'@': path.resolve(__dirname, '.'),
|
||||||
@@ -14,7 +13,9 @@ export default defineConfig(() => {
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||||
|
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||||
hmr: process.env.DISABLE_HMR !== 'true',
|
hmr: process.env.DISABLE_HMR !== 'true',
|
||||||
|
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
||||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -785,7 +785,7 @@
|
|||||||
"@tailwindcss/oxide-win32-arm64-msvc" "4.3.3"
|
"@tailwindcss/oxide-win32-arm64-msvc" "4.3.3"
|
||||||
"@tailwindcss/oxide-win32-x64-msvc" "4.3.3"
|
"@tailwindcss/oxide-win32-x64-msvc" "4.3.3"
|
||||||
|
|
||||||
"@tailwindcss/vite@^4.3.3":
|
"@tailwindcss/vite@^4.1.14":
|
||||||
version "4.3.3"
|
version "4.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/@tailwindcss/vite/-/vite-4.3.3.tgz#1f4037badcb5e6c6bb574d3d8a0172c83f26b97f"
|
resolved "https://registry.yarnpkg.com/@tailwindcss/vite/-/vite-4.3.3.tgz#1f4037badcb5e6c6bb574d3d8a0172c83f26b97f"
|
||||||
integrity sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==
|
integrity sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==
|
||||||
@@ -942,11 +942,6 @@
|
|||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
"@types/send" "<1"
|
"@types/send" "<1"
|
||||||
|
|
||||||
"@vitejs/plugin-basic-ssl@^2.3.0":
|
|
||||||
version "2.3.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz#f505b1c197d8cb4fd6e213a78847574ecc51e2f9"
|
|
||||||
integrity sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==
|
|
||||||
|
|
||||||
"@vitejs/plugin-react@^5.0.4":
|
"@vitejs/plugin-react@^5.0.4":
|
||||||
version "5.2.0"
|
version "5.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz#108bd0f566f288ce3566982df4eff137ded7b15f"
|
resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz#108bd0f566f288ce3566982df4eff137ded7b15f"
|
||||||
@@ -989,10 +984,10 @@ asynckit@^0.4.0:
|
|||||||
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
|
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
|
||||||
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
|
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
|
||||||
|
|
||||||
autoprefixer@^10.5.6:
|
autoprefixer@^10.4.21:
|
||||||
version "10.5.6"
|
version "10.6.1"
|
||||||
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.5.6.tgz#b613978f41eb58f0b3719ff1e21f993875e86a75"
|
resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.6.1.tgz#437cea1a0b96cedc4065c05327b728b1594faf27"
|
||||||
integrity sha512-HiH4oYNc5+DQEx/b8FPfMj+WHH/WWUBmbN5r1Uf/ixtfyFkk+wGIlfJT/RD5eAyzlwkeYRtZUCI8qrEi26pl2g==
|
integrity sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA==
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist "^4.28.9"
|
browserslist "^4.28.9"
|
||||||
caniuse-lite "^1.0.30001810"
|
caniuse-lite "^1.0.30001810"
|
||||||
@@ -1529,6 +1524,11 @@ has-tostringtag@^1.0.2:
|
|||||||
dependencies:
|
dependencies:
|
||||||
has-symbols "^1.0.3"
|
has-symbols "^1.0.3"
|
||||||
|
|
||||||
|
hashids@^2.3.0:
|
||||||
|
version "2.3.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/hashids/-/hashids-2.3.0.tgz#af92c9cdb093c0ed3542cf0dcbd6c289125df387"
|
||||||
|
integrity sha512-ljM73TE/avEhNnazxaj0Dw3BbEUuLC5yYCQ9RSkSUcT4ZSU6ZebdKCIBJ+xT/DnSYW36E9k82GH1Q6MydSIosQ==
|
||||||
|
|
||||||
hasown@^2.0.2, hasown@^2.0.4:
|
hasown@^2.0.2, hasown@^2.0.4:
|
||||||
version "2.0.4"
|
version "2.0.4"
|
||||||
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003"
|
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003"
|
||||||
@@ -1875,7 +1875,7 @@ postcss-value-parser@^4.2.0:
|
|||||||
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
|
resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514"
|
||||||
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==
|
||||||
|
|
||||||
postcss@^8.5.28, postcss@^8.5.3:
|
postcss@^8.5.3:
|
||||||
version "8.5.28"
|
version "8.5.28"
|
||||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.28.tgz#da4563a99a06e62d6c1cd1acae363224bcaed6e9"
|
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.28.tgz#da4563a99a06e62d6c1cd1acae363224bcaed6e9"
|
||||||
integrity sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==
|
integrity sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==
|
||||||
@@ -2131,7 +2131,7 @@ statuses@~2.0.1, statuses@~2.0.2:
|
|||||||
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382"
|
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382"
|
||||||
integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
|
integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==
|
||||||
|
|
||||||
tailwindcss@4.3.3, tailwindcss@^4.3.3:
|
tailwindcss@4.3.3, tailwindcss@^4.1.14:
|
||||||
version "4.3.3"
|
version "4.3.3"
|
||||||
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.3.3.tgz#c006861611c213c1877893ab5b23daa16be2bb55"
|
resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-4.3.3.tgz#c006861611c213c1877893ab5b23daa16be2bb55"
|
||||||
integrity sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==
|
integrity sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==
|
||||||
|
|||||||
+8
-5
@@ -69,10 +69,13 @@ services:
|
|||||||
- "8076:3000"
|
- "8076:3000"
|
||||||
environment:
|
environment:
|
||||||
# OIDC Identity Provider Configuration
|
# OIDC Identity Provider Configuration
|
||||||
- OIDC_AUTHORITY=${OIDC_AUTHORITY:-https://}
|
- VITE_OIDC_AUTHORITY=${OIDC_AUTHORITY:-https://}
|
||||||
- OIDC_AUDIENCE=${OIDC_AUDIENCE:-aud}
|
- VITE_OIDC_AUDIENCE=${OIDC_AUDIENCE:-aud}
|
||||||
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-pfm-console-dev}
|
- VITE_OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-pfm-console-dev}
|
||||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-yoursecret}
|
- VITE_OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-yoursecret}
|
||||||
|
- VITE_HASHER_MIN_LENGTH=${HASHER_MIN_LENGTH:-11}
|
||||||
|
- VITE_HASHER_SALT=${HASHER_SALT}
|
||||||
|
|
||||||
# Backend API Configuration
|
# Backend API Configuration
|
||||||
- API_BASE_URL=${API_BASE_URL:-https://api.ifri.khongisa.co.za/}
|
- VITE_API_BASE_URL=${API_BASE_URL:-https://api.ifri.khongisa.co.za/}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
Reference in New Issue
Block a user