Refactored UI endpoint usage #84

Merged
khwezi merged 1 commits from garages3 into main 2026-09-20 08:27:14 +02:00
35 changed files with 17482 additions and 648 deletions
@@ -17,6 +17,12 @@ VITE_OIDC_CLIENT_SECRET="secret_71f88570a46eb22eb5270b47f69e3332c84d8db85d008f02
# PFM Backend API Base URL
VITE_API_BASE_URL="https://api.ifri.khongisa.co.za/"
# Protocol / SSL Settings for local development
# HTTPS is enabled by default when running locally via @vitejs/plugin-basic-ssl.
# Set to 'false' if you ever need to run local dev over plain HTTP.
HTTPS="true"
VITE_ENABLE_HTTPS="true"
# HasherSettings for HashIds compatibility with backend pfm-api
VITE_HASHER_MIN_LENGTH="11"
VITE_HASHER_SALT="TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=="
File diff suppressed because it is too large Load Diff
+3
View File
@@ -28,6 +28,7 @@
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tsx": "^4.21.0",
@@ -282,6 +283,8 @@
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
"@vitejs/plugin-basic-ssl": ["@vitejs/plugin-basic-ssl@2.3.0", "", { "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.2.0", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-rc.3", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,434 @@
=== ALL SCHEMAS WITH PROPERTIES ===
Model: Activity
Required: []
id: integer,string (format: int64, nullable: false)
projectId: null,integer,string (format: int64, nullable: false)
milestoneId: null,integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
approvedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
category: null,string (format: -, nullable: false)
status: ApprovalStatus (format: -, nullable: false)
amount: number,string (format: double, nullable: false)
title: null,string (format: -, nullable: false)
description: null,string (format: -, nullable: false)
performedAt: null,string (format: date-time, nullable: false)
Model: Award
Required: []
id: integer,string (format: int64, nullable: false)
organisationId: integer,string (format: int64, nullable: false)
programmeId: integer,string (format: int64, nullable: false)
projectId: null,integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
approvedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
status: ApprovalStatus (format: -, nullable: false)
amount: number,string (format: double, nullable: false)
Model: Budget
Required: []
id: integer,string (format: int64, nullable: false)
projectId: integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
approvedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
name: null,string (format: -, nullable: false)
amount: number,string (format: double, nullable: false)
Model: Contract
Required: []
id: integer,string (format: int64, nullable: false)
awardId: integer,string (format: int64, nullable: false)
externalSignatureId: null,string (format: -, nullable: false)
signedDocumentUrl: null,string (format: -, nullable: false)
effectiveAt: null,string (format: date-time, nullable: false)
expiresAt: null,string (format: date-time, nullable: false)
totalValue: number,string (format: double, nullable: false)
status: ContractStatus (format: -, nullable: false)
createdBy: string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
Model: CreateActivityRequest
Required: [
'projectId',
'milestoneId',
'category',
'amount',
'title',
'description',
'performedAt'
]
projectId*: integer,string (format: int64, nullable: false)
milestoneId*: null,integer,string (format: int64, nullable: false)
category*: string (format: -, nullable: false)
amount*: number,string (format: double, nullable: false)
title*: string (format: -, nullable: false)
description*: string (format: -, nullable: false)
performedAt*: null,string (format: date-time, nullable: false)
Model: CreateAwardRequest
Required: [ 'organisationId', 'programmeId', 'amount' ]
organisationId*: integer,string (format: int64, nullable: false)
programmeId*: integer,string (format: int64, nullable: false)
amount*: number,string (format: double, nullable: false)
Model: CreateBudgetRequest
Required: [ 'projectId', 'name', 'amount' ]
projectId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
amount*: number,string (format: double, nullable: false)
Model: CreateContractRequest
Required: [ 'awardId', 'totalValue', 'effectiveAt', 'expiresAt' ]
awardId*: integer,string (format: int64, nullable: false)
totalValue*: number,string (format: double, nullable: false)
effectiveAt*: string (format: date-time, nullable: false)
expiresAt*: string (format: date-time, nullable: false)
Model: CreateDisbursementRequest
Required: [ 'projectId', 'milestoneId', 'amount' ]
projectId*: integer,string (format: int64, nullable: false)
milestoneId*: null,integer,string (format: int64, nullable: false)
amount*: number,string (format: double, nullable: false)
Model: CreateIndicatorRequest
Required: [
'projectId',
'name',
'unitOfMeasure',
'baselineAmount',
'targetAmount'
]
projectId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
unitOfMeasure*: UnitOfMeasure (format: -, nullable: false)
baselineAmount*: number,string (format: double, nullable: false)
targetAmount*: number,string (format: double, nullable: false)
Model: CreateMilestoneRequest
Required: [ 'projectId', 'dueAt', 'name', 'priority' ]
projectId*: integer,string (format: int64, nullable: false)
dueAt*: string (format: date-time, nullable: false)
name*: string (format: -, nullable: false)
priority*: Priority (format: -, nullable: false)
Model: CreateOrganisationRequest
Required: [ 'name', 'registrationNo', 'email' ]
name*: string (format: -, nullable: false)
registrationNo*: string (format: -, nullable: false)
email*: string (format: -, nullable: false)
type: OrganisationType (format: -, nullable: false)
Model: CreatePortfolioRequest
Required: [ 'name', 'description', 'organisationId', 'ownerId' ]
name*: string (format: -, nullable: false)
description*: string (format: -, nullable: false)
organisationId*: integer,string (format: int64, nullable: false)
ownerId*: string (format: uuid, nullable: false)
Model: CreateProgrammeRequest
Required: [ 'portfolioId', 'name', 'ownerId' ]
portfolioId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
ownerId*: string (format: uuid, nullable: false)
Model: CreateProjectRequest
Required: [ 'programmeId', 'name', 'description', 'sector', 'projectType' ]
programmeId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
description*: string (format: -, nullable: false)
sector*: IndustrySector (format: -, nullable: false)
projectType*: ProjectType (format: -, nullable: false)
Model: CreateRiskRequest
Required: [ 'projectId', 'likelihood', 'impact', 'name', 'description' ]
projectId*: integer,string (format: int64, nullable: false)
likelihood*: RiskLikelihood (format: -, nullable: false)
impact*: RiskImpact (format: -, nullable: false)
name*: string (format: -, nullable: false)
description*: string (format: -, nullable: false)
Model: Dashboard
Required: [
'portfolioValue',
'activeAwards',
'disbursed',
'onTrackPercentage',
'atRiskCount',
'beneficiariesReached'
]
portfolioValue*: number,string (format: double, nullable: false)
activeAwards*: integer,string (format: int32, nullable: false)
disbursed*: number,string (format: double, nullable: false)
onTrackPercentage*: number,string (format: double, nullable: false)
atRiskCount*: integer,string (format: int32, nullable: false)
beneficiariesReached*: integer,string (format: int32, nullable: false)
Model: Disbursement
Required: []
id: integer,string (format: int64, nullable: false)
projectId: integer,string (format: int64, nullable: false)
milestoneId: null,integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
amount: number,string (format: double, nullable: false)
status: ApprovalStatus (format: -, nullable: false)
Model: Evidence
Required: []
id: integer,string (format: int64, nullable: false)
projectId: integer,string (format: int64, nullable: false)
milestoneId: integer,string (format: int64, nullable: false)
indicatorId: integer,string (format: int64, nullable: false)
activityId: integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
version: integer,string (format: int32, nullable: false)
type: EvidenceType (format: -, nullable: false)
documentUrl: null,string (format: -, nullable: false)
status: ApprovalStatus (format: -, nullable: false)
Model: ExecuteContractRequest
Required: [ 'contractId', 'externalSignatureId', 'signedDocumentUrl' ]
contractId*: integer,string (format: int64, nullable: false)
externalSignatureId*: string (format: -, nullable: false)
signedDocumentUrl*: string (format: -, nullable: false)
Model: Indicator
Required: []
id: integer,string (format: int64, nullable: false)
projectId: integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
name: null,string (format: -, nullable: false)
unitOfMeasure: any (format: -, nullable: false)
baselineAmount: number,string (format: double, nullable: false)
targetAmount: number,string (format: double, nullable: false)
actualAmount: number,string (format: double, nullable: false)
Model: Instrument
Required: []
id: integer,string (format: int64, nullable: false)
programmeId: integer,string (format: int64, nullable: false)
code: null,string (format: -, nullable: false)
name: null,string (format: -, nullable: false)
description: null,string (format: -, nullable: false)
status: ActivityStatus (format: -, nullable: false)
createdBy: string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
Model: Invite
Required: []
id: integer,string (format: int64, nullable: false)
organisationId: integer,string (format: int64, nullable: false)
invitedOrganisationId: integer,string (format: int64, nullable: false)
awardId: integer,string (format: int64, nullable: false)
contractId: integer,string (format: int64, nullable: false)
recipient: string (format: uuid, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
Model: InviteRequest
Required: [
'organisationId',
'invitedOrganisationId',
'awardId',
'contractId',
'recipient',
'message',
'platform'
]
organisationId*: integer,string (format: int64, nullable: false)
invitedOrganisationId*: integer,string (format: int64, nullable: false)
awardId*: integer,string (format: int64, nullable: false)
contractId*: integer,string (format: int64, nullable: false)
recipient*: string (format: uuid, nullable: false)
message*: string (format: -, nullable: false)
platform*: NotificationPlatform (format: -, nullable: false)
Model: Milestone
Required: []
id: integer,string (format: int64, nullable: false)
projectId: integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
dueAt: string (format: date-time, nullable: false)
name: null,string (format: -, nullable: false)
status: ApprovalStatus (format: -, nullable: false)
priority: Priority (format: -, nullable: false)
Model: Organisation
Required: []
id: integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
registrationNo: null,string (format: -, nullable: false)
name: null,string (format: -, nullable: false)
email: null,string (format: -, nullable: false)
type: OrganisationType (format: -, nullable: false)
status: ActivityStatus (format: -, nullable: false)
Model: Portfolio
Required: []
id: integer,string (format: int64, nullable: false)
organisationId: integer,string (format: int64, nullable: false)
ownedBy: string (format: uuid, nullable: false)
createdBy: string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
name: null,string (format: -, nullable: false)
description: null,string (format: -, nullable: false)
Model: ProblemDetails
Required: []
type: null,string (format: -, nullable: false)
title: null,string (format: -, nullable: false)
status: null,integer,string (format: int32, nullable: false)
detail: null,string (format: -, nullable: false)
instance: null,string (format: -, nullable: false)
Model: Programme
Required: []
id: integer,string (format: int64, nullable: false)
portfolioId: integer,string (format: int64, nullable: false)
ownedBy: string (format: uuid, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
name: null,string (format: -, nullable: false)
status: ApprovalStatus (format: -, nullable: false)
Model: Project
Required: []
id: integer,string (format: int64, nullable: false)
programmeId: integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
updatedBy: null,string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
updatedAt: null,string (format: date-time, nullable: false)
startedAt: null,string (format: date-time, nullable: false)
endedAt: null,string (format: date-time, nullable: false)
sector: IndustrySector (format: -, nullable: false)
projectType: ProjectType (format: -, nullable: false)
name: null,string (format: -, nullable: false)
description: null,string (format: -, nullable: false)
status: ApprovalStatus (format: -, nullable: false)
Model: Risk
Required: []
id: integer,string (format: int64, nullable: false)
projectId: integer,string (format: int64, nullable: false)
createdBy: string (format: uuid, nullable: false)
createdAt: string (format: date-time, nullable: false)
likelihood: RiskLikelihood (format: -, nullable: false)
impact: RiskImpact (format: -, nullable: false)
name: null,string (format: -, nullable: false)
description: null,string (format: -, nullable: false)
Model: UpdateContractRequest
Required: [ 'contractId', 'status' ]
contractId*: integer,string (format: int64, nullable: false)
status*: ContractStatus (format: -, nullable: false)
Model: UpdateDisbursementRequest
Required: [ 'disbursementId', 'milestoneId', 'amount' ]
disbursementId*: integer,string (format: int64, nullable: false)
milestoneId*: null,integer,string (format: int64, nullable: false)
amount*: number,string (format: double, nullable: false)
Model: UpdateEvidenceRequest
Required: [ 'evidenceId', 'version', 'evidenceType', 'status' ]
evidenceId*: integer,string (format: int64, nullable: false)
version*: integer,string (format: int32, nullable: false)
evidenceType*: EvidenceType (format: -, nullable: false)
status*: ApprovalStatus (format: -, nullable: false)
Model: UpdateMilestoneRequest
Required: [ 'milestoneId', 'dueAt', 'name', 'priority', 'status' ]
milestoneId*: integer,string (format: int64, nullable: false)
dueAt*: string (format: date-time, nullable: false)
name*: string (format: -, nullable: false)
priority*: Priority (format: -, nullable: false)
status*: ApprovalStatus (format: -, nullable: false)
Model: UpdateOrganisationRequest
Required: [ 'organisationId', 'name', 'registrationNo', 'email', 'type' ]
organisationId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
registrationNo*: string (format: -, nullable: false)
email*: string (format: -, nullable: false)
type*: OrganisationType (format: -, nullable: false)
Model: UpdatePortfolioRequest
Required: [ 'portfolioId', 'name', 'description', 'ownerId' ]
portfolioId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
description*: string (format: -, nullable: false)
ownerId*: string (format: uuid, nullable: false)
Model: UpdateProgrammeRequest
Required: [ 'programmeId', 'name', 'ownerId', 'status' ]
programmeId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
ownerId*: string (format: uuid, nullable: false)
status*: ApprovalStatus (format: -, nullable: false)
Model: UpdateProjectRequest
Required: [
'projectId',
'name',
'description',
'startedAt',
'endedAt',
'sector',
'projectType'
]
projectId*: integer,string (format: int64, nullable: false)
name*: string (format: -, nullable: false)
description*: string (format: -, nullable: false)
startedAt*: null,string (format: date-time, nullable: false)
endedAt*: null,string (format: date-time, nullable: false)
sector*: IndustrySector (format: -, nullable: false)
projectType*: ProjectType (format: -, nullable: false)
Model: UpdateProjectStatusRequest
Required: [ 'projectId', 'status' ]
projectId*: integer,string (format: int64, nullable: false)
status*: ApprovalStatus (format: -, nullable: false)
Model: UpdateRiskRequest
Required: [ 'riskId', 'likelihood', 'impact', 'name', 'description' ]
riskId*: integer,string (format: int64, nullable: false)
likelihood*: RiskLikelihood (format: -, nullable: false)
impact*: RiskImpact (format: -, nullable: false)
name*: string (format: -, nullable: false)
description*: string (format: -, nullable: false)
Model: User
Required: []
id: string (format: uuid, nullable: false)
lastLoginAt: null,string (format: date-time, nullable: false)
email: null,string (format: -, nullable: false)
status: ActivityStatus (format: -, nullable: false)
Model: UserFilterRequest
Required: []
status: any (format: -, nullable: false)
email: null,string (format: -, nullable: false)
sortBy: null,string (format: -, nullable: false)
sortDescending: boolean (format: -, nullable: false)
@@ -0,0 +1,919 @@
=== COMPONENT SCHEMAS ===
Activity, ActivityStatus, ApprovalStatus, Award, Budget, Contract, ContractStatus, CreateActivityRequest, CreateAwardRequest, CreateBudgetRequest, CreateContractRequest, CreateDisbursementRequest, CreateIndicatorRequest, CreateMilestoneRequest, CreateOrganisationRequest, CreatePortfolioRequest, CreateProgrammeRequest, CreateProjectRequest, CreateRiskRequest, Dashboard, Disbursement, Evidence, EvidenceType, ExecuteContractRequest, Indicator, IndustrySector, Instrument, Invite, InviteRequest, Milestone, NotificationPlatform, Organisation, OrganisationType, Portfolio, Priority, ProblemDetails, Programme, Project, ProjectType, Risk, RiskImpact, RiskLikelihood, UnitOfMeasure, UpdateContractRequest, UpdateDisbursementRequest, UpdateEvidenceRequest, UpdateMilestoneRequest, UpdateOrganisationRequest, UpdatePortfolioRequest, UpdateProgrammeRequest, UpdateProjectRequest, UpdateProjectStatusRequest, UpdateRiskRequest, User, UserFilterRequest
=== DETAILED ENDPOINTS (EXCLUDING TOOLS) ===
------------------------------------------------------------
GET /v1/api/users/{userId} [Tag: Users]
OperationId: getuser
Description: Gets the metadata of a single user.
Parameters:
- userId (in: path, required: true) schema: {"type":"string","format":"uuid"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/User"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/users/{page}/{pageSize} [Tag: Users]
OperationId: getusers
Description: Paginated, searchable directory of system users. Supports filtering by ActivityStatus, searching by Email, and sorting
Parameters:
- page (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":1}
- pageSize (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":100}
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UserFilterRequest"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/User"}}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/integration/sync [Tag: Users]
OperationId: syncuser
Description: Trigger integration and data synchronization in the background
Responses:
[200] (No content)
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/users/{userId}/status/{status} [Tag: Users]
OperationId: updateuserstatus
Description: Changes the Activity status of the user in the system
Parameters:
- userId (in: path, required: true) schema: {"type":"string","format":"uuid"}
- status (in: path, required: true) schema: {"$ref":"#/components/schemas/ActivityStatus"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/projects/activities [Tag: Projects]
OperationId: createactivity
Description: Add a new activity under project (M04)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateActivityRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/projects/programme [Tag: Projects]
OperationId: createprogramme
Description: Create programme
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateProgrammeRequest"}
Responses:
[200] (No content)
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/projects/programme [Tag: Projects]
OperationId: updateprogramme
Description: Update existing programme
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateProgrammeRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/projects [Tag: Projects]
OperationId: createproject
Description: Create a new project (M04)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateProjectRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects [Tag: Projects]
OperationId: getprojects
Description: Get a list of projects
Parameters:
- page (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":1}
- pageSize (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Project"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/projects [Tag: Projects]
OperationId: updateproject
Description: Update project details
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateProjectRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/{projectId}/activities [Tag: Projects]
OperationId: getactivities
Description: Get a list of activities for a project
Parameters:
- projectId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Activity"}}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/activities/{activityId} [Tag: Projects]
OperationId: getactivity
Description: Gets a specific activity by it's id
Parameters:
- activityId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Activity"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/milestones/{milestoneId} [Tag: Projects]
OperationId: getmilestone
Description: Gets a specific milestone
Parameters:
- milestoneId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Milestone"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/milestones/{page}/{pageSize} [Tag: Projects]
OperationId: getmilestones
Description: Get a list of all milestones
Parameters:
- page (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":1}
- pageSize (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Milestone"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/programme/{programmeId} [Tag: Projects]
OperationId: getprogramme
Description: Get specific programme
Parameters:
- programmeId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Programme"}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/{programmeId}/programme [Tag: Projects]
OperationId: getprogrammeprojects
Description: Get a list of all projects under a programme
Parameters:
- programmeId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Project"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/programme/{page}/{pageSize} [Tag: Projects]
OperationId: getprogrammesprojects
Description: Get a list of all programmes
Parameters:
- page (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":1}
- pageSize (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Programme"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/{projectId} [Tag: Projects]
OperationId: getproject
Description: Get project by ID
Parameters:
- projectId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Project"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/indicators/project/{projectId} [Tag: Projects]
OperationId: getprojectindicators
Description: Get get a detailed list of project indicators
Parameters:
- projectId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Indicator"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/{projectId}/milestones [Tag: Projects]
OperationId: getprojectmilestones
Description: Get a list of milestones for a project
Parameters:
- projectId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Milestone"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/projects/{projectId}/risks [Tag: Projects]
OperationId: getprojectrisks
Description: Get a list of risks for a project
Parameters:
- projectId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Risk"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/projects/milestone [Tag: Projects]
OperationId: updatemilestone
Description: Update project milestone details
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateMilestoneRequest"}
Responses:
[200] (No content)
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/projects/status [Tag: Projects]
OperationId: updateprojectstatus
Description: Update project status (M04)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateProjectStatusRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/grants/awards [Tag: Grants]
OperationId: createaward
Description: Register an approved funding award (M01)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateAwardRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/grants/awards [Tag: Grants]
OperationId: getawards
Description: Get a list of awards
Parameters:
- page (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":1}
- pageSize (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":10}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Award"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/grants/budgets [Tag: Grants]
OperationId: createbudget
Description: Create a new budget line (M05)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateBudgetRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/grants/budgets [Tag: Grants]
OperationId: getbudgets
Description: Get all budgets
Parameters:
- page (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":1}
- pageSize (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Budget"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/grants/contracts [Tag: Grants]
OperationId: createcontract
Description: Create a contract record from an approved award (M02)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateContractRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/grants/contracts [Tag: Grants]
OperationId: getcontracts
Description: Get all contracts
Parameters:
- page (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":1}
- pageSize (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Contract"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/grants/contracts [Tag: Grants]
OperationId: updatecontract
Description: Updates a specific contract
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateContractRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/grants/contracts/execute [Tag: Grants]
OperationId: executecontract
Description: Execute the contract and lock the contractual version
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/ExecuteContractRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/grants/awards/{awardId} [Tag: Grants]
OperationId: getaward
Description: Get award by ID
Parameters:
- awardId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Award"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/grants/budgets/{budgetId} [Tag: Grants]
OperationId: getbudget
Description: Get budget by ID
Parameters:
- budgetId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Budget"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/grants/contracts/{contractId} [Tag: Grants]
OperationId: getcontract
Description: Get contract by ID
Parameters:
- contractId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Contract"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/grants/contracts/{contractId} [Tag: Grants]
OperationId: uploadcontract
Description: Uploads a contract file securely
Parameters:
- contractId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] (No content)
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/grants/contracts/{contractId}/securelink [Tag: Grants]
OperationId: getcontracturl
Description: Gets a secure download URL, has expiry period (default 15min)
Parameters:
- contractId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"string"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/governance/organisations [Tag: Governance]
OperationId: createorganisation
Description: Create a new organization profile
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateOrganisationRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/organisations [Tag: Governance]
OperationId: getorganisations
Description: Get a paginated directory of organizations
Parameters:
- page (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":1}
- pageSize (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Organisation"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/governance/organisations [Tag: Governance]
OperationId: updateorganisation
Description: Update an existing organization profile
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateOrganisationRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/governance/organisations/portfolio [Tag: Governance]
OperationId: createportfolio
Description: Create a new organization portfolio
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreatePortfolioRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[403] (No content)
[404] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/governance/organisations/portfolio [Tag: Governance]
OperationId: updateportfolio
Description: Update an existing organization portfolio
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdatePortfolioRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/dashboard [Tag: Governance]
OperationId: dashboard
Description: Get executive portfolio dashboard metrics and KPIs
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Dashboard"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/instruments/{programmeId} [Tag: Governance]
OperationId: getinstruments
Description: Get all funding instruments
Parameters:
- programmeId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Instrument"}}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/organisations/{organisationId} [Tag: Governance]
OperationId: getorganisation
Description: Get organization profile by ID
Parameters:
- organisationId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Organisation"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/portfolios/{portfolioId} [Tag: Governance]
OperationId: getportfolio
Description: Get a specific portfolios
Parameters:
- portfolioId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Portfolio"}}
[401] (No content)
[403] (No content)
[404] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Portfolio"}}
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/portfolios/{portfolioId}/programmes [Tag: Governance]
OperationId: getportfolioprogrammes
Description: Get a programme's portfolios
Parameters:
- portfolioId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Programme"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/organisations/{organisationId}/portfolio [Tag: Governance]
OperationId: getportfolios
Description: Get all portfolios
Parameters:
- organisationId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Portfolio"}}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/governance/programmes/{portfolioId} [Tag: Governance]
OperationId: getprogrammes
Description: Get all programmes
Parameters:
- portfolioId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Programme"}}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/execution/disbursements/{disbursementId}/approve [Tag: Execution]
OperationId: approvedisbursement
Description: Approve disbursement and release payment (M06)
Parameters:
- disbursementId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/execution/disbursements [Tag: Execution]
OperationId: createdisbursement
Description: Create a new disbursement schedule (M06)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateDisbursementRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/execution/disbursements [Tag: Execution]
OperationId: getdisbursements
Description: Get a list of disbursements
Parameters:
- page (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":1}
- pageSize (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":10}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Disbursement"}}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/execution/disbursements [Tag: Execution]
OperationId: updatedisbursement
Description: Update the details of a specific disbursement
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateDisbursementRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/execution/risks [Tag: Execution]
OperationId: createrisk
Description: Add risk to risk register (M09)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateRiskRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/execution/risks [Tag: Execution]
OperationId: getrisks
Description: Get a list of all risks
Parameters:
- page (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":1}
- pageSize (in: query, required: false) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":["integer","string"],"format":"int32","default":100}
Responses:
[200] (No content)
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/execution/risks [Tag: Execution]
OperationId: updaterisk
Description: Update the details of a specific risk
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateRiskRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/execution/disbursements/{disbursementId} [Tag: Execution]
OperationId: getdisbursement
Description: Get a specific disbursement
Parameters:
- disbursementId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Disbursement"}
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/execution/risks/{riskId} [Tag: Execution]
OperationId: getrisk
Description: Get a specific risk
Parameters:
- riskId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Risk"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/evidence/file [Tag: Evidence & M&E]
OperationId: createevidence
Description: Upload supporting evidence file to S3 and register metadata (M14)
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/evidence/indicators [Tag: Evidence & M&E]
OperationId: createindicator
Description: Define indicators and baselines/targets (M08)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateIndicatorRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/evidence/milestones [Tag: Evidence & M&E]
OperationId: createmilestonerequest
Description: Define contractual milestones (M07)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/CreateMilestoneRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/evidence/{evidenceId} [Tag: Evidence & M&E]
OperationId: getevidence
Description: Get a specific evidence record and assiciated file URLs
Parameters:
- evidenceId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Evidence"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/evidence/files/{page}/{pageSize} [Tag: Evidence & M&E]
OperationId: getevidencefiles
Description: Get a list of all uploaded evidence files metadata
Parameters:
- page (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":1}
- pageSize (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Evidence"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/evidence/{evidenceId}/securelink [Tag: Grants]
OperationId: getevidenceurl
Description: Gets a secure download URL of the evidence file, URL expires (default: 15min)
Parameters:
- evidenceId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"string"}
[401] (No content)
[403] (No content)
[404] application/json; version=1 -> {"type":"string"}
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/evidence/indicators/{indicatorId} [Tag: Evidence & M&E]
OperationId: getindicator
Description: Get a specific indicator.
Parameters:
- indicatorId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Indicator"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/evidence/indicators/{page}/{pageSize} [Tag: Evidence & M&E]
OperationId: getindicators
Description: Get a list of indicators
Parameters:
- page (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":1}
- pageSize (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Indicator"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/evidence/milestone/{milestoneId} [Tag: Evidence & M&E]
OperationId: getmilestoneevidence
Description: Get a list of all uploaded evidence metadata for a specific mliestone
Parameters:
- milestoneId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Evidence"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/evidence/{projectId} [Tag: Evidence & M&E]
OperationId: getprojectevidence
Description: Get a list of all uploaded evidence metadata for a specific project
Parameters:
- projectId (in: path, required: true) schema: {"type":"string"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Evidence"}}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/evidence/indicators/{indicatorId}/actuals/{actualAmount} [Tag: Evidence & M&E]
OperationId: recordindicatoramount
Description: Record actual values for indicators (M08)
Parameters:
- indicatorId (in: path, required: true) schema: {"type":"string"}
- actualAmount (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$","type":"number","format":"double"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[403] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
PUT /v1/api/evidence [Tag: Evidence & M&E]
OperationId: updateevidence
Description: Updates a specific evidence record metadata
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/UpdateEvidenceRequest"}
Responses:
[200] (No content)
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[403] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/communications/invites/applicant/{applicantUserId} [Tag: Communications]
OperationId: getapplicantinvite
Description: Gets an applicant's invite
Parameters:
- applicantUserId (in: path, required: true) schema: {"type":"string","format":"uuid"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Invite"}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/communications/invites/{applicantUserId} [Tag: Communications]
OperationId: getapplicantinvites
Description: Gets an applicant's invite list
Parameters:
- applicantUserId (in: path, required: true) schema: {"type":"string","format":"uuid"}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Invite"}}
[401] (No content)
[404] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
GET /v1/api/communications/invites/{page}/{pageSize} [Tag: Communications]
OperationId: getinvites
Description: Get a list of sent invitations
Parameters:
- page (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":1}
- pageSize (in: path, required: true) schema: {"pattern":"^-?(?:0|[1-9]\\d*)$","type":"integer","format":"int32","default":100}
Responses:
[200] application/json; version=1 -> {"type":"array","items":{"$ref":"#/components/schemas/Invite"}}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
------------------------------------------------------------
POST /v1/api/communications/invite [Tag: Communications]
OperationId: sendinvite
Description: Trigger an invite to an organization or candidate (specifying NotificationPlatform)
Request Body: required=true
Content-Type: application/json; version=1 -> Schema: {"$ref":"#/components/schemas/InviteRequest"}
Responses:
[200] application/json; version=1 -> {"$ref":"#/components/schemas/Invite"}
[400] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
[401] (No content)
[500] application/json; version=1 -> {"$ref":"#/components/schemas/ProblemDetails"}
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -5,6 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"dev:https": "HTTPS=true vite --port=3000 --host=0.0.0.0",
"dev:http": "HTTPS=false vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist server.js",
@@ -15,6 +17,7 @@
"@hookform/resolvers": "^5.9.1",
"@tailwindcss/vite": "^4.1.14",
"@tanstack/react-query": "^5.102.8",
"@vitejs/plugin-basic-ssl": "^2.3.0",
"@vitejs/plugin-react": "^5.0.4",
"axios": "^1.20.0",
"dotenv": "^17.2.3",
@@ -29,14 +32,14 @@
"react-router-dom": "^7.18.3",
"tailwindcss": "^4.1.14",
"vite": "^6.2.3",
"zod": "^4.5.4"
"zod": "^3.25.76"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"@types/express": "^4.17.21"
"typescript": "~5.8.2"
}
}
@@ -0,0 +1,469 @@
=== SCHEMA: Activity ===
Required: []
- id: integer,string (format: int64)
- projectId: null,integer,string (format: int64)
- milestoneId: null,integer,string (format: int64)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- approvedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- category: null,string (format: none)
- status: #/components/schemas/ApprovalStatus (format: none)
- amount: number,string (format: double)
- title: null,string (format: none)
- description: null,string (format: none)
- performedAt: null,string (format: date-time)
=== SCHEMA: ActivityStatus ===
Other: { type: 'integer' }
=== SCHEMA: ApprovalStatus ===
Other: { type: 'integer' }
=== SCHEMA: Award ===
Required: []
- id: integer,string (format: int64)
- organisationId: integer,string (format: int64)
- programmeId: integer,string (format: int64)
- projectId: null,integer,string (format: int64)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- approvedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- status: #/components/schemas/ApprovalStatus (format: none)
- amount: number,string (format: double)
=== SCHEMA: Budget ===
Required: []
- id: integer,string (format: int64)
- projectId: integer,string (format: int64)
- createdBy: string (format: uuid)
- approvedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- name: null,string (format: none)
- amount: number,string (format: double)
=== SCHEMA: Contract ===
Required: []
- id: integer,string (format: int64)
- awardId: integer,string (format: int64)
- externalSignatureId: null,string (format: none)
- signedDocumentUrl: null,string (format: none)
- effectiveAt: null,string (format: date-time)
- expiresAt: null,string (format: date-time)
- totalValue: number,string (format: double)
- status: #/components/schemas/ContractStatus (format: none)
- createdBy: string (format: uuid)
- createdAt: string (format: date-time)
=== SCHEMA: ContractStatus ===
Other: { type: 'integer' }
=== SCHEMA: CreateActivityRequest ===
Required: [
'projectId',
'milestoneId',
'category',
'amount',
'title',
'description',
'performedAt'
]
- projectId: integer,string (format: int64)
- milestoneId: null,integer,string (format: int64)
- category: string (format: none)
- amount: number,string (format: double)
- title: string (format: none)
- description: string (format: none)
- performedAt: null,string (format: date-time)
=== SCHEMA: CreateAwardRequest ===
Required: [ 'organisationId', 'programmeId', 'amount' ]
- organisationId: integer,string (format: int64)
- programmeId: integer,string (format: int64)
- amount: number,string (format: double)
=== SCHEMA: CreateBudgetRequest ===
Required: [ 'projectId', 'name', 'amount' ]
- projectId: integer,string (format: int64)
- name: string (format: none)
- amount: number,string (format: double)
=== SCHEMA: CreateContractRequest ===
Required: [ 'awardId', 'totalValue', 'effectiveAt', 'expiresAt' ]
- awardId: integer,string (format: int64)
- totalValue: number,string (format: double)
- effectiveAt: string (format: date-time)
- expiresAt: string (format: date-time)
=== SCHEMA: CreateDisbursementRequest ===
Required: [ 'projectId', 'milestoneId', 'amount' ]
- projectId: integer,string (format: int64)
- milestoneId: null,integer,string (format: int64)
- amount: number,string (format: double)
=== SCHEMA: CreateIndicatorRequest ===
Required: [
'projectId',
'name',
'unitOfMeasure',
'baselineAmount',
'targetAmount'
]
- projectId: integer,string (format: int64)
- name: string (format: none)
- unitOfMeasure: #/components/schemas/UnitOfMeasure (format: none)
- baselineAmount: number,string (format: double)
- targetAmount: number,string (format: double)
=== SCHEMA: CreateMilestoneRequest ===
Required: [ 'projectId', 'dueAt', 'name', 'priority' ]
- projectId: integer,string (format: int64)
- dueAt: string (format: date-time)
- name: string (format: none)
- priority: #/components/schemas/Priority (format: none)
=== SCHEMA: CreateOrganisationRequest ===
Required: [ 'name', 'registrationNo', 'email' ]
- name: string (format: none)
- registrationNo: string (format: none)
- email: string (format: none)
- type: #/components/schemas/OrganisationType (format: none)
=== SCHEMA: CreatePortfolioRequest ===
Required: [ 'name', 'description', 'organisationId', 'ownerId' ]
- name: string (format: none)
- description: string (format: none)
- organisationId: integer,string (format: int64)
- ownerId: string (format: uuid)
=== SCHEMA: CreateProgrammeRequest ===
Required: [ 'portfolioId', 'name', 'ownerId' ]
- portfolioId: integer,string (format: int64)
- name: string (format: none)
- ownerId: string (format: uuid)
=== SCHEMA: CreateProjectRequest ===
Required: [ 'programmeId', 'name', 'description', 'sector', 'projectType' ]
- programmeId: integer,string (format: int64)
- name: string (format: none)
- description: string (format: none)
- sector: #/components/schemas/IndustrySector (format: none)
- projectType: #/components/schemas/ProjectType (format: none)
=== SCHEMA: CreateRiskRequest ===
Required: [ 'projectId', 'likelihood', 'impact', 'name', 'description' ]
- projectId: integer,string (format: int64)
- likelihood: #/components/schemas/RiskLikelihood (format: none)
- impact: #/components/schemas/RiskImpact (format: none)
- name: string (format: none)
- description: string (format: none)
=== SCHEMA: Dashboard ===
Required: [
'portfolioValue',
'activeAwards',
'disbursed',
'onTrackPercentage',
'atRiskCount',
'beneficiariesReached'
]
- portfolioValue: number,string (format: double)
- activeAwards: integer,string (format: int32)
- disbursed: number,string (format: double)
- onTrackPercentage: number,string (format: double)
- atRiskCount: integer,string (format: int32)
- beneficiariesReached: integer,string (format: int32)
=== SCHEMA: Disbursement ===
Required: []
- id: integer,string (format: int64)
- projectId: integer,string (format: int64)
- milestoneId: null,integer,string (format: int64)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- amount: number,string (format: double)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: Evidence ===
Required: []
- id: integer,string (format: int64)
- projectId: integer,string (format: int64)
- milestoneId: integer,string (format: int64)
- indicatorId: integer,string (format: int64)
- activityId: integer,string (format: int64)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- version: integer,string (format: int32)
- type: #/components/schemas/EvidenceType (format: none)
- documentUrl: null,string (format: none)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: EvidenceType ===
Other: { type: 'integer' }
=== SCHEMA: ExecuteContractRequest ===
Required: [ 'contractId', 'externalSignatureId', 'signedDocumentUrl' ]
- contractId: integer,string (format: int64)
- externalSignatureId: string (format: none)
- signedDocumentUrl: string (format: none)
=== SCHEMA: Indicator ===
Required: []
- id: integer,string (format: int64)
- projectId: integer,string (format: int64)
- createdBy: string (format: uuid)
- createdAt: string (format: date-time)
- name: null,string (format: none)
- unitOfMeasure: any (format: none)
- baselineAmount: number,string (format: double)
- targetAmount: number,string (format: double)
- actualAmount: number,string (format: double)
=== SCHEMA: IndustrySector ===
Other: { type: 'integer' }
=== SCHEMA: Instrument ===
Required: []
- id: integer,string (format: int64)
- programmeId: integer,string (format: int64)
- code: null,string (format: none)
- name: null,string (format: none)
- description: null,string (format: none)
- status: #/components/schemas/ActivityStatus (format: none)
- createdBy: string (format: uuid)
- createdAt: string (format: date-time)
=== SCHEMA: Invite ===
Required: []
- id: integer,string (format: int64)
- organisationId: integer,string (format: int64)
- invitedOrganisationId: integer,string (format: int64)
- awardId: integer,string (format: int64)
- contractId: integer,string (format: int64)
- recipient: string (format: uuid)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
=== SCHEMA: InviteRequest ===
Required: [
'organisationId',
'invitedOrganisationId',
'awardId',
'contractId',
'recipient',
'message',
'platform'
]
- organisationId: integer,string (format: int64)
- invitedOrganisationId: integer,string (format: int64)
- awardId: integer,string (format: int64)
- contractId: integer,string (format: int64)
- recipient: string (format: uuid)
- message: string (format: none)
- platform: #/components/schemas/NotificationPlatform (format: none)
=== SCHEMA: Milestone ===
Required: []
- id: integer,string (format: int64)
- projectId: integer,string (format: int64)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- dueAt: string (format: date-time)
- name: null,string (format: none)
- status: #/components/schemas/ApprovalStatus (format: none)
- priority: #/components/schemas/Priority (format: none)
=== SCHEMA: NotificationPlatform ===
Other: { type: 'integer' }
=== SCHEMA: Organisation ===
Required: []
- id: integer,string (format: int64)
- createdBy: string (format: uuid)
- createdAt: string (format: date-time)
- registrationNo: null,string (format: none)
- name: null,string (format: none)
- email: null,string (format: none)
- type: #/components/schemas/OrganisationType (format: none)
- status: #/components/schemas/ActivityStatus (format: none)
=== SCHEMA: OrganisationType ===
Other: { type: 'integer', default: 5 }
=== SCHEMA: Portfolio ===
Required: []
- id: integer,string (format: int64)
- organisationId: integer,string (format: int64)
- ownedBy: string (format: uuid)
- createdBy: string (format: uuid)
- createdAt: string (format: date-time)
- name: null,string (format: none)
- description: null,string (format: none)
=== SCHEMA: Priority ===
Other: { type: 'integer' }
=== SCHEMA: ProblemDetails ===
Required: []
- type: null,string (format: none)
- title: null,string (format: none)
- status: null,integer,string (format: int32)
- detail: null,string (format: none)
- instance: null,string (format: none)
=== SCHEMA: Programme ===
Required: []
- id: integer,string (format: int64)
- portfolioId: integer,string (format: int64)
- ownedBy: string (format: uuid)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- name: null,string (format: none)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: Project ===
Required: []
- id: integer,string (format: int64)
- programmeId: integer,string (format: int64)
- createdBy: string (format: uuid)
- updatedBy: null,string (format: uuid)
- createdAt: string (format: date-time)
- updatedAt: null,string (format: date-time)
- startedAt: null,string (format: date-time)
- endedAt: null,string (format: date-time)
- sector: #/components/schemas/IndustrySector (format: none)
- projectType: #/components/schemas/ProjectType (format: none)
- name: null,string (format: none)
- description: null,string (format: none)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: ProjectType ===
Other: { type: 'integer' }
=== SCHEMA: Risk ===
Required: []
- id: integer,string (format: int64)
- projectId: integer,string (format: int64)
- createdBy: string (format: uuid)
- createdAt: string (format: date-time)
- likelihood: #/components/schemas/RiskLikelihood (format: none)
- impact: #/components/schemas/RiskImpact (format: none)
- name: null,string (format: none)
- description: null,string (format: none)
=== SCHEMA: RiskImpact ===
Other: { type: 'integer' }
=== SCHEMA: RiskLikelihood ===
Other: { type: 'integer' }
=== SCHEMA: UnitOfMeasure ===
Other: { type: 'integer' }
=== SCHEMA: UpdateContractRequest ===
Required: [ 'contractId', 'status' ]
- contractId: integer,string (format: int64)
- status: #/components/schemas/ContractStatus (format: none)
=== SCHEMA: UpdateDisbursementRequest ===
Required: [ 'disbursementId', 'milestoneId', 'amount' ]
- disbursementId: integer,string (format: int64)
- milestoneId: null,integer,string (format: int64)
- amount: number,string (format: double)
=== SCHEMA: UpdateEvidenceRequest ===
Required: [ 'evidenceId', 'version', 'evidenceType', 'status' ]
- evidenceId: integer,string (format: int64)
- version: integer,string (format: int32)
- evidenceType: #/components/schemas/EvidenceType (format: none)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: UpdateMilestoneRequest ===
Required: [ 'milestoneId', 'dueAt', 'name', 'priority', 'status' ]
- milestoneId: integer,string (format: int64)
- dueAt: string (format: date-time)
- name: string (format: none)
- priority: #/components/schemas/Priority (format: none)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: UpdateOrganisationRequest ===
Required: [ 'organisationId', 'name', 'registrationNo', 'email', 'type' ]
- organisationId: integer,string (format: int64)
- name: string (format: none)
- registrationNo: string (format: none)
- email: string (format: none)
- type: #/components/schemas/OrganisationType (format: none)
=== SCHEMA: UpdatePortfolioRequest ===
Required: [ 'portfolioId', 'name', 'description', 'ownerId' ]
- portfolioId: integer,string (format: int64)
- name: string (format: none)
- description: string (format: none)
- ownerId: string (format: uuid)
=== SCHEMA: UpdateProgrammeRequest ===
Required: [ 'programmeId', 'name', 'ownerId', 'status' ]
- programmeId: integer,string (format: int64)
- name: string (format: none)
- ownerId: string (format: uuid)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: UpdateProjectRequest ===
Required: [
'projectId',
'name',
'description',
'startedAt',
'endedAt',
'sector',
'projectType'
]
- projectId: integer,string (format: int64)
- name: string (format: none)
- description: string (format: none)
- startedAt: null,string (format: date-time)
- endedAt: null,string (format: date-time)
- sector: #/components/schemas/IndustrySector (format: none)
- projectType: #/components/schemas/ProjectType (format: none)
=== SCHEMA: UpdateProjectStatusRequest ===
Required: [ 'projectId', 'status' ]
- projectId: integer,string (format: int64)
- status: #/components/schemas/ApprovalStatus (format: none)
=== SCHEMA: UpdateRiskRequest ===
Required: [ 'riskId', 'likelihood', 'impact', 'name', 'description' ]
- riskId: integer,string (format: int64)
- likelihood: #/components/schemas/RiskLikelihood (format: none)
- impact: #/components/schemas/RiskImpact (format: none)
- name: string (format: none)
- description: string (format: none)
=== SCHEMA: User ===
Required: []
- id: string (format: uuid)
- lastLoginAt: null,string (format: date-time)
- email: null,string (format: none)
- status: #/components/schemas/ActivityStatus (format: none)
=== SCHEMA: UserFilterRequest ===
Required: []
- status: any (format: none)
- email: null,string (format: none)
- sortBy: null,string (format: none)
- sortDescending: boolean (format: none)
+42 -30
View File
@@ -1,17 +1,27 @@
import React from 'react';
import React, { Suspense, lazy } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { queryClient } from './lib/queryClient';
import { AuthProvider, ProtectedRoute } from './auth/AuthProvider';
import { ToastProvider } from './components/ui/Toast';
import { AppLayout } from './components/layout/AppLayout';
import { LandingPage } from './pages/LandingPage';
import { ExecutiveDashboard } from './pages/ExecutiveDashboard';
import { GovernancePage } from './pages/GovernancePage';
import { ProjectsPage } from './pages/ProjectsPage';
import { GrantsPage } from './pages/GrantsPage';
import { MonitoringPage } from './pages/MonitoringPage';
import { SettingsPage } from './pages/SettingsPage';
const LandingPage = lazy(() => import('./pages/LandingPage').then(m => ({ default: m.LandingPage })));
const ExecutiveDashboard = lazy(() => import('./pages/ExecutiveDashboard').then(m => ({ default: m.ExecutiveDashboard })));
const GovernancePage = lazy(() => import('./pages/GovernancePage').then(m => ({ default: m.GovernancePage })));
const ProjectsPage = lazy(() => import('./pages/ProjectsPage').then(m => ({ default: m.ProjectsPage })));
const GrantsPage = lazy(() => import('./pages/GrantsPage').then(m => ({ default: m.GrantsPage })));
const MonitoringPage = lazy(() => import('./pages/MonitoringPage').then(m => ({ default: m.MonitoringPage })));
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(m => ({ default: m.SettingsPage })));
const RouteLoading = () => (
<div className="flex items-center justify-center min-h-[400px] w-full text-slate-400">
<div className="flex flex-col items-center gap-3">
<div className="w-7 h-7 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
<span className="text-xs font-medium text-slate-400">Loading module...</span>
</div>
</div>
);
export default function App() {
return (
@@ -19,30 +29,32 @@ export default function App() {
<BrowserRouter>
<AuthProvider>
<ToastProvider>
<Routes>
{/* Public OIDC Login / Landing Page */}
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<LandingPage />} />
<Suspense fallback={<RouteLoading />}>
<Routes>
{/* Public OIDC Login / Landing Page */}
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<LandingPage />} />
{/* Protected Backoffice Application Shell */}
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route path="/dashboard" element={<ExecutiveDashboard />} />
<Route path="/governance" element={<GovernancePage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/grants" element={<GrantsPage />} />
<Route path="/monitoring" element={<MonitoringPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
{/* Protected Backoffice Application Shell */}
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route path="/dashboard" element={<ExecutiveDashboard />} />
<Route path="/governance" element={<GovernancePage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/grants" element={<GrantsPage />} />
<Route path="/monitoring" element={<MonitoringPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
{/* Fallback Redirect */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
{/* Fallback Redirect */}
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Suspense>
</ToastProvider>
</AuthProvider>
</BrowserRouter>
@@ -131,7 +131,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Subscribe to global HTTP 401 session expiration events
useEffect(() => {
const unsubscribe = onSessionExpired((reason) => {
// Subordination: If account activation lockout is active, session expiration is completely avoided and bypassed
// If account activation is pending, suppress session expiration modal
if (isPendingActivation || isActivationPending()) {
return;
}
@@ -148,8 +148,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
};
}, [accessToken, isPendingActivation]);
// Priority Subordination: When activation process is active, dismiss all other modals,
// cancel session expiration and avoid its processes so that there is no overlapping.
// When account activation is pending, ensure secondary dialogs are closed
useEffect(() => {
if (isPendingActivation) {
setIsSessionExpired(false);
@@ -159,7 +158,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
}
}, [isPendingActivation]);
// When session expiration modal is displayed, dismiss all other subordinates
// When session expiration modal is displayed, dismiss background modals
useEffect(() => {
if (isSessionExpired) {
dismissSubordinateModals();
@@ -221,10 +220,7 @@ export const AuthProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
// Clean up code and state from URL
window.history.replaceState({}, document.title, window.location.pathname);
// Execute user sync endpoint upon login.
// If HTTP 401 -> routes to landing page
// If non-401 failure -> displays non-dismissable activation modal
// If success -> saved in browser to prevent endpoint hotspot
// Synchronize authenticated user profile with backend directory
try {
const syncResult = await syncUserAccount(userSub, token, profile, true);
if (syncResult.isUnauthorized) {
@@ -38,8 +38,7 @@ export const AccountActivationModal: React.FC<AccountActivationModalProps> = ({
const [errorMessage, setErrorMessage] = useState<string | null>(initialError || null);
const [syncAttemptCount, setSyncAttemptCount] = useState(0);
// Activation process dismisses all other modals so that it is displayed without overlapping.
// By extension, any active session expiration modal is dismissed and its processes cancelled.
// Clear any existing session prompt and close background dialogs
useEffect(() => {
cancelSessionExpiration();
dismissSubordinateModals();
@@ -21,14 +21,14 @@ export const SessionExpiredModal: React.FC<SessionExpiredModalProps> = ({
const totalDurationMs = countdownSeconds * 1000;
const isClearingRef = useRef(false);
// When session expiration modal is displayed, dismiss all subordinate modals
// Close background modals when session expires
useEffect(() => {
if (isOpen) {
dismissSubordinateModals();
}
}, [isOpen]);
// Listen for cancellation event (e.g., when activation lockout takes priority)
// Reset state if session expiration is cancelled
useEffect(() => {
const handleCancelled = () => {
setTimeLeftMs(countdownSeconds * 1000);
@@ -18,6 +18,7 @@ import {
} from 'lucide-react';
import { useAuth } from '../../auth/AuthProvider';
import { BrandLogo } from '../ui/BrandLogo';
import { HelpTooltip } from '../ui/HelpTooltip';
import { useQuery } from '@tanstack/react-query';
import { getUsers } from '../../services/api';
import { getUserEmail, registerUserEmail } from '../../lib/formatters';
@@ -27,31 +28,37 @@ const navItems = [
name: 'Executive Dashboard',
path: '/dashboard',
icon: LayoutDashboard,
help: 'Strategic macro metrics, portfolio value, disbursed funding, ISO 31000 risk heatmap, and projects requiring immediate intervention.',
},
{
name: 'Governance & Structure',
path: '/governance',
icon: Building2,
help: 'Master records for legal entities, holding structures, investment portfolios, and thematic programmes funded under statutory mandates.',
},
{
name: 'Projects & Milestones',
path: '/projects',
icon: FolderKanban,
help: 'Project Work Breakdown Structure (WBS), contractual deliverables, scheduled milestones, evidence verification, and execution tracking.',
},
{
name: 'Grant & Financials',
path: '/grants',
icon: Coins,
help: 'Financial management including statutory awards, multi-year budget line allocations, tranches, and scheduled payment disbursements.',
},
{
name: 'M&E, Evidence & Risks',
path: '/monitoring',
icon: ShieldAlert,
help: 'Monitoring & Evaluation (M&E) framework, indicator tracking, ISO 31000 risk registers, mitigation plans, and compliance audits.',
},
{
name: 'Settings & Communications',
path: '/settings',
icon: Settings,
help: 'User account provisioning, statutory sync, tenant environment parameters, and communication invite templates.',
},
];
@@ -109,7 +116,7 @@ export const AppLayout: React.FC = () => {
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`}
>
{/* Brand Header — Clickable link navigating to auth-aware Home page */}
{/* Brand Header */}
<div className="h-16 px-4 flex items-center justify-between border-b border-slate-800/80 bg-[#0d121f]/90">
<Link
to="/"
@@ -141,25 +148,36 @@ export const AppLayout: React.FC = () => {
{navItems.map((item) => {
const Icon = item.icon;
return (
<NavLink
key={item.path}
to={item.path}
className={({ 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'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}`
}
>
<Icon className="w-4 h-4 shrink-0" />
{sidebarOpen && <span className="truncate">{item.name}</span>}
</NavLink>
<div key={item.path} className="flex items-center justify-between group">
<NavLink
to={item.path}
className={({ isActive }) =>
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all flex-1 min-w-0 ${
isActive
? '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'
}`
}
>
<Icon className="w-4 h-4 shrink-0" />
{sidebarOpen && <span className="truncate">{item.name}</span>}
</NavLink>
{sidebarOpen && (
<div className="pl-1">
<HelpTooltip
title={item.name}
content={item.help}
size="sm"
placement="right"
/>
</div>
)}
</div>
);
})}
</nav>
{/* User Card with Token-derived info & Identity Server Logout */}
{/* User Card */}
<div className="p-3 border-t border-slate-800/80 bg-[#0d121f]/90">
<div className="flex items-center gap-3">
<div className="h-9 w-9 shrink-0 rounded-full bg-blue-600/20 border border-blue-500/40 flex items-center justify-center font-bold text-xs text-blue-400">
@@ -192,7 +210,7 @@ export const AppLayout: React.FC = () => {
{/* Top Header */}
<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">
{/* Top Bar Brand Icon & Direct Portal Link */}
{/* Brand Icon */}
<Link
to="/"
id="header-brand-logo-link"
@@ -207,7 +225,7 @@ export const AppLayout: React.FC = () => {
<span className="h-4 w-px bg-slate-800 hidden md:block" />
{/* Clickable Home Link */}
{/* Home Navigation */}
<Link
to="/"
id="header-home-link"
@@ -229,17 +247,31 @@ export const AppLayout: React.FC = () => {
<Database className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span className="truncate">{tenantName}</span>
</span>
<HelpTooltip
title="Tenant Context"
content="Identifies the multi-tenant organization partition and database boundary within the national PFM environment."
size="sm"
placement="bottom"
/>
</div>
<ChevronRight className="w-4 h-4 text-slate-600 hidden sm:block" />
<span className="text-xs font-medium text-emerald-400 bg-emerald-500/10 px-2.5 py-0.5 rounded-full border border-emerald-500/30 flex items-center gap-1.5 shadow-xs">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
Authenticated Session
</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-medium text-emerald-400 bg-emerald-500/10 px-2.5 py-0.5 rounded-full border border-emerald-500/30 flex items-center gap-1.5 shadow-xs">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
Authenticated Session
</span>
<HelpTooltip
title="Security Session Status"
content="Current session is verified against OIDC Identity Server with active cryptographic token and claims."
size="sm"
placement="bottom"
/>
</div>
</div>
{/* User Name & Email derived from token, plus Identity Server signout */}
{/* User Profile and Controls */}
<div className="relative flex items-center gap-3">
<div className="text-right hidden sm:block">
<p className="text-xs font-semibold text-white truncate max-w-[200px]" title={tokenUserName}>
@@ -292,7 +324,7 @@ export const AppLayout: React.FC = () => {
</div>
</header>
{/* Viewport with Landing Page ambient dot grid and glow */}
{/* Main Content Area */}
<main className="flex-1 overflow-y-auto p-6 md:p-8 bg-[#090d16] relative text-slate-100">
<div
className="absolute inset-0 pointer-events-none opacity-15"
File diff suppressed because it is too large Load Diff
@@ -17,12 +17,7 @@ const sizeMap = {
};
/**
* 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
* SVG brand logo component for Post Fund Management (PFM).
*/
export const BrandLogo: React.FC<BrandLogoProps> = ({
size = 'md',
@@ -0,0 +1,125 @@
import React, { useState, useRef, useEffect, ReactNode } from 'react';
import { Info, X } from 'lucide-react';
export interface HelpTooltipProps {
/** Concise context-sensitive explanation of the section, control, metric, or dialog */
content: ReactNode;
/** Optional title for the tooltip popover */
title?: string;
/** Custom alignment: top, bottom, left, right (defaults to top) */
placement?: 'top' | 'bottom' | 'left' | 'right';
/** Size of the (i) icon: sm (13px), md (15px), lg (18px) */
size?: 'sm' | 'md' | 'lg';
/** Additional styling classes for the container or trigger button */
className?: string;
/** Accessible label or ARIA label */
ariaLabel?: string;
}
export const HelpTooltip: React.FC<HelpTooltipProps> = ({
content,
title,
placement = 'top',
size = 'md',
className = '',
ariaLabel = 'Information and guidance',
}) => {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Close on Escape or click outside
useEffect(() => {
if (!isOpen) return;
const handleOutsideClick = (e: MouseEvent | TouchEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleOutsideClick);
document.addEventListener('touchstart', handleOutsideClick);
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('mousedown', handleOutsideClick);
document.removeEventListener('touchstart', handleOutsideClick);
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen]);
const sizeClasses = {
sm: 'w-3.5 h-3.5 text-[11px]',
md: 'w-4 h-4 text-xs',
lg: 'w-5 h-5 text-sm',
}[size];
const iconSizes = {
sm: 'w-3 h-3',
md: 'w-3.5 h-3.5',
lg: 'w-4 h-4',
}[size];
const placementClasses = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
}[placement];
return (
<div ref={containerRef} className={`relative inline-flex items-center align-middle ${className}`}>
<button
type="button"
aria-label={ariaLabel}
aria-expanded={isOpen}
onClick={(e) => {
e.stopPropagation();
setIsOpen((prev) => !prev);
}}
className={`inline-flex items-center justify-center rounded-full text-slate-400 hover:text-blue-400 bg-slate-800/80 hover:bg-slate-700/80 border border-slate-700/60 hover:border-blue-500/50 transition-all cursor-pointer shadow-xs focus:outline-none focus:ring-1 focus:ring-blue-500/60 ${sizeClasses} p-0.5`}
title="Click for context guidance"
>
<Info className={`${iconSizes} shrink-0`} />
</button>
{isOpen && (
<div
role="tooltip"
onClick={(e) => e.stopPropagation()}
className={`absolute ${placementClasses} z-50 w-72 max-w-[calc(100vw-32px)] p-3 rounded-xl bg-[#0c121e] border border-blue-500/30 text-slate-200 shadow-2xl backdrop-blur-md animate-in fade-in zoom-in-95 duration-150`}
>
<div className="flex items-start justify-between gap-2 border-b border-slate-800/80 pb-1.5 mb-2">
<div className="flex items-center gap-1.5 text-blue-400">
<Info className="w-3.5 h-3.5 shrink-0" />
<span className="font-semibold text-xs tracking-tight text-white">
{title || 'Guidance & Information'}
</span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setIsOpen(false);
}}
className="rounded-md p-0.5 text-slate-400 hover:text-white hover:bg-slate-800 transition cursor-pointer"
aria-label="Close tooltip"
>
<X className="w-3.5 h-3.5" />
</button>
</div>
<div className="text-[11px] leading-relaxed text-slate-300 font-normal space-y-1">
{content}
</div>
</div>
)}
</div>
);
};
@@ -1,11 +1,13 @@
import React, { ReactNode, useEffect } from 'react';
import { X } from 'lucide-react';
import { HelpTooltip } from './HelpTooltip';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: string;
subtitle?: string;
helpText?: ReactNode;
children: ReactNode;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
}
@@ -15,6 +17,7 @@ export const Modal: React.FC<ModalProps> = ({
onClose,
title,
subtitle,
helpText,
children,
maxWidth = 'lg',
}) => {
@@ -56,7 +59,17 @@ export const Modal: React.FC<ModalProps> = ({
>
<div className="flex items-start justify-between p-5 border-b border-slate-800/80 bg-[#0d121f]/90">
<div>
<h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3>
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-white tracking-tight">{title}</h3>
{helpText && (
<HelpTooltip
title={`${title} Guidance`}
content={helpText}
size="sm"
placement="bottom"
/>
)}
</div>
{subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
</div>
<button
@@ -0,0 +1,85 @@
import React from 'react';
import { AlertCircle, AlertTriangle, X } from 'lucide-react';
import { parseFriendlyError } from '../../utils/apiErrorParser';
interface ProblemDetailsAlertProps {
error: unknown;
onDismiss?: () => void;
title?: string;
className?: string;
}
export const ProblemDetailsAlert: React.FC<ProblemDetailsAlertProps> = ({
error,
onDismiss,
title,
className = '',
}) => {
if (!error) return null;
const parsed = parseFriendlyError(error, title || 'Action Could Not Be Completed');
return (
<div
role="alert"
className={`rounded-xl border border-rose-500/40 bg-[#160d14] p-4 text-gray-200 shadow-lg shadow-rose-950/20 ${className}`}
>
<div className="flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-rose-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="text-sm font-semibold text-white tracking-tight">
{parsed.title}
</h4>
{parsed.categoryLabel && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded border bg-rose-950/60 text-rose-300 border-rose-800/60">
{parsed.categoryLabel}
</span>
)}
{parsed.statusCode && (
<span className="text-[10px] font-mono font-medium px-1.5 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700">
HTTP {parsed.statusCode}
</span>
)}
</div>
{/* Single error or fallback message displayed directly */}
{(!parsed.errorsList || parsed.errorsList.length <= 1) && parsed.message && (
<p className="text-xs mt-1.5 text-rose-200/90 leading-relaxed whitespace-pre-line">
{parsed.message}
</p>
)}
{/* Multiple errors from ProblemDetails displayed directly */}
{parsed.errorsList && parsed.errorsList.length > 1 && (
<ul className="mt-2 space-y-1 bg-black/30 rounded-lg p-2.5 border border-rose-900/40">
{parsed.errorsList.map((err, idx) => (
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-2">
<span className="text-rose-400 mt-0.5">•</span>
<span className="leading-snug">{err}</span>
</li>
))}
</ul>
)}
{parsed.technicalDetails && (
<p className="text-[10px] font-mono text-slate-500 mt-2">
Ref: {parsed.technicalDetails}
</p>
)}
</div>
{onDismiss && (
<button
type="button"
onClick={onDismiss}
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800/60 transition-colors shrink-0 cursor-pointer"
aria-label="Dismiss error"
>
<X className="w-4 h-4" />
</button>
)}
</div>
</div>
);
};
@@ -2,6 +2,7 @@ import React, { useState, useMemo } from 'react';
import { Risk, Project } from '../../types';
import { getUserEmail } from '../../lib/formatters';
import { AlertTriangle, ShieldAlert, CheckCircle2, ChevronRight, X, Info } from 'lucide-react';
import { HelpTooltip } from './HelpTooltip';
interface RiskHeatmapMatrixProps {
risks: Risk[];
@@ -168,7 +169,15 @@ export const RiskHeatmapMatrix: React.FC<RiskHeatmapMatrixProps> = ({
{/* Header & Controls */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
{title && <h3 className="text-sm font-semibold text-white">{title}</h3>}
<div className="flex items-center gap-2">
{title && <h3 className="text-sm font-semibold text-white">{title}</h3>}
<HelpTooltip
title="Risk Heatmap Guidance"
content="Visualizes project risk exposure based on ISO 31000 standards. Severity score = Likelihood (1–5) × Impact (1–5). Click any populated cell to drill into registered risks."
size="sm"
placement="bottom"
/>
</div>
{subtitle && <p className="text-xs text-slate-400 mt-0.5">{subtitle}</p>}
</div>
@@ -200,6 +209,12 @@ export const RiskHeatmapMatrix: React.FC<RiskHeatmapMatrixProps> = ({
3×3 Summary
</button>
</div>
<HelpTooltip
title="Matrix Resolution Switcher"
content="Toggle between 5×5 detailed granular risk distribution and 3×3 executive aggregated categories (Low, Medium, High)."
size="sm"
placement="left"
/>
</div>
</div>
@@ -25,6 +25,7 @@ export interface ToastItem {
categoryLabel?: string;
technicalDetails?: string;
fieldErrors?: Array<{ field: string; message: string }>;
errorsList?: string[];
}
export interface ToastOptions {
@@ -34,6 +35,7 @@ export interface ToastOptions {
categoryLabel?: string;
technicalDetails?: string;
fieldErrors?: Array<{ field: string; message: string }>;
errorsList?: string[];
}
interface ToastContextType {
@@ -153,11 +155,21 @@ const ToastCard: React.FC<{
)}
</div>
{toast.message && (
{/* Display errors directly if multiple, or single message directly */}
{toast.errorsList && toast.errorsList.length > 1 ? (
<ul className="mt-1.5 space-y-1">
{toast.errorsList.map((err, idx) => (
<li key={idx} className="text-xs text-rose-200/90 flex items-start gap-1.5">
<span className="text-rose-400 mt-0.5">•</span>
<span className="leading-snug break-words">{err}</span>
</li>
))}
</ul>
) : toast.message ? (
<p className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
{toast.message}
</p>
)}
) : null}
{/* Optional collapsible technical reference */}
{toast.technicalDetails && (
@@ -255,6 +267,7 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
categoryLabel: options?.categoryLabel,
technicalDetails: options?.technicalDetails,
fieldErrors: options?.fieldErrors,
errorsList: options?.errorsList,
},
]);
};
@@ -302,6 +315,7 @@ export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) =
categoryLabel: options?.categoryLabel || parsed.categoryLabel,
technicalDetails: parsed.technicalDetails || options?.technicalDetails,
fieldErrors: parsed.fieldErrors || options?.fieldErrors,
errorsList: parsed.errorsList || options?.errorsList,
});
};
+23 -3
View File
@@ -65,13 +65,33 @@ function pickFirstNonEmpty(...candidates: any[]): string | undefined {
}
/**
* Normalizes an API base URL ensuring proper protocol and single trailing slash.
* Normalizes an API base URL ensuring HTTPS protocol by default and a single trailing slash.
*/
function normalizeBaseUrl(url: string): string {
const trimmed = url.trim();
let trimmed = url.trim();
// Ensure HTTPS is default protocol if protocol is omitted
if (!/^https?:\/\//i.test(trimmed)) {
trimmed = `https://${trimmed}`;
} else if (trimmed.startsWith('http://') && !trimmed.includes('localhost') && !trimmed.includes('127.0.0.1')) {
// Upgrade remote HTTP URLs to HTTPS by default
trimmed = trimmed.replace(/^http:\/\//i, 'https://');
}
return trimmed.endsWith('/') ? trimmed : `${trimmed}/`;
}
/**
* Normalizes an OIDC/STS Authority URL ensuring HTTPS protocol by default.
*/
function normalizeAuthorityUrl(url: string): string {
let trimmed = url.trim();
if (!/^https?:\/\//i.test(trimmed)) {
trimmed = `https://${trimmed}`;
} else if (trimmed.startsWith('http://') && !trimmed.includes('localhost') && !trimmed.includes('127.0.0.1')) {
trimmed = trimmed.replace(/^http:\/\//i, 'https://');
}
return trimmed.replace(/\/+$/, '');
}
/**
* Reads any runtime config object injected into window.
* Supports: window.__RUNTIME_CONFIG__, window.APP_CONFIG, window._env_
@@ -268,7 +288,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
return {
SecuritySettings: {
Authority: authority,
Authority: normalizeAuthorityUrl(authority),
Audience: audience,
ClientId: clientId,
ClientSecret: clientSecret,
@@ -28,6 +28,7 @@ import {
import { formatCompactZAR, formatZAR, getIndustrySectorLabel } from '../lib/formatters';
import { ApprovalStatus } from '../types';
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
import { HelpTooltip } from '../components/ui/HelpTooltip';
export const ExecutiveDashboard: React.FC = () => {
const queryClient = useQueryClient();
@@ -35,7 +36,7 @@ export const ExecutiveDashboard: React.FC = () => {
const [selectedSector, setSelectedSector] = useState('All');
const [selectedPeriod, setSelectedPeriod] = useState('FY 2026/27');
// Real Queries from OpenAPI Endpoints
// Data queries
const { data: dashboard, isLoading: loadingDashboard, refetch: refetchDashboard, isFetching } = useQuery({
queryKey: ['dashboard'],
queryFn: getDashboard,
@@ -109,7 +110,7 @@ export const ExecutiveDashboard: React.FC = () => {
return risks.filter((r) => projectIds.has(String(r.projectId)));
}, [risks, filteredProjects, selectedProgramme, selectedSector]);
// Headline metrics sourced directly from live API models with real filtering
// Headline portfolio metrics calculation
const portfolioValue = useMemo(() => {
if (selectedProgramme !== 'All' || selectedSector !== 'All') {
const projectIds = new Set(filteredProjects.map((p) => String(p.id)));
@@ -176,7 +177,7 @@ export const ExecutiveDashboard: React.FC = () => {
return Math.round((onTrack / targetProjects.length) * 100);
}, [dashboard, filteredProjects, projects]);
// Dynamically calculate At Risk count directly from live registered risks
// Calculate at-risk projects count based on risk scores
const atRiskCount = useMemo(() => {
const targetRisks = filteredRisks;
const highRisks = targetRisks.filter((r) => {
@@ -198,7 +199,7 @@ export const ExecutiveDashboard: React.FC = () => {
return sumActual > 0 ? sumActual : 0;
}, [dashboard, indicators]);
// Real sector breakdown calculated dynamically from actual project records
// Sector allocation breakdown
const sectorData = useMemo(() => {
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
if (targetProjects.length === 0) return [];
@@ -224,7 +225,7 @@ export const ExecutiveDashboard: React.FC = () => {
});
}, [projects, filteredProjects, portfolioValue]);
// Quarterly disbursements distribution computed from real disbursement items
// Quarterly disbursements distribution
const quarterlyTrend = useMemo(() => {
if (!disbursements || disbursements.length === 0) {
return [
@@ -260,7 +261,7 @@ export const ExecutiveDashboard: React.FC = () => {
}));
}, [disbursements]);
// Attention Projects derived directly from live project data & risk scores
// Projects requiring management attention based on risk profile
const attentionProjects = useMemo(() => {
const targetProjects = filteredProjects.length > 0 ? filteredProjects : projects;
if (targetProjects.length === 0) return [];
@@ -323,6 +324,12 @@ export const ExecutiveDashboard: React.FC = () => {
<h2 className="text-xl font-bold text-white tracking-tight">
Executive Portfolio Dashboard
</h2>
<HelpTooltip
title="Executive Dashboard Overview"
content="Macro-level command center showing aggregated national portfolio commitments, actual tranche disbursements, delivery pace, and critical project escalations."
size="sm"
placement="bottom"
/>
<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 Portfolio Feed
</span>
@@ -339,41 +346,65 @@ export const ExecutiveDashboard: React.FC = () => {
<span>Filter:</span>
</div>
<select
value={selectedProgramme}
onChange={(e) => setSelectedProgramme(e.target.value)}
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer"
>
<option value="All">Programme: All</option>
{programmes.map((pr) => (
<option key={pr.id} value={pr.id}>
{pr.name}
</option>
))}
</select>
<div className="flex items-center gap-1">
<select
value={selectedProgramme}
onChange={(e) => setSelectedProgramme(e.target.value)}
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer"
>
<option value="All">Programme: All</option>
{programmes.map((pr) => (
<option key={pr.id} value={pr.id}>
{pr.name}
</option>
))}
</select>
<HelpTooltip
title="Programme Filter"
content="Isolates metrics and projects funded under a specific thematic programme mandate."
size="sm"
placement="bottom"
/>
</div>
<select
value={selectedSector}
onChange={(e) => setSelectedSector(e.target.value)}
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer"
>
<option value="All">Sector: All</option>
{sectorData.map((s) => (
<option key={s.name} value={s.name}>
{s.name}
</option>
))}
</select>
<div className="flex items-center gap-1">
<select
value={selectedSector}
onChange={(e) => setSelectedSector(e.target.value)}
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer"
>
<option value="All">Sector: All</option>
{sectorData.map((s) => (
<option key={s.name} value={s.name}>
{s.name}
</option>
))}
</select>
<HelpTooltip
title="Industry Sector Filter"
content="Filters analytics by designated economic industry sector (e.g. AgriTech, HealthTech, Renewable Energy)."
size="sm"
placement="bottom"
/>
</div>
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer"
>
<option value="FY 2026/27">Period: FY 2026/27</option>
<option value="FY 2025/26">Period: FY 2025/26</option>
<option value="All-Time">All-Time Cumulative</option>
</select>
<div className="flex items-center gap-1">
<select
value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)}
className="text-xs font-medium bg-[#090d16] border border-slate-700/80 rounded-xl px-3 py-1.5 text-slate-200 focus:outline-none focus:ring-1 focus:ring-blue-500 transition cursor-pointer"
>
<option value="FY 2026/27">Period: FY 2026/27</option>
<option value="FY 2025/26">Period: FY 2025/26</option>
<option value="All-Time">All-Time Cumulative</option>
</select>
<HelpTooltip
title="Financial Period Filter"
content="Calculates disbursements and milestones according to the government fiscal year or cumulative life-of-programme."
size="sm"
placement="bottom"
/>
</div>
<button
onClick={refetchAll}
@@ -391,7 +422,15 @@ export const ExecutiveDashboard: React.FC = () => {
{/* Card 1: Portfolio Value */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Portfolio Value</span>
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Portfolio Value</span>
<HelpTooltip
title="Total Portfolio Value"
content="Aggregated statutory grant commitment amount approved across all active grant awards and contracts."
size="sm"
placement="top"
/>
</div>
<div className="p-1.5 rounded-lg bg-blue-500/10 text-blue-400 border border-blue-500/20 group-hover:scale-110 transition">
<Coins className="w-3.5 h-3.5" />
</div>
@@ -405,7 +444,15 @@ export const ExecutiveDashboard: React.FC = () => {
{/* Card 2: Active Awards */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-blue-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Active Awards</span>
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Active Awards</span>
<HelpTooltip
title="Active Grant Awards"
content="Total legally executed statutory agreements currently undergoing active disbursement and implementation."
size="sm"
placement="top"
/>
</div>
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-110 transition">
<Layers className="w-3.5 h-3.5" />
</div>
@@ -421,7 +468,15 @@ export const ExecutiveDashboard: React.FC = () => {
{/* Card 3: Disbursed */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-teal-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Disbursed</span>
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Disbursed</span>
<HelpTooltip
title="Disbursed Funds"
content="Actual funds paid out to implementing grantees following verified milestone proof-of-performance."
size="sm"
placement="top"
/>
</div>
<div className="p-1.5 rounded-lg bg-teal-500/10 text-teal-400 border border-teal-500/20 group-hover:scale-110 transition">
<TrendingUp className="w-3.5 h-3.5" />
</div>
@@ -437,7 +492,15 @@ export const ExecutiveDashboard: React.FC = () => {
{/* Card 4: On Track */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-emerald-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">On Track</span>
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">On Track</span>
<HelpTooltip
title="On-Track Progress Ratio"
content="Percentage of active projects meeting schedule deadlines and deliverable acceptance criteria without critical deviations."
size="sm"
placement="top"
/>
</div>
<div className="p-1.5 rounded-lg bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 group-hover:scale-110 transition">
<CheckCircle className="w-3.5 h-3.5" />
</div>
@@ -451,7 +514,15 @@ export const ExecutiveDashboard: React.FC = () => {
{/* Card 5: At Risk */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-amber-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">At Risk</span>
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">At Risk</span>
<HelpTooltip
title="At-Risk Projects"
content="Count of projects with high residual risk scores (≥12), schedule slippage >30 days, or adverse financial variance."
size="sm"
placement="top"
/>
</div>
<div className="p-1.5 rounded-lg bg-amber-500/10 text-amber-400 border border-amber-500/20 group-hover:scale-110 transition">
<AlertTriangle className="w-3.5 h-3.5" />
</div>
@@ -465,7 +536,15 @@ export const ExecutiveDashboard: React.FC = () => {
{/* Card 6: Beneficiaries Reached */}
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 hover:border-indigo-500/30 transition-all flex flex-col justify-between shadow-md group backdrop-blur-sm">
<div className="flex items-center justify-between text-slate-400">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Beneficiaries</span>
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wider text-slate-400">Beneficiaries</span>
<HelpTooltip
title="Beneficiaries Impacted"
content="Cumulative count of direct beneficiaries reached through projects, verified against national M&E indicator targets."
size="sm"
placement="top"
/>
</div>
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-400 border border-indigo-500/20 group-hover:scale-110 transition">
<Users className="w-3.5 h-3.5" />
</div>
@@ -485,13 +564,21 @@ export const ExecutiveDashboard: React.FC = () => {
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 flex flex-col justify-between shadow-lg backdrop-blur-sm">
<div>
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
<h3 className="text-sm font-semibold text-white">Funding by Sector</h3>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-white">Funding by Sector</h3>
<HelpTooltip
title="Sector Funding Distribution"
content="Breakdown of active grant commitments distributed across designated industrial and socio-economic sectors."
size="sm"
placement="bottom"
/>
</div>
<span className="text-[11px] font-mono text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded-md border border-blue-500/30">
Live Portfolio
</span>
</div>
{/* Circular Graphic Simulation */}
{/* Sector Donut Chart */}
<div className="py-6 flex items-center justify-center">
<div className="relative w-36 h-36 rounded-full border-8 border-slate-800 flex items-center justify-center">
<div className="w-24 h-24 rounded-full border-4 border-blue-500 flex flex-col items-center justify-center bg-[#090d16] shadow-inner">
@@ -535,7 +622,15 @@ export const ExecutiveDashboard: React.FC = () => {
<div className="bg-[#0f1523]/80 p-5 rounded-2xl border border-slate-800/80 flex flex-col justify-between shadow-lg backdrop-blur-sm">
<div>
<div className="flex items-center justify-between border-b border-slate-800/80 pb-3">
<h3 className="text-sm font-semibold text-white">Disbursement Distribution</h3>
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-white">Disbursement Distribution</h3>
<HelpTooltip
title="Disbursement Timeline & Cadence"
content="Compares statutory multi-year grant tranche commitments against verified payouts released across calendar quarters."
size="sm"
placement="bottom"
/>
</div>
<span className="text-[11px] font-mono text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-md border border-emerald-500/30 font-semibold">
{disbursements.length} Tranche{disbursements.length === 1 ? '' : 's'}
</span>
@@ -545,7 +640,7 @@ export const ExecutiveDashboard: React.FC = () => {
Quarterly release distribution across active portfolios and milestone verifications.
</p>
{/* Graphic Trend Visualization from Live Disbursements */}
{/* Quarterly Disbursement Trend Chart */}
<div className="h-44 flex items-end gap-3 pt-6 pb-2 px-2 border-b border-slate-800/80">
{quarterlyTrend.map((q) => (
<div key={q.quarter} className="flex-1 flex flex-col items-center gap-1.5 h-full justify-end">
@@ -597,7 +692,15 @@ export const ExecutiveDashboard: React.FC = () => {
<div className="bg-[#0f1523]/80 rounded-2xl border border-slate-800/80 overflow-hidden shadow-lg backdrop-blur-sm">
<div className="p-5 border-b border-slate-800/80 flex flex-col sm:flex-row sm:items-center justify-between gap-2 bg-[#0d121f]/90">
<div>
<h3 className="text-base font-semibold text-white">Projects Requiring Attention</h3>
<div className="flex items-center gap-2">
<h3 className="text-base font-semibold text-white">Projects Requiring Attention</h3>
<HelpTooltip
title="Exception Escalations"
content="Identifies projects breaching risk thresholds, schedule deadlines, or financial variances (>10%) requiring executive steering committee action."
size="sm"
placement="bottom"
/>
</div>
<p className="text-xs text-slate-400 mt-0.5">
Prioritized by risk severity, delivery delay, or financial variance threshold breach.
</p>
@@ -19,13 +19,16 @@ import {
} from 'lucide-react';
import {
getOrganisations,
getOrganisation,
createOrganisation,
updateOrganisation,
getAllPortfolios,
getPortfolios,
getPortfolio,
createPortfolio,
updatePortfolio,
getAllProgrammes,
getProgrammes,
getProgramme,
createProgramme,
updateProgramme,
@@ -33,6 +36,7 @@ import {
getPortfolioProgrammes,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast';
import { useAuth } from '../auth/AuthProvider';
import {
@@ -111,6 +115,15 @@ export const GovernancePage: React.FC = () => {
const [selectedPortfolioForProgrammes, setSelectedPortfolioForProgrammes] = useState<Portfolio | null>(null);
const [isPortfolioProgrammesModalOpen, setIsPortfolioProgrammesModalOpen] = useState(false);
const [selectedOrgForDetail, setSelectedOrgForDetail] = useState<Organisation | null>(null);
const [isOrgDetailModalOpen, setIsOrgDetailModalOpen] = useState(false);
const [selectedPortfolioForDetail, setSelectedPortfolioForDetail] = useState<Portfolio | null>(null);
const [isPortfolioDetailModalOpen, setIsPortfolioDetailModalOpen] = useState(false);
const [selectedProgrammeForDetail, setSelectedProgrammeForDetail] = useState<Programme | null>(null);
const [isProgrammeDetailModalOpen, setIsProgrammeDetailModalOpen] = useState(false);
// Queries
const { data: organisations = [], isLoading: loadingOrgs } = useQuery({
queryKey: ['organisations'],
@@ -139,6 +152,36 @@ export const GovernancePage: React.FC = () => {
enabled: !!selectedPortfolioForProgrammes && isPortfolioProgrammesModalOpen,
});
const { data: portfolioDirectProgrammes = [] } = useQuery({
queryKey: ['portfolio-direct-programmes', selectedPortfolioForProgrammes?.id],
queryFn: () => (selectedPortfolioForProgrammes ? getProgrammes(selectedPortfolioForProgrammes.id) : Promise.resolve([])),
enabled: !!selectedPortfolioForProgrammes && isPortfolioProgrammesModalOpen,
});
const { data: orgFreshDetail, isLoading: loadingOrgFreshDetail } = useQuery({
queryKey: ['organisation', selectedOrgForDetail?.id],
queryFn: () => (selectedOrgForDetail ? getOrganisation(selectedOrgForDetail.id) : null),
enabled: !!selectedOrgForDetail && isOrgDetailModalOpen,
});
const { data: orgPortfolios = [], isLoading: loadingOrgPortfolios } = useQuery({
queryKey: ['organisation-portfolios', selectedOrgForDetail?.id],
queryFn: () => (selectedOrgForDetail ? getPortfolios(selectedOrgForDetail.id) : Promise.resolve([])),
enabled: !!selectedOrgForDetail && isOrgDetailModalOpen,
});
const { data: portfolioFreshDetail, isLoading: loadingPortfolioFreshDetail } = useQuery({
queryKey: ['portfolio', selectedPortfolioForDetail?.id],
queryFn: () => (selectedPortfolioForDetail ? getPortfolio(selectedPortfolioForDetail.id) : null),
enabled: !!selectedPortfolioForDetail && isPortfolioDetailModalOpen,
});
const { data: programmeFreshDetail, isLoading: loadingProgrammeFreshDetail } = useQuery({
queryKey: ['programme', selectedProgrammeForDetail?.id],
queryFn: () => (selectedProgrammeForDetail ? getProgramme(selectedProgrammeForDetail.id) : null),
enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen,
});
// Forms
const orgForm = useForm<OrganisationFormValues>({
resolver: zodResolver(organisationSchema) as any,
@@ -336,6 +379,11 @@ export const GovernancePage: React.FC = () => {
<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">
Directory
</span>
<HelpTooltip
title="Statutory Governance Architecture"
content="Maintain authoritative organizational profiles, funding portfolios, and strategic programmes. Ensures compliance with national grant mandates and statutory reporting standards."
placement="right"
/>
</div>
<p className="text-xs text-slate-400 mt-1">
Maintain Master Organisation Registry, Funder Portfolios, and Strategic Programmes hierarchy.
@@ -385,7 +433,13 @@ export const GovernancePage: React.FC = () => {
}`}
>
<Building2 className="w-4 h-4" />
Organisations Directory ({organisations.length})
<span>Organisations Directory ({organisations.length})</span>
<HelpTooltip
title="Organisations Master"
content="Official register of participating recipient and funder entities, verified against legal and regulatory standards."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => setActiveTab('portfolios')}
@@ -396,7 +450,13 @@ export const GovernancePage: React.FC = () => {
}`}
>
<FolderKanban className="w-4 h-4" />
Funder Portfolios ({portfolios.length})
<span>Funder Portfolios ({portfolios.length})</span>
<HelpTooltip
title="Funder Portfolios"
content="High-level funding groupings designated for specific strategic objectives, themes, or departmental allocations."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => setActiveTab('programmes')}
@@ -407,7 +467,13 @@ export const GovernancePage: React.FC = () => {
}`}
>
<Layers className="w-4 h-4" />
Programmes Hierarchy ({programmes.length})
<span>Programmes Hierarchy ({programmes.length})</span>
<HelpTooltip
title="Strategic Programmes"
content="Operational funding vehicles linked to portfolios that govern grant instruments, projects, and target metrics."
size="sm"
placement="top"
/>
</button>
</div>
@@ -494,13 +560,27 @@ export const GovernancePage: React.FC = () => {
</span>
</td>
<td className="py-3.5 px-5 text-right">
<button
onClick={() => openEditOrg(org)}
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-3 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
onClick={() => {
setSelectedOrgForDetail(org);
setIsOrgDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 hover:text-white bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-2.5 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
title="Inspect Organisation (GET /v1/api/governance/organisations/{id})"
>
<Eye className="w-3 h-3" />
Inspect
</button>
<button
onClick={() => openEditOrg(org)}
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-3 py-1.5 rounded-lg transition shadow-xs cursor-pointer"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</div>
</td>
</tr>
);
@@ -544,6 +624,18 @@ export const GovernancePage: React.FC = () => {
Owner: <span className="text-slate-200 font-mono text-[11px]">{getUserEmail(p.ownedBy)}</span>
</span>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={() => {
setSelectedPortfolioForDetail(p);
setIsPortfolioDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-cyan-400 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Inspect Portfolio (GET /v1/api/governance/portfolios/{id})"
>
<Eye className="w-3 h-3" />
Inspect
</button>
<button
type="button"
onClick={() => {
@@ -619,6 +711,18 @@ export const GovernancePage: React.FC = () => {
</td>
<td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-1.5">
<button
type="button"
onClick={() => {
setSelectedProgrammeForDetail(pr);
setIsProgrammeDetailModalOpen(true);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-cyan-400 hover:text-white bg-cyan-500/10 hover:bg-cyan-500/20 border border-cyan-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Inspect Programme (GET /v1/api/projects/programme/{id})"
>
<Eye className="w-3 h-3" />
Inspect
</button>
<button
type="button"
onClick={() => {
@@ -657,12 +761,21 @@ export const GovernancePage: React.FC = () => {
onClose={() => setIsOrgModalOpen(false)}
title={editingOrg ? 'Edit Organisation Profile' : 'Register New Organisation'}
subtitle="Mandatory master data profile for Recipient or Funder entities (POPIA compliant)"
helpText="Maintains formal company or institutional credentials for funding allocation, audited legal standing, and regulatory reporting."
>
<form onSubmit={orgForm.handleSubmit((data) => orgMutation.mutate(data))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Legal Organisation Name *
</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Legal Organisation Name *
</label>
<HelpTooltip
title="Registered Legal Entity"
content="Registered statutory name matching company incorporation or university charter."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...orgForm.register('name')}
@@ -676,9 +789,17 @@ export const GovernancePage: React.FC = () => {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Registration / CIPC Number *
</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Registration / CIPC Number *
</label>
<HelpTooltip
title="Official Registration Number"
content="Companies and Intellectual Property Commission (CIPC) registration number or institutional tax ID."
size="sm"
placement="top"
/>
</div>
<input
type="text"
{...orgForm.register('registrationNo')}
@@ -691,9 +812,17 @@ export const GovernancePage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Entity Classification *
</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Entity Classification *
</label>
<HelpTooltip
title="Institutional Type"
content="Classification determining applicable compliance rules, indirect cost rates, and audit tiers."
size="sm"
placement="top"
/>
</div>
<select
{...orgForm.register('type')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
@@ -709,9 +838,17 @@ export const GovernancePage: React.FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Official Contact Email *
</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Official Contact Email *
</label>
<HelpTooltip
title="Primary Notice Email"
content="Designated address for formal legal notices, grant awards, and compliance audit notifications."
size="sm"
placement="top"
/>
</div>
<input
type="email"
{...orgForm.register('email')}
@@ -725,9 +862,17 @@ export const GovernancePage: React.FC = () => {
{editingOrg && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Operating Status *
</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Operating Status *
</label>
<HelpTooltip
title="Lifecycle State"
content="Active organizations are eligible for new contracts and disbursement releases."
size="sm"
placement="top"
/>
</div>
<select
{...orgForm.register('status')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
@@ -770,10 +915,19 @@ export const GovernancePage: React.FC = () => {
}}
title={editingPortfolio ? 'Edit Funder Portfolio' : 'Create Funder Portfolio'}
subtitle="Strategic allocation bucket grouping multiple programmatic initiatives"
helpText="Portfolios consolidate thematic grant priorities under an authoritative parent entity (e.g. Science & Innovation Department) for high-level fiscal tracking."
>
<form onSubmit={portfolioForm.handleSubmit((data) => portfolioMutation.mutate(data))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Portfolio Title *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Portfolio Title *</label>
<HelpTooltip
title="Portfolio Nomenclature"
content="Clear strategic theme or national fund title (e.g. Energy Transition Fund, Biotechnology Thrust)."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...portfolioForm.register('name')}
@@ -786,7 +940,15 @@ export const GovernancePage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Parent Organisation *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Parent Organisation *</label>
<HelpTooltip
title="Funding Custodian"
content="The governing department, ministry, or endowment holding statutory financial authority."
size="sm"
placement="right"
/>
</div>
<select
{...portfolioForm.register('organisationId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
@@ -800,7 +962,15 @@ export const GovernancePage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Owner Email *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Owner Email *</label>
<HelpTooltip
title="Portfolio Director"
content="Designated chief director or portfolio lead responsible for executive decisions and allocations."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...portfolioForm.register('ownerId')}
@@ -810,7 +980,15 @@ export const GovernancePage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Strategic Objective *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Strategic Objective *</label>
<HelpTooltip
title="Statutory Mandate"
content="High-level policy goal, target impact, or socioeconomic transformation objectives."
size="sm"
placement="right"
/>
</div>
<textarea
rows={3}
{...portfolioForm.register('description')}
@@ -853,10 +1031,19 @@ export const GovernancePage: React.FC = () => {
}}
title={editingProgramme ? 'Edit Strategic Programme' : 'Create Strategic Programme'}
subtitle="Programmatic operational entity funding distinct projects and awards"
helpText="Programmes operationalize portfolios into concrete funding instruments. Projects and grant awards are linked directly to programmes."
>
<form onSubmit={programmeForm.handleSubmit((data) => programmeMutation.mutate(data))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Programme Title *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Programme Title *</label>
<HelpTooltip
title="Programme Identifier"
content="Official operational title under which funding calls and awards are issued."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...programmeForm.register('name')}
@@ -869,7 +1056,15 @@ export const GovernancePage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Assign to Portfolio *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Assign to Portfolio *</label>
<HelpTooltip
title="Parent Portfolio"
content="The portfolio funding umbrella to which this programme reports."
size="sm"
placement="right"
/>
</div>
<select
{...programmeForm.register('portfolioId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
@@ -884,7 +1079,15 @@ export const GovernancePage: React.FC = () => {
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Governance Status *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Governance Status *</label>
<HelpTooltip
title="Approval Gate"
content="Only Approved programmes can issue active grants and enter contract agreements."
size="sm"
placement="top"
/>
</div>
<select
{...programmeForm.register('status')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
@@ -898,7 +1101,15 @@ export const GovernancePage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Owner Email *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Owner Email *</label>
<HelpTooltip
title="Programme Manager"
content="Designated programme officer responsible for monitoring KPIs and milestone approvals."
size="sm"
placement="top"
/>
</div>
<input
type="text"
{...programmeForm.register('ownerId')}
@@ -1048,6 +1259,196 @@ export const GovernancePage: React.FC = () => {
</div>
</div>
</Modal>
{/* Modal: View Organisation Details (GET /v1/api/governance/organisations/{id} & /portfolio) */}
<Modal
isOpen={isOrgDetailModalOpen}
onClose={() => {
setIsOrgDetailModalOpen(false);
setSelectedOrgForDetail(null);
}}
title={orgFreshDetail?.name || selectedOrgForDetail?.name || 'Organisation Master Record'}
subtitle={`Authoritative profile for Organisation #${selectedOrgForDetail?.id || ''}`}
>
<div className="space-y-4 text-xs">
{loadingOrgFreshDetail ? (
<div className="py-8 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Fetching canonical record (GET /v1/api/governance/organisations/{selectedOrgForDetail?.id})...
</div>
) : (
<>
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-2">
<div className="grid grid-cols-2 gap-3">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Legal Name:</span>
<span className="text-white font-medium">{orgFreshDetail?.name || selectedOrgForDetail?.name}</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Reg Number:</span>
<span className="font-mono text-slate-300">{orgFreshDetail?.registrationNo || selectedOrgForDetail?.registrationNo || '—'}</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Official Email:</span>
<span className="font-mono text-slate-300">{orgFreshDetail?.email || selectedOrgForDetail?.email}</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Entity Type:</span>
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
</div>
</div>
</div>
<div>
<h4 className="font-semibold text-slate-300 text-xs mb-2 flex items-center justify-between">
<span>Linked Portfolios (GET /v1/api/governance/organisations/{selectedOrgForDetail?.id}/portfolio)</span>
<span className="text-[10px] font-mono text-slate-400">{orgPortfolios.length} linked</span>
</h4>
{loadingOrgPortfolios ? (
<p className="text-xs text-slate-500">Loading portfolios...</p>
) : orgPortfolios.length === 0 ? (
<p className="text-xs text-slate-500">No portfolios directly linked to this organisation.</p>
) : (
<div className="space-y-2 max-h-[220px] overflow-y-auto">
{orgPortfolios.map((port) => (
<div key={port.id} className="p-3 bg-slate-900/60 rounded-xl border border-slate-800 flex items-center justify-between">
<div>
<span className="text-white font-medium block">{port.name}</span>
<span className="text-[10px] text-slate-500 font-mono">ID: {port.id}</span>
</div>
<span className="text-[10px] text-blue-400 bg-blue-500/10 px-2 py-0.5 rounded border border-blue-500/20 font-mono">
Portfolio
</span>
</div>
))}
</div>
)}
</div>
</>
)}
<div className="pt-2 border-t border-slate-800 flex justify-end">
<button
type="button"
onClick={() => {
setIsOrgDetailModalOpen(false);
setSelectedOrgForDetail(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 Details (GET /v1/api/governance/portfolios/{id}) */}
<Modal
isOpen={isPortfolioDetailModalOpen}
onClose={() => {
setIsPortfolioDetailModalOpen(false);
setSelectedPortfolioForDetail(null);
}}
title={portfolioFreshDetail?.name || selectedPortfolioForDetail?.name || 'Portfolio Details'}
subtitle={`Authoritative record for Portfolio #${selectedPortfolioForDetail?.id || ''}`}
>
<div className="space-y-4 text-xs">
{loadingPortfolioFreshDetail ? (
<div className="py-8 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Fetching canonical record (GET /v1/api/governance/portfolios/{selectedPortfolioForDetail?.id})...
</div>
) : (
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Title:</span>
<span className="text-white font-medium text-sm">{portfolioFreshDetail?.name || selectedPortfolioForDetail?.name}</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Description:</span>
<p className="text-slate-300 mt-0.5 leading-relaxed">{portfolioFreshDetail?.description || selectedPortfolioForDetail?.description || 'None provided'}</p>
</div>
<div className="grid grid-cols-2 gap-2 pt-2 border-t border-slate-800/80">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Owner:</span>
<span className="font-mono text-slate-300">{getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)}</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Created:</span>
<span className="font-mono text-slate-300">
{formatDate(portfolioFreshDetail?.createdAt || selectedPortfolioForDetail?.createdAt || '')}
</span>
</div>
</div>
</div>
)}
<div className="pt-2 border-t border-slate-800 flex justify-end">
<button
type="button"
onClick={() => {
setIsPortfolioDetailModalOpen(false);
setSelectedPortfolioForDetail(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 Programme Details (GET /v1/api/projects/programme/{id}) */}
<Modal
isOpen={isProgrammeDetailModalOpen}
onClose={() => {
setIsProgrammeDetailModalOpen(false);
setSelectedProgrammeForDetail(null);
}}
title={programmeFreshDetail?.name || selectedProgrammeForDetail?.name || 'Programme Details'}
subtitle={`Authoritative record for Programme #${selectedProgrammeForDetail?.id || ''}`}
>
<div className="space-y-4 text-xs">
{loadingProgrammeFreshDetail ? (
<div className="py-8 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Fetching canonical record (GET /v1/api/projects/programme/{selectedProgrammeForDetail?.id})...
</div>
) : (
<div className="p-4 bg-[#0d121f] rounded-xl border border-slate-800 space-y-3">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Programme Name:</span>
<span className="text-white font-medium text-sm">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
</div>
<div className="grid grid-cols-2 gap-2 pt-2">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Approval Status:</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-semibold border ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Owner:</span>
<span className="font-mono text-slate-300">{getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)}</span>
</div>
</div>
</div>
)}
<div className="pt-2 border-t border-slate-800 flex justify-end">
<button
type="button"
onClick={() => {
setIsProgrammeDetailModalOpen(false);
setSelectedProgrammeForDetail(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>
);
};
@@ -48,6 +48,7 @@ import {
getAllMilestones,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast';
import {
Award,
@@ -71,13 +72,13 @@ import {
getUserEmail,
} from '../lib/formatters';
// Schemas matching Figure 9 and OpenAPI constraints
// Validation schemas for grant administration
const awardSchema = z.object({
referenceNo: z.string().min(4, 'Approval / Reference number is mandatory (FR-M01-002)'),
amount: z.coerce.number().min(1, 'Approved funding amount must be > 0 (FR-M01-003)'),
referenceNo: z.string().min(4, 'Approval / Reference number is mandatory'),
amount: z.coerce.number().min(1, 'Approved funding amount must be greater than zero'),
fundingPeriod: z.string().min(3, 'Funding period is mandatory (e.g. 24 Months)'),
organisationId: z.coerce.number().min(1, 'Select recipient organisation (FR-M01-006)'),
programmeId: z.coerce.number().min(1, 'Select funding programme (FR-M01-005)'),
organisationId: z.coerce.number().min(1, 'Select recipient organisation'),
programmeId: z.coerce.number().min(1, 'Select funding programme'),
});
const contractSchema = z.object({
@@ -253,7 +254,7 @@ export const GrantsPage: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ['contracts'] });
success(
'Contract Executed & Locked',
'Agreement locked as authoritative version (AT-02). Downstream budgets and disbursements enabled.'
'Agreement locked as authoritative version. Downstream budgets and disbursements enabled.'
);
setIsExecuteModalOpen(false);
executeForm.reset();
@@ -333,7 +334,7 @@ export const GrantsPage: React.FC = () => {
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success(
'Payment Approved & Released',
'Conditions precedent verified. EFT reference generated and disbursement ledger updated (AT-05).'
'Conditions precedent verified. EFT reference generated and disbursement ledger updated.'
);
},
onError: (err: unknown) => {
@@ -354,8 +355,14 @@ export const GrantsPage: React.FC = () => {
const handleGetContractSecureLink = async (contractId: string | number) => {
try {
const url = await getContractSecureLink(contractId);
if (url) {
window.open(url, '_blank', 'noopener,noreferrer');
if (url && typeof url === 'string' && url.trim() !== '') {
const link = document.createElement('a');
link.href = url.trim();
link.target = '_blank';
link.rel = 'noopener noreferrer';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
success('Secure URL Retrieved', 'Temporary signed download link opened.');
} else {
toastError('Download Error', 'No active secure download link generated.');
@@ -400,9 +407,17 @@ export const GrantsPage: React.FC = () => {
<div className="bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl space-y-5">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
Grant & Financial Management
</h2>
<div className="flex items-center gap-2">
<h2 className="text-2xl font-bold text-white tracking-tight">
Grant & Financial Management
</h2>
<HelpTooltip
title="Grant Financial Lifecycle"
content="Enforces the statutory financial control thread: Awards (M01) -> Contract Execution & Vault (M02) -> Multi-Year Budgets (M03) -> Condition-Checked Tranche Disbursements (M04)."
size="md"
placement="right"
/>
</div>
<p className="text-xs text-slate-400 mt-1">
End-to-end controlled execution thread: Funding Award Intake &rarr; Contracting &rarr; Budgeting &rarr; Condition-Checked Disbursement.
</p>
@@ -451,15 +466,39 @@ export const GrantsPage: React.FC = () => {
{/* Pipeline Summary Bar */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-3 border-t border-slate-800/80 text-xs">
<div className="p-3.5 rounded-xl bg-slate-900/60 border border-slate-800/80 flex items-center justify-between">
<span className="text-slate-400 font-medium">Total Awards Registered</span>
<div className="flex items-center gap-1.5">
<span className="text-slate-400 font-medium">Total Awards Registered</span>
<HelpTooltip
title="Total Awards"
content="Cumulative grant allocation amounts committed across all approved beneficiary entities."
size="sm"
placement="top"
/>
</div>
<span className="font-bold text-white font-mono">{formatCompactZAR(totalAwardValue)}</span>
</div>
<div className="p-3.5 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-between">
<span className="text-emerald-400 font-medium">Disbursed (Verified & Paid)</span>
<div className="flex items-center gap-1.5">
<span className="text-emerald-400 font-medium">Disbursed (Verified & Paid)</span>
<HelpTooltip
title="Disbursed Capital"
content="Funds verified, authorized by accounting officer, and disbursed into beneficiary bank accounts."
size="sm"
placement="top"
/>
</div>
<span className="font-bold text-emerald-300 font-mono">{formatCompactZAR(totalDisbursedValue)}</span>
</div>
<div className="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-between">
<span className="text-amber-400 font-medium">Pending Release</span>
<div className="flex items-center gap-1.5">
<span className="text-amber-400 font-medium">Pending Release</span>
<HelpTooltip
title="Pending Release"
content="Tranches currently pending condition precedent verification, milestone sign-off, or statutory review."
size="sm"
placement="top"
/>
</div>
<span className="font-bold text-amber-300 font-mono">{formatCompactZAR(pendingDisbursementValue)}</span>
</div>
</div>
@@ -819,13 +858,22 @@ export const GrantsPage: React.FC = () => {
onClose={() => setIsAwardModalOpen(false)}
title="Register Approved Funding Award"
subtitle="Step 1 of 3 — Award Details"
helpText="Register an approved grant allocation. Once registered, the system will automatically prepare a draft contract workflow linked to this award."
>
<form onSubmit={awardForm.handleSubmit((data) => awardMutation.mutate(data as any))} className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Approval / Reference Number *
</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Approval / Reference Number *
</label>
<HelpTooltip
title="Statutory Reference Number"
content="Formal committee approval voucher or gazetted award identifier."
size="sm"
placement="top"
/>
</div>
<input
type="text"
{...awardForm.register('referenceNo')}
@@ -838,9 +886,17 @@ export const GrantsPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Approved Funding Amount (ZAR) *
</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Approved Funding Amount (ZAR) *
</label>
<HelpTooltip
title="Award Quantum"
content="Total approved allocation authorized by the statutory grants board."
size="sm"
placement="top"
/>
</div>
<input
type="number"
{...awardForm.register('amount')}
@@ -854,9 +910,17 @@ export const GrantsPage: React.FC = () => {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Recipient Organisation [search] *
</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Recipient Organisation *
</label>
<HelpTooltip
title="Beneficiary Entity"
content="Verified enterprise, university, or consortium holding the funding agreement."
size="sm"
placement="top"
/>
</div>
<select
{...awardForm.register('organisationId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -870,9 +934,17 @@ export const GrantsPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Funding Programme / Instrument *
</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Funding Programme / Instrument *
</label>
<HelpTooltip
title="Statutory Fund Instrument"
content="The national budget vote or sub-programme financing this grant award."
size="sm"
placement="top"
/>
</div>
<select
{...awardForm.register('programmeId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -887,7 +959,15 @@ export const GrantsPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Funding Period *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Funding Period *</label>
<HelpTooltip
title="Funding Duration"
content="Tenure of the grant agreement (e.g., 24 Months, 36 Months)."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...awardForm.register('fundingPeriod')}
@@ -926,10 +1006,19 @@ export const GrantsPage: React.FC = () => {
onClose={() => setIsContractModalOpen(false)}
title="Draft Contract Agreement"
subtitle="Convert approved funding decision into legally enforceable terms"
helpText="Draft a legally binding grant agreement. Once drafted, contracts can be executed with digital signature verification to lock the terms."
>
<form onSubmit={contractForm.handleSubmit((data) => contractMutation.mutate(data as any))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Select Award *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Select Award *</label>
<HelpTooltip
title="Underlying Award"
content="The approved statutory funding decision forming the legal premise for this contract."
size="sm"
placement="right"
/>
</div>
<select
{...contractForm.register('awardId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -943,7 +1032,15 @@ export const GrantsPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Contract Total Value (ZAR) *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Contract Total Value (ZAR) *</label>
<HelpTooltip
title="Total Contract Value"
content="Maximum statutory financial liability established under this contract agreement."
size="sm"
placement="right"
/>
</div>
<input
type="number"
{...contractForm.register('totalValue')}
@@ -953,7 +1050,15 @@ export const GrantsPage: React.FC = () => {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Effective Date *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Effective Date *</label>
<HelpTooltip
title="Effective Date"
content="The legal commencement date from which project activities become eligible for grant reimbursement."
size="sm"
placement="top"
/>
</div>
<input
type="date"
{...contractForm.register('effectiveAt')}
@@ -961,7 +1066,15 @@ export const GrantsPage: React.FC = () => {
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Expiry Date *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Expiry Date *</label>
<HelpTooltip
title="Expiry Date"
content="Formal termination date after which unexpended commitments lapse."
size="sm"
placement="top"
/>
</div>
<input
type="date"
{...contractForm.register('expiresAt')}
@@ -995,6 +1108,7 @@ export const GrantsPage: React.FC = () => {
onClose={() => setIsExecuteModalOpen(false)}
title="Execute Contract & Lock Agreement"
subtitle={`Locking Contract #${selectedContractForExecution?.id} as Authoritative Version`}
helpText="Electronic execution permanently locks the agreement. After execution, the contract cannot be modified, and milestone-linked disbursements can proceed."
>
<form onSubmit={executeForm.handleSubmit((data) => executeMutation.mutate(data as any))} className="space-y-4">
<div className="p-3.5 bg-slate-900/60 border border-slate-800/80 rounded-xl text-xs space-y-1 text-slate-300">
@@ -1005,9 +1119,17 @@ export const GrantsPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
External Electronic Signature ID *
</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
External Electronic Signature ID *
</label>
<HelpTooltip
title="Electronic Signature Hash"
content="Unique audit certificate or transaction ID from the signing authority (e.g. DocuSign, Adobe Sign, or government PKI)."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...executeForm.register('externalSignatureId')}
@@ -1017,7 +1139,15 @@ export const GrantsPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Signed Document URL / Vault *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Signed Document URL / Vault *</label>
<HelpTooltip
title="Authoritative Document Vault"
content="Direct secure URL or object storage path to the countersigned PDF agreement."
size="sm"
placement="right"
/>
</div>
<input
type="url"
{...executeForm.register('signedDocumentUrl')}
@@ -1050,10 +1180,19 @@ export const GrantsPage: React.FC = () => {
onClose={() => setIsBudgetModalOpen(false)}
title="Add Project Budget Line"
subtitle="Maintain approved expenditure breakdown and commitments"
helpText="Establishes financial lines for project cost categories. Ensures all disbursements are mapped to audited budget ceilings."
>
<form onSubmit={budgetForm.handleSubmit((data) => budgetMutation.mutate(data as any))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Associated Project *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Associated Project *</label>
<HelpTooltip
title="Funded Project"
content="Target project entity against which expenditure is tracked."
size="sm"
placement="right"
/>
</div>
<select
{...budgetForm.register('projectId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -1067,7 +1206,15 @@ export const GrantsPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Budget Category Name *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Budget Category Name *</label>
<HelpTooltip
title="Category Name"
content="Standard chart of accounts line (e.g. Personnel, Research Consumables, Capital Equipment)."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...budgetForm.register('name')}
@@ -1077,7 +1224,15 @@ export const GrantsPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Approved Line Amount (ZAR) *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Approved Line Amount (ZAR) *</label>
<HelpTooltip
title="Line Allocation"
content="Ceiling limit allocated for this specific cost category."
size="sm"
placement="right"
/>
</div>
<input
type="number"
{...budgetForm.register('amount')}
@@ -1117,11 +1272,20 @@ export const GrantsPage: React.FC = () => {
? `Updating payment tranche DSB-${editingDisbursement.id}`
: 'Link disbursement to milestone verification and conditions precedent'
}
helpText="Create or adjust scheduled disbursement tranches. Payment release requires verified milestone completion and positive compliance checks."
>
<form onSubmit={disbursementForm.handleSubmit((data) => disbursementMutation.mutate(data as any))} className="space-y-4">
{!editingDisbursement && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Select Project *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Select Project *</label>
<HelpTooltip
title="Target Project"
content="The project entity receiving this payment tranche."
size="sm"
placement="right"
/>
</div>
<select
{...disbursementForm.register('projectId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -1136,7 +1300,15 @@ export const GrantsPage: React.FC = () => {
)}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Linked Milestone Deliverable</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Linked Milestone Deliverable</label>
<HelpTooltip
title="Milestone Requirement"
content="If linked, the disbursement is held until the milestone is marked Approved/Verified."
size="sm"
placement="right"
/>
</div>
<select
{...disbursementForm.register('milestoneId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -1151,7 +1323,15 @@ export const GrantsPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Tranche Amount (ZAR) *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Tranche Amount (ZAR) *</label>
<HelpTooltip
title="Tranche Quantum"
content="Exact currency value to be transferred upon authorization."
size="sm"
placement="right"
/>
</div>
<input
type="number"
{...disbursementForm.register('amount')}
@@ -37,7 +37,9 @@ import {
getAllMilestones,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast';
import { ContractsManagement } from '../components/monitoring/ContractsManagement';
import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix';
import {
Indicator,
@@ -87,7 +89,8 @@ export const MonitoringPage: React.FC = () => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const [activeTab, setActiveTab] = useState<'indicators' | 'evidence' | 'risks'>('evidence');
const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>('contracts');
const [isCreateContractModalOpen, setIsCreateContractModalOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
// Modals & States
@@ -98,7 +101,7 @@ export const MonitoringPage: React.FC = () => {
const [actualInput, setActualInput] = useState<number>(0);
const [uploadEvidenceType, setUploadEvidenceType] = useState<number>(EvidenceType.CompletionCertificate);
// Figure 10 Reviewer commentary
// Reviewer commentary
const [reviewerComment, setReviewerComment] = useState('');
const [selectedMilestoneEvidenceFilter, setSelectedMilestoneEvidenceFilter] = useState<string | number | 'all'>('all');
@@ -185,7 +188,7 @@ export const MonitoringPage: React.FC = () => {
mutationFn: (formData: FormData) => uploadEvidenceFile(formData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['evidence'] });
success('Evidence uploaded', 'Supporting file registered for reviewer inspection (Figure 10 / M14)');
success('Evidence uploaded', 'Supporting file registered for reviewer inspection.');
},
onError: (err: unknown) => {
toastError('Upload failed', err);
@@ -302,7 +305,7 @@ export const MonitoringPage: React.FC = () => {
}
};
// Indicator thresholds from SRS 9.6:
// Performance indicator thresholds:
// Green >= 90%, Amber 70-89%, Red < 70%
const getIndicatorStatus = (actual: number, target: number) => {
if (!target) return { label: 'Pending', badge: 'bg-slate-800/60 text-slate-400 border-slate-700/60' };
@@ -318,7 +321,12 @@ export const MonitoringPage: React.FC = () => {
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl">
<div>
<h2 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
M&E, Evidence & Risk Management
<span>M&E, Evidence & Risk Management</span>
<HelpTooltip
title="Monitoring & Evaluation Hub"
content="Track performance indicators, verify tangible deliverable evidence against statutory milestones, and maintain proactive risk mitigation plans."
placement="right"
/>
</h2>
<p className="text-xs text-slate-400 mt-1">
Verification workflow: Evidence Upload & Review Queue, Indicator Actuals Recording, and Risk Register.
@@ -326,10 +334,19 @@ export const MonitoringPage: React.FC = () => {
</div>
<div className="flex items-center gap-2">
{activeTab === 'contracts' && (
<button
onClick={() => setIsCreateContractModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2 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-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Create Contract & Upload PDF
</button>
)}
{activeTab === 'indicators' && (
<button
onClick={() => setIsIndicatorModalOpen(true)}
className="inline-flex items-center gap-1.5 px-4 py-2 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-xl shadow-lg shadow-blue-500/20 transition"
className="inline-flex items-center gap-1.5 px-4 py-2 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-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Define Indicator
@@ -338,7 +355,7 @@ export const MonitoringPage: React.FC = () => {
{activeTab === 'risks' && (
<button
onClick={openCreateRisk}
className="inline-flex items-center gap-1.5 px-4 py-2 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-xl shadow-lg shadow-blue-500/20 transition"
className="inline-flex items-center gap-1.5 px-4 py-2 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-xl shadow-lg shadow-blue-500/20 transition cursor-pointer"
>
<Plus className="w-4 h-4" />
Add Risk to Register
@@ -349,6 +366,23 @@ export const MonitoringPage: React.FC = () => {
{/* Tabs */}
<div className="flex gap-2 border-b border-slate-800/80 pb-3 overflow-x-auto">
<button
onClick={() => setActiveTab('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'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<FileText className="w-4 h-4" />
<span>Contracts & PDF Documents</span>
<HelpTooltip
title="Executed Contracts & Vault"
content="Digitally executed bilateral agreements, legal riders, and authoritative PDF repository."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => setActiveTab('evidence')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 ${
@@ -358,7 +392,13 @@ export const MonitoringPage: React.FC = () => {
}`}
>
<Paperclip className="w-4 h-4" />
Milestone Evidence & Review Queue
<span>Milestone Evidence & Review Queue</span>
<HelpTooltip
title="Verification Workflow"
content="Evidence submission, file validation, and peer review sign-off gating milestone payments."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => setActiveTab('indicators')}
@@ -369,7 +409,13 @@ export const MonitoringPage: React.FC = () => {
}`}
>
<TrendingUp className="w-4 h-4" />
Indicators & Actuals ({indicators.length})
<span>Indicators & Actuals ({indicators.length})</span>
<HelpTooltip
title="Quantitative Indicators"
content="Baseline vs. target metric progression with automated green/amber/red performance thresholds."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => setActiveTab('risks')}
@@ -380,14 +426,28 @@ export const MonitoringPage: React.FC = () => {
}`}
>
<ShieldAlert className="w-4 h-4" />
Risk Register & Heatmap ({risks.length})
<span>Risk Register & Heatmap ({risks.length})</span>
<HelpTooltip
title="Risk Register & Heatmap"
content="5x5 severity matrix scoring likelihood against impact to safeguard public grant investments."
size="sm"
placement="top"
/>
</button>
</div>
{/* Tab 1: Milestone Evidence Submission & Review (Exact Implementation of Figure 10) */}
{/* Tab 0: Contracts & Legal Documents with PDF Upload */}
{activeTab === 'contracts' && (
<ContractsManagement
isCreateModalOpenExternal={isCreateContractModalOpen}
onCloseCreateModalExternal={() => setIsCreateContractModalOpen(false)}
/>
)}
{/* Tab 1: Milestone Evidence Submission & Review */}
{activeTab === 'evidence' && (
<div className="space-y-6">
{/* Wireframe Container from SRS Figure 10 */}
{/* Milestone Evidence Review Container */}
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
{/* Header Bar */}
<div className="bg-slate-900/90 text-white px-6 py-4 flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-slate-800/80">
@@ -423,7 +483,7 @@ export const MonitoringPage: React.FC = () => {
</div>
</div>
{/* Upload Evidence & Attached Evidence Panels (Matching Figure 10) */}
{/* Upload Evidence & Attached Evidence Panels */}
<div className="grid grid-cols-1 md:grid-cols-2 divide-y md:divide-y-0 md:divide-x divide-slate-800/80">
{/* Upload Box */}
<div className="p-6 space-y-4">
@@ -545,7 +605,7 @@ export const MonitoringPage: React.FC = () => {
</div>
</div>
{/* Programme Manager Review Section (Figure 10) */}
{/* Programme Manager Review Section */}
<div className="p-6 border-t border-slate-800/80 bg-slate-900/40 space-y-4">
<h4 className="text-sm font-bold text-white">Programme Manager Review</h4>
@@ -765,10 +825,19 @@ export const MonitoringPage: React.FC = () => {
onClose={() => setIsIndicatorModalOpen(false)}
title="Define M&E Indicator"
subtitle="Logical Framework & Theory of Change metric"
helpText="Quantitative or qualitative KPI tracked across reporting periods against target thresholds to assess project efficacy."
>
<form onSubmit={indicatorForm.handleSubmit((data) => indicatorMutation.mutate(data as any))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Project *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Project *</label>
<HelpTooltip
title="Target Project"
content="The project against which this indicator is tracked."
size="sm"
placement="right"
/>
</div>
<select
{...indicatorForm.register('projectId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -782,7 +851,15 @@ export const MonitoringPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Indicator Statement *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Indicator Statement *</label>
<HelpTooltip
title="Measurement Statement"
content="Precise description of the result or output being counted or verified."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...indicatorForm.register('name')}
@@ -793,7 +870,15 @@ export const MonitoringPage: React.FC = () => {
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Unit of Measure *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Unit of Measure *</label>
<HelpTooltip
title="Metric Dimension"
content="Counting unit (e.g. Number, Percentage, Currency ZAR, Rating Score)."
size="sm"
placement="top"
/>
</div>
<select
{...indicatorForm.register('unitOfMeasure')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -807,7 +892,15 @@ export const MonitoringPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Baseline Value *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Baseline Value *</label>
<HelpTooltip
title="Pre-grant Baseline"
content="Observed initial starting value prior to project intervention."
size="sm"
placement="top"
/>
</div>
<input
type="number"
{...indicatorForm.register('baselineAmount')}
@@ -816,7 +909,15 @@ export const MonitoringPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Target Value *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Target Value *</label>
<HelpTooltip
title="Target Benchmark"
content="Contracted goal to achieve before contract completion."
size="sm"
placement="top"
/>
</div>
<input
type="number"
{...indicatorForm.register('targetAmount')}
@@ -850,6 +951,7 @@ export const MonitoringPage: React.FC = () => {
onClose={() => setSelectedIndicatorForActual(null)}
title="Record Indicator Actual Value"
subtitle={selectedIndicatorForActual?.name || ''}
helpText="Logs a certified field or audited measurement against the target, recalculating the achievement percentage and RAG rating."
>
<div className="space-y-4">
<div className="p-3.5 bg-slate-900/60 border border-slate-800/80 rounded-xl text-xs flex justify-between text-slate-300">
@@ -858,7 +960,15 @@ export const MonitoringPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Actual Value to Record *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Actual Value to Record *</label>
<HelpTooltip
title="Monitored Measurement"
content="Current realized value for this reporting period. Automatically updates Green (>=90%), Amber (70-89%), or Red (<70%) status."
size="sm"
placement="right"
/>
</div>
<input
type="number"
value={actualInput}
@@ -902,11 +1012,20 @@ export const MonitoringPage: React.FC = () => {
}}
title={editingRisk ? 'Edit Project Risk' : 'Add Risk to Risk Register'}
subtitle={editingRisk ? `Updating risk item #${editingRisk.id}` : 'Identify and score project delivery risks'}
helpText="Risks are scored by Likelihood (1-5) x Impact (1-5) to plot on the 5x5 Heatmap. High severity risks flag alerts for portfolio governance."
>
<form onSubmit={riskForm.handleSubmit((data) => riskMutation.mutate(data as any))} className="space-y-4">
{!editingRisk && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Associated Project *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Associated Project *</label>
<HelpTooltip
title="Context Project"
content="The project directly exposed to this potential threat or operational hazard."
size="sm"
placement="right"
/>
</div>
<select
{...riskForm.register('projectId')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -921,7 +1040,15 @@ export const MonitoringPage: React.FC = () => {
)}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Risk Title *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Risk Title *</label>
<HelpTooltip
title="Event Name"
content="Succinct identifier of the uncertainty (e.g. Key Personnel Departure, Import Tariff Hike)."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...riskForm.register('name')}
@@ -932,7 +1059,15 @@ export const MonitoringPage: React.FC = () => {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Likelihood (1–5) *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Likelihood (1–5) *</label>
<HelpTooltip
title="Occurrence Probability"
content="Rated 1 (Rare) through 5 (Almost Certain)."
size="sm"
placement="top"
/>
</div>
<select
{...riskForm.register('likelihood')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -946,7 +1081,15 @@ export const MonitoringPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Impact (1–5) *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Impact (1–5) *</label>
<HelpTooltip
title="Consequence Severity"
content="Rated 1 (Insignificant) through 5 (Catastrophic to schedule/budget)."
size="sm"
placement="top"
/>
</div>
<select
{...riskForm.register('impact')}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition [&>option]:bg-slate-900 [&>option]:text-white"
@@ -961,7 +1104,15 @@ export const MonitoringPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">Risk Description *</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">Risk Description *</label>
<HelpTooltip
title="Root Cause & Mitigation"
content="Detail underlying triggers, preventative actions, and contingency response steps."
size="sm"
placement="right"
/>
</div>
<textarea
rows={2}
{...riskForm.register('description')}
@@ -39,6 +39,7 @@ import {
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { useToast } from '../components/ui/Toast';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import {
Project,
Activity,
@@ -383,7 +384,7 @@ export const ProjectsPage: React.FC = () => {
},
});
// Calculate project progress based on milestones (FR-M04-018)
// Calculate project progress percentage based on completed milestones
const completedMilestones = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneProgress = milestones.length > 0 ? Math.round((completedMilestones / milestones.length) * 100) : 0;
@@ -396,6 +397,12 @@ export const ProjectsPage: React.FC = () => {
<h2 className="text-xl font-bold text-white tracking-tight flex items-center gap-2">
Project & Milestone Management
</h2>
<HelpTooltip
title="Work Breakdown Structure (WBS)"
content="Manage operational activities, verified contractual milestone deliverables, risk registers, and M&E performance indicator targets."
size="sm"
placement="bottom"
/>
<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">
Work Breakdown
</span>
@@ -418,21 +425,31 @@ export const ProjectsPage: React.FC = () => {
{/* Left Column: Project Directory List */}
<div className="lg:col-span-5 space-y-3">
<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})
</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 className="flex items-center gap-1.5">
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider">
Projects Directory ({projects.length})
</h3>
<HelpTooltip
title="Project Registry"
content="Select any funded project to inspect its work breakdown structure, contractual deliverables, progress score, and M&E indicators."
size="sm"
placement="bottom"
/>
</div>
<div className="flex items-center gap-1">
<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>
</div>
{loadingProjects ? (
@@ -513,7 +530,15 @@ export const ProjectsPage: React.FC = () => {
Edit Project
</button>
<div>
<label className="text-[10px] uppercase font-bold text-slate-400 block mb-1">Approval Status</label>
<div className="flex items-center gap-1 mb-1">
<label className="text-[10px] uppercase font-bold text-slate-400 block">Approval Status</label>
<HelpTooltip
title="Governance Approval Status"
content="Current statutory clearance stage of the project: Draft, In Review, Approved, or Active."
size="sm"
placement="bottom"
/>
</div>
<select
value={selectedProject.status}
onChange={(e) =>
@@ -534,12 +559,18 @@ export const ProjectsPage: React.FC = () => {
</div>
</div>
{/* Progress bar (FR-M04-018: calculated from approved milestones) */}
{/* Progress bar calculated from approved milestones */}
<div className="mt-5 pt-4 border-t border-slate-800/80">
<div className="flex items-center justify-between text-xs mb-1.5">
<span className="font-semibold text-slate-300 flex items-center gap-1.5">
<TrendingUp className="w-3.5 h-3.5 text-blue-400" />
Calculated Progress (Milestone-based)
<HelpTooltip
title="Calculated Progress Formula"
content="Computed dynamically as (Verified & Approved Milestones / Total Milestones) × 100%."
size="sm"
placement="top"
/>
</span>
<span className="font-bold text-blue-400">{milestoneProgress}%</span>
</div>
@@ -610,7 +641,15 @@ export const ProjectsPage: React.FC = () => {
{detailTab === 'activities' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-300">Project Work Breakdown</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-bold text-slate-300">Project Work Breakdown</span>
<HelpTooltip
title="Work Breakdown Structure"
content="Granular operational tasks executed under this project. Activities can optionally be linked to contractual milestones."
size="sm"
placement="bottom"
/>
</div>
<button
onClick={openCreateActivity}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-3 py-1 rounded-xl transition cursor-pointer"
@@ -671,7 +710,15 @@ export const ProjectsPage: React.FC = () => {
{detailTab === 'milestones' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-xs font-bold text-slate-300">Contractual Milestones</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-bold text-slate-300">Contractual Milestones</span>
<HelpTooltip
title="Contractual Milestones"
content="Statutory deliverables tied to legal grant agreements. Verified completion triggers tranche disbursement releases."
size="sm"
placement="bottom"
/>
</div>
<button
onClick={openCreateMilestone}
className="inline-flex items-center gap-1 text-xs font-semibold text-blue-400 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 px-3 py-1 rounded-xl transition cursor-pointer"
@@ -728,7 +775,15 @@ export const ProjectsPage: React.FC = () => {
{/* Indicators Sub-tab */}
{detailTab === 'indicators' && (
<div className="space-y-3">
<span className="text-xs font-bold text-slate-300 block">Assigned Project Indicators</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-bold text-slate-300 block">Assigned Project Indicators</span>
<HelpTooltip
title="M&E Indicator Tracking"
content="Monitors quantitative performance targets (e.g. jobs created, patents filed, rural SMMEs trained) against baseline values."
size="sm"
placement="bottom"
/>
</div>
{indicators.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No indicators attached.</p>
) : (
@@ -755,7 +810,15 @@ export const ProjectsPage: React.FC = () => {
{/* Risks Sub-tab */}
{detailTab === 'risks' && (
<div className="space-y-3">
<span className="text-xs font-bold text-slate-300 block">Project Risk Register (M09)</span>
<div className="flex items-center gap-1.5">
<span className="text-xs font-bold text-slate-300 block">Project Risk Register (M09)</span>
<HelpTooltip
title="ISO 31000 Risk Register"
content="Assesses specific operational risks by Likelihood (1-5) and Consequence/Impact (1-5), specifying mitigation commitments."
size="sm"
placement="bottom"
/>
</div>
{risks.length === 0 ? (
<p className="text-xs text-slate-500 py-4 text-center">No project risks logged.</p>
) : (
@@ -799,10 +862,19 @@ export const ProjectsPage: React.FC = () => {
}}
title={editingProject ? 'Edit Funded Project' : 'Create Funded Project'}
subtitle={editingProject ? `Updating project #${editingProject.id}` : 'Operational entity under an approved Programme'}
helpText="Register or modify a funded project. Ensure the correct parent programme and industrial sector are assigned to preserve statutory reporting compliance."
>
<form onSubmit={projectForm.handleSubmit((data) => projectMutation.mutate(data as any))} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Project Name *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Project Name *</label>
<HelpTooltip
title="Project Title"
content="Clear, unique operational project title corresponding to the legal agreement."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...projectForm.register('name')}
@@ -816,7 +888,15 @@ export const ProjectsPage: React.FC = () => {
{!editingProject && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Parent Programme *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Parent Programme *</label>
<HelpTooltip
title="Parent Programme"
content="The national statutory funding vehicle financing this initiative."
size="sm"
placement="right"
/>
</div>
<select
{...projectForm.register('programmeId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
@@ -832,7 +912,15 @@ export const ProjectsPage: React.FC = () => {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Industry Sector *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Industry Sector *</label>
<HelpTooltip
title="Industrial Sector"
content="Primary industrial or economic sector for national cluster analytics."
size="sm"
placement="top"
/>
</div>
<select
{...projectForm.register('sector')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
@@ -846,7 +934,15 @@ export const ProjectsPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Project Type *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Project Type *</label>
<HelpTooltip
title="Project Type"
content="Category of execution: Grant, Commercialisation, Infrastructure, or Research."
size="sm"
placement="top"
/>
</div>
<select
{...projectForm.register('projectType')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
@@ -901,13 +997,22 @@ export const ProjectsPage: React.FC = () => {
}}
title={editingActivity ? 'Edit Activity in Implementation Plan' : 'Add Activity to Implementation Plan'}
subtitle={editingActivity ? `Updating activity #${editingActivity.id}` : `Registering activity under Project #${activeProjectId}`}
helpText="Define operational tasks and assign them to contractual milestones. The allocated budget will be tracked against expenditure categories."
>
<form
onSubmit={activityForm.handleSubmit((data) => activityMutation.mutate(data as any))}
className="space-y-4"
>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Activity Title *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Activity Title *</label>
<HelpTooltip
title="Activity Title"
content="Concise description of the specific work task to be undertaken by the project team."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...activityForm.register('title')}
@@ -916,11 +1021,19 @@ export const ProjectsPage: React.FC = () => {
/>
</div>
{/* Associated Milestone Dropdown (FR: Passing milestoneId to PUT/POST request) */}
{/* Associated Milestone Dropdown */}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Associated Milestone (Deliverable Link)
</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">
Associated Milestone (Deliverable Link)
</label>
<HelpTooltip
title="Milestone Linkage"
content="Optionally bind this activity as a work package that contributes to a contractual milestone deliverable."
size="sm"
placement="right"
/>
</div>
<select
{...activityForm.register('milestoneId')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
@@ -936,7 +1049,15 @@ export const ProjectsPage: React.FC = () => {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Budget Allocation (ZAR) *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Budget Allocation (ZAR) *</label>
<HelpTooltip
title="Activity Budget"
content="Estimated expenditure or cost required to complete this activity."
size="sm"
placement="top"
/>
</div>
<input
type="number"
{...activityForm.register('amount')}
@@ -944,7 +1065,15 @@ export const ProjectsPage: React.FC = () => {
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Cost Category *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Cost Category *</label>
<HelpTooltip
title="Cost Category"
content="Budget line category (e.g. Capital Equipment, Personnel, Field Trials, Operational)."
size="sm"
placement="top"
/>
</div>
<input
type="text"
{...activityForm.register('category')}
@@ -994,13 +1123,22 @@ export const ProjectsPage: React.FC = () => {
}}
title={editingMilestone ? 'Edit Contractual Milestone' : 'Define Contractual Milestone'}
subtitle={editingMilestone ? `Updating milestone #${editingMilestone.id}` : `Deliverable schedule for Project #${activeProjectId}`}
helpText="Contractual milestones represent formal deliverables specified in the grant contract. Upon verification and approval, they increment the calculated project progress score and unblock disbursement releases."
>
<form
onSubmit={milestoneForm.handleSubmit((data) => milestoneMutation.mutate(data as any))}
className="space-y-4"
>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Milestone Deliverable Name *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Milestone Deliverable Name *</label>
<HelpTooltip
title="Deliverable Title"
content="Title of the agreed milestone (e.g. Inception Report, Working Prototype, Pilot Demonstration)."
size="sm"
placement="right"
/>
</div>
<input
type="text"
{...milestoneForm.register('name')}
@@ -1011,7 +1149,15 @@ export const ProjectsPage: React.FC = () => {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Contractual Due Date *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Contractual Due Date *</label>
<HelpTooltip
title="Due Date"
content="Contractual cutoff deadline by which evidence must be submitted for review."
size="sm"
placement="top"
/>
</div>
<input
type="date"
{...milestoneForm.register('dueAt')}
@@ -1019,7 +1165,15 @@ export const ProjectsPage: React.FC = () => {
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Priority Level *</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Priority Level *</label>
<HelpTooltip
title="Milestone Priority"
content="Criticality of the milestone: Low, Medium, High, or Critical path."
size="sm"
placement="top"
/>
</div>
<select
{...milestoneForm.register('priority')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
@@ -1035,7 +1189,15 @@ export const ProjectsPage: React.FC = () => {
{editingMilestone && (
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Approval / Verification Status</label>
<div className="flex items-center gap-1 mb-1">
<label className="block text-xs font-semibold text-slate-300">Approval / Verification Status</label>
<HelpTooltip
title="Verification Status"
content="Only Approved/Verified milestones contribute to the calculated progress percentage."
size="sm"
placement="right"
/>
</div>
<select
{...milestoneForm.register('status')}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
@@ -22,7 +22,6 @@ import {
AlertCircle,
} from 'lucide-react';
import {
getAuditLogs,
getUsers,
getUser,
updateUserStatus,
@@ -31,12 +30,14 @@ import {
getUserSyncRecord,
getInvites,
sendInvite,
getApplicantInvite,
getApplicantInvites,
getOrganisations,
getAwards,
getContracts,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
import { useToast } from '../components/ui/Toast';
import { useAuth } from '../auth/AuthProvider';
import { getAppConfig } from '../config';
@@ -80,7 +81,7 @@ export const SettingsPage: React.FC = () => {
const { user } = useAuth();
const { success, error: toastError } = useToast();
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit' | 'profile'>('users');
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'sync' | 'profile'>('users');
// ==========================================
// 1. User Directory State
@@ -100,6 +101,12 @@ export const SettingsPage: React.FC = () => {
queryFn: () => getUsers(1, 100, userFilter),
});
const { data: userFreshDetail, isLoading: loadingUserFreshDetail } = useQuery({
queryKey: ['user-canonical', selectedUserForDetail?.id],
queryFn: () => (selectedUserForDetail?.id ? getUser(selectedUserForDetail.id) : null),
enabled: !!selectedUserForDetail?.id && isUserDetailModalOpen,
});
const syncMutation = useMutation({
mutationFn: () => syncUser(undefined, true),
onSuccess: () => {
@@ -111,6 +118,17 @@ export const SettingsPage: React.FC = () => {
},
});
const syncIntegrationMutation = useMutation({
mutationFn: () => syncIntegration(true),
onSuccess: () => {
success('Backend Integration Synced', 'Direct POST /v1/api/integration/sync executed successfully.');
queryClient.invalidateQueries({ queryKey: ['users'] });
},
onError: (err: any) => {
toastError('Integration Sync Failed', err);
},
});
const updateUserStatusMutation = useMutation({
mutationFn: ({ userId, status }: { userId: string; status: ActivityStatus }) =>
updateUserStatus(userId, status),
@@ -177,8 +195,8 @@ export const SettingsPage: React.FC = () => {
},
});
const handleSearchApplicantInvites = async (e: React.FormEvent) => {
e.preventDefault();
const handleSearchApplicantInvites = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!applicantSearchId.trim()) return;
try {
setSearchingApplicant(true);
@@ -193,8 +211,23 @@ export const SettingsPage: React.FC = () => {
}
};
const handleLookupSingleApplicantInvite = async () => {
if (!applicantSearchId.trim()) return;
try {
setSearchingApplicant(true);
const res = await getApplicantInvite(applicantSearchId.trim());
setApplicantInvitesResults(res ? [res] : []);
success('Primary Invite Found', `Retrieved primary invitation for applicant.`);
} catch (err: any) {
toastError('Lookup Error', err);
setApplicantInvitesResults([]);
} finally {
setSearchingApplicant(false);
}
};
// ==========================================
// 3. Email Gateway & Dispatch Simulation
// 3. Email Gateway & Dispatch
// ==========================================
const [emailGatewayStatus, setEmailGatewayStatus] = useState<EmailStatuses>(EmailStatuses.Connected);
const [isSendNotificationModalOpen, setIsSendNotificationModalOpen] = useState(false);
@@ -204,21 +237,18 @@ export const SettingsPage: React.FC = () => {
const [dispatchSubject, setDispatchSubject] = useState('');
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
// ==========================================
// 4. Statutory Audit Trail
// ==========================================
const { data: auditLogs = [], isLoading: loadingAudit } = useQuery({
queryKey: ['auditLogs'],
queryFn: () => getAuditLogs(),
});
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl">
<div>
<h2 className="text-2xl font-bold text-white tracking-tight">
System Administration & Communications
<h2 className="text-2xl font-bold text-white tracking-tight flex items-center gap-2">
<span>System Administration & Communications</span>
<HelpTooltip
title="System Administration & Audit"
content="Manage platform security, active user directory, external gateway sync, communications dispatch, and verified OIDC claims."
placement="right"
/>
</h2>
<p className="text-xs text-slate-400 mt-1">
User access directory, statutory invitations, background integration sync, and audit ledger.
@@ -230,7 +260,7 @@ export const SettingsPage: React.FC = () => {
<div className="flex items-center gap-2">
<span
className="hidden sm:inline-flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-[11px] font-medium bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
title={getUserSyncRecord() ? `Endpoint: ${getUserSyncRecord()?.syncedEndpoint || '/v1/api/users/sync'} (Synced in browser)` : 'Sync active in browser'}
title={getUserSyncRecord() ? `Endpoint: ${getUserSyncRecord()?.syncedEndpoint || '/v1/api/integration/sync'} (Synced in browser)` : 'Sync active in browser'}
>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
Browser Sync Saved
@@ -280,7 +310,13 @@ export const SettingsPage: React.FC = () => {
}`}
>
<Users className="w-4 h-4" />
User Directory ({usersList.length})
<span>User Directory ({usersList.length})</span>
<HelpTooltip
title="User Directory"
content="Authorized system users, role memberships, and account lifecycle states."
size="sm"
placement="top"
/>
</button>
<button
@@ -292,7 +328,13 @@ export const SettingsPage: React.FC = () => {
}`}
>
<Mail className="w-4 h-4" />
Communications & Invites ({invitesList.length})
<span>Communications & Invites ({invitesList.length})</span>
<HelpTooltip
title="Communications Dispatch"
content="Statutory onboarding invitations and applicant notification ledger."
size="sm"
placement="top"
/>
</button>
<button
@@ -304,19 +346,31 @@ export const SettingsPage: React.FC = () => {
}`}
>
<Bell className="w-4 h-4" />
Gateways & Notifications
<span>Gateways & Notifications</span>
<HelpTooltip
title="Notification Gateways"
content="Real-time status of email dispatch SMTP engines, SMS gateways, and broadcast logs."
size="sm"
placement="top"
/>
</button>
<button
onClick={() => setActiveTab('audit')}
onClick={() => setActiveTab('sync')}
className={`px-4 py-2 text-xs font-medium rounded-xl transition flex items-center gap-2 whitespace-nowrap cursor-pointer ${
activeTab === 'audit'
activeTab === 'sync'
? 'bg-blue-600 text-white shadow-lg shadow-blue-600/30'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<History className="w-4 h-4" />
Statutory Audit Trail ({auditLogs.length})
<RefreshCw className="w-4 h-4" />
<span>Integration & Sync Engine</span>
<HelpTooltip
title="Integration Engine"
content="Backend synchronization bridge maintaining parity with external enterprise systems."
size="sm"
placement="top"
/>
</button>
<button
@@ -328,7 +382,13 @@ export const SettingsPage: React.FC = () => {
}`}
>
<UserCheck className="w-4 h-4" />
Session & Claims
<span>Session & Claims</span>
<HelpTooltip
title="OIDC Security Tokens"
content="Current authenticated session attributes, claims scope, and cryptographic token expiration."
size="sm"
placement="top"
/>
</button>
</div>
@@ -492,9 +552,19 @@ export const SettingsPage: React.FC = () => {
<button
type="submit"
disabled={searchingApplicant || !applicantSearchId.trim()}
className="px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer disabled:opacity-50"
className="px-3.5 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl transition cursor-pointer disabled:opacity-50"
title="Lookup all invites (GET /v1/api/communications/invites/{applicantUserId})"
>
{searchingApplicant ? 'Searching...' : 'Lookup Invites'}
{searchingApplicant ? 'Searching...' : 'All Invites'}
</button>
<button
type="button"
onClick={handleLookupSingleApplicantInvite}
disabled={searchingApplicant || !applicantSearchId.trim()}
className="px-3.5 py-2 text-xs font-semibold text-blue-300 bg-blue-500/10 hover:bg-blue-500/20 border border-blue-500/30 rounded-xl transition cursor-pointer disabled:opacity-50"
title="Lookup primary invite (GET /v1/api/communications/invites/applicant/{applicantUserId})"
>
Primary Invite
</button>
</form>
@@ -642,49 +712,80 @@ export const SettingsPage: React.FC = () => {
)}
{/* ============================================================== */}
{/* TAB 4: Statutory Audit Trail */}
{/* TAB 4: Integration & Sync Engine (POST /v1/api/integration/sync) */}
{/* ============================================================== */}
{activeTab === 'audit' && (
<div className="bg-[#0f1523]/80 backdrop-blur-md rounded-2xl border border-slate-800/80 shadow-xl overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<th className="py-3.5 px-5">Audit ID</th>
<th className="py-3.5 px-4">Entity</th>
<th className="py-3.5 px-4">Action</th>
<th className="py-3.5 px-4">Performed By</th>
<th className="py-3.5 px-5 text-right">Timestamp</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/50">
{loadingAudit ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
Loading audit logs...
</td>
</tr>
) : auditLogs.length === 0 ? (
<tr>
<td colSpan={5} className="py-8 text-center text-slate-500">
No audit events recorded.
</td>
</tr>
) : (
auditLogs.map((log) => (
<tr key={log.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 font-mono text-slate-300">LOG-{log.id}</td>
<td className="py-3.5 px-4 text-white font-medium">{log.entityType} #{log.entityId}</td>
<td className="py-3.5 px-4 text-blue-400 font-mono">{log.action}</td>
<td className="py-3.5 px-4 text-slate-300 font-mono text-[11px]">{log.userEmail}</td>
<td className="py-3.5 px-5 text-right text-slate-400 font-mono text-[11px]">
{formatDate(log.createdAt)}
</td>
</tr>
))
)}
</tbody>
</table>
{activeTab === 'sync' && (
<div className="space-y-4">
<div className="bg-[#0f1523]/80 backdrop-blur-md p-6 rounded-2xl border border-slate-800/80 shadow-xl space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h3 className="text-sm font-bold text-white flex items-center gap-2">
<RefreshCw className="w-4 h-4 text-blue-400" />
Backend Integration Sync Engine (POST /v1/api/integration/sync)
</h3>
<p className="text-xs text-slate-400 mt-1 max-w-2xl leading-relaxed">
The integration sync endpoint provisions and synchronizes authenticated accounts with the backend security directory. To avoid hotspotting the backend during frequent navigation, synchronizations are locally persisted in the browser and refreshed on demand or when roles update.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => syncIntegrationMutation.mutate()}
disabled={syncIntegrationMutation.isPending}
className="inline-flex items-center gap-2 px-4 py-2 text-xs font-semibold text-white bg-blue-600 hover:bg-blue-500 rounded-xl shadow-lg shadow-blue-500/20 transition cursor-pointer disabled:opacity-50"
title="Direct POST /v1/api/integration/sync call"
>
<RefreshCw className={`w-3.5 h-3.5 ${syncIntegrationMutation.isPending ? 'animate-spin' : ''}`} />
{syncIntegrationMutation.isPending ? 'Syncing...' : 'Trigger Integration Sync'}
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">
Browser Cache Status
</span>
<div className="text-xs space-y-1.5 font-mono">
<div>
<span className="text-slate-500">Synced in Browser:</span>{' '}
<span className={getUserSyncRecord() ? 'text-emerald-400 font-bold' : 'text-amber-400'}>
{getUserSyncRecord() ? 'Yes (Cached & Active)' : 'Pending Sync'}
</span>
</div>
<div>
<span className="text-slate-500">Synced User Subject:</span>{' '}
<span className="text-white">{getUserSyncRecord()?.sub || user?.id || 'Not recorded'}</span>
</div>
<div>
<span className="text-slate-500">Last Synced Timestamp:</span>{' '}
<span className="text-slate-300">
{getUserSyncRecord()?.syncedAt ? formatDate(new Date(getUserSyncRecord()!.syncedAt).toISOString()) : 'N/A'}
</span>
</div>
</div>
</div>
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<span className="text-[11px] font-semibold text-slate-400 uppercase tracking-wider block">
OpenAPI Target Route
</span>
<div className="text-xs space-y-1.5 font-mono">
<div>
<span className="text-slate-500">HTTP Method:</span> <span className="text-emerald-400 font-bold">POST</span>
</div>
<div>
<span className="text-slate-500">Endpoint Path:</span>{' '}
<span className="text-cyan-400">/v1/api/integration/sync</span>
</div>
<div>
<span className="text-slate-500">Execution Target:</span>{' '}
<span className="text-white">Authoritative User Directory Synchronization</span>
</div>
</div>
</div>
</div>
</div>
</div>
)}
@@ -805,14 +906,14 @@ export const SettingsPage: React.FC = () => {
</div>
<button
type="button"
id="simulate-401-btn"
id="test-401-btn"
onClick={() => {
triggerSessionExpired('Simulated HTTP 401: Security token expired on business endpoint during active session test.');
triggerSessionExpired('Security token expired on business endpoint during active session test (HTTP 401).');
}}
className="inline-flex items-center gap-2 px-3.5 py-2 text-xs font-semibold text-amber-300 bg-amber-500/10 hover:bg-amber-500/20 border border-amber-500/30 rounded-xl transition-all shadow-md shrink-0"
>
<AlertCircle className="w-3.5 h-3.5 text-amber-400" />
Simulate HTTP 401 Session Expiry
Test HTTP 401 Session Expiry
</button>
</div>
</div>
@@ -826,6 +927,8 @@ export const SettingsPage: React.FC = () => {
isOpen={isSendInviteModalOpen}
onClose={() => setIsSendInviteModalOpen(false)}
title="Dispatch Statutory Invitation"
subtitle="Onboard enterprise entities, project PIs, or external reviewers"
helpText="Generates cryptographic invitations linked to organizations, awards, or contracts to initiate formal onboarding workflows."
>
<form
onSubmit={(e) => {
@@ -847,9 +950,17 @@ export const SettingsPage: React.FC = () => {
className="space-y-4"
>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Recipient User UUID *
</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Recipient User UUID *
</label>
<HelpTooltip
title="Recipient UUID"
content="Unique directory user identifier or email for targeted delivery."
size="sm"
placement="right"
/>
</div>
<input
type="text"
value={inviteRecipientUuid}
@@ -862,9 +973,17 @@ export const SettingsPage: React.FC = () => {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Dispatch Platform *
</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Dispatch Platform *
</label>
<HelpTooltip
title="Channel Protocol"
content="Select delivery transport: Email SMTP, In-App Dispatch, or SMS alert."
size="sm"
placement="top"
/>
</div>
<select
value={invitePlatform}
onChange={(e) => setInvitePlatform(Number(e.target.value) as NotificationPlatform)}
@@ -879,9 +998,17 @@ export const SettingsPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Originating Organisation
</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Originating Organisation
</label>
<HelpTooltip
title="Inviting Authority"
content="Statutory entity under whose jurisdiction this invitation is issued."
size="sm"
placement="top"
/>
</div>
<select
value={inviteOrgId}
onChange={(e) => setInviteOrgId(e.target.value)}
@@ -898,9 +1025,17 @@ export const SettingsPage: React.FC = () => {
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Associated Award
</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Associated Award
</label>
<HelpTooltip
title="Linked Grant Award"
content="Optional award attachment to link recipient permissions directly to grant administration."
size="sm"
placement="top"
/>
</div>
<select
value={inviteAwardId}
onChange={(e) => setInviteAwardId(e.target.value)}
@@ -915,9 +1050,17 @@ export const SettingsPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Associated Contract
</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Associated Contract
</label>
<HelpTooltip
title="Linked Contract"
content="Optional bilateral agreement linking the recipient to contract execution."
size="sm"
placement="top"
/>
</div>
<select
value={inviteContractId}
onChange={(e) => setInviteContractId(e.target.value)}
@@ -933,9 +1076,17 @@ export const SettingsPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
Invitation Message *
</label>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Invitation Message *
</label>
<HelpTooltip
title="Instructional Body"
content="Clear instructions and statutory obligations displayed to the invitee upon opening."
size="sm"
placement="right"
/>
</div>
<textarea
rows={3}
value={inviteMessage}
@@ -975,30 +1126,41 @@ export const SettingsPage: React.FC = () => {
>
{selectedUserForDetail && (
<div className="space-y-4 text-xs">
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Email:</span>
<span className="text-white font-semibold text-xs">{selectedUserForDetail.email || getUserEmail(selectedUserForDetail.id) || 'Not specified'}</span>
{loadingUserFreshDetail ? (
<div className="p-6 text-center text-slate-400">
<RefreshCw className="w-5 h-5 animate-spin mx-auto mb-2 text-blue-400" />
Querying backend endpoint (GET /v1/api/users/{selectedUserForDetail.id})...
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Identifier:</span>
<span className="font-mono text-slate-300 text-xs break-all">{selectedUserForDetail.id}</span>
</div>
<div className="flex items-center justify-between pt-1">
) : (
<div className="p-4 bg-slate-900/60 rounded-xl border border-slate-800 space-y-2">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Status:</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${getActivityStatusBadge(selectedUserForDetail.status).className}`}>
{getActivityStatusBadge(selectedUserForDetail.status).label}
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Email:</span>
<span className="text-white font-semibold text-xs">
{userFreshDetail?.email || selectedUserForDetail.email || getUserEmail(selectedUserForDetail.id) || 'Not specified'}
</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Last Login:</span>
<span className="font-mono text-slate-300">
{selectedUserForDetail.lastLoginAt ? formatDate(selectedUserForDetail.lastLoginAt) : 'Never'}
<span className="text-slate-400 font-semibold block text-[11px] uppercase">User Identifier:</span>
<span className="font-mono text-slate-300 text-xs break-all">
{userFreshDetail?.id || selectedUserForDetail.id}
</span>
</div>
<div className="flex items-center justify-between pt-1">
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Status:</span>
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold border ${getActivityStatusBadge(userFreshDetail?.status ?? selectedUserForDetail.status).className}`}>
{getActivityStatusBadge(userFreshDetail?.status ?? selectedUserForDetail.status).label}
</span>
</div>
<div>
<span className="text-slate-400 font-semibold block text-[11px] uppercase">Last Login:</span>
<span className="font-mono text-slate-300">
{(userFreshDetail?.lastLoginAt || selectedUserForDetail.lastLoginAt) ? formatDate((userFreshDetail?.lastLoginAt || selectedUserForDetail.lastLoginAt)!) : 'Never'}
</span>
</div>
</div>
</div>
</div>
)}
<div className="pt-2 flex justify-end gap-2">
<button
+119 -31
View File
@@ -27,6 +27,7 @@ import {
Award,
CreateAwardRequest,
Contract,
ContractStatus,
CreateContractRequest,
UpdateContractRequest,
ExecuteContractRequest,
@@ -429,6 +430,18 @@ export const createAward = async (payload: CreateAwardRequest): Promise<Award> =
return res.data;
};
export const fetchHashId = async (id64: number | string): Promise<string> => {
try {
const res = await apiClient.get<string>(`/v1/api/tools/hashid/${id64}`);
if (res.data && typeof res.data === 'string') {
return res.data;
}
} catch {
// Fall back to client-side deterministic Hashids
}
return toolsService.encodeIdSync(id64);
};
export const getContracts = async (page = 1, pageSize = 100): Promise<Contract[]> => {
const res = await apiClient.get<Contract[]>('/v1/api/grants/contracts', {
params: { page, pageSize },
@@ -444,15 +457,94 @@ export const getContract = async (contractId: string | number): Promise<Contract
export const createContract = async (payload: CreateContractRequest): Promise<Contract> => {
const userSub = getCurrentUserSub();
const hashedAwardId = await toolsService.resolveAwardId(payload.awardId);
const res = await apiClient.post('/v1/api/grants/contracts', {
...payload,
awardId: hashedAwardId,
createdBy: userSub || undefined,
});
return res.data;
// Extract ID from headers (e.g. Location or Content-Location) if present
const locationHeader = res.headers?.['location'] || res.headers?.['Location'];
let headerId: string | undefined;
if (locationHeader && typeof locationHeader === 'string') {
const parts = locationHeader.split('/');
headerId = parts[parts.length - 1];
}
// Case 1: Backend returns string contractId/hash directly
if (typeof res.data === 'string' && res.data.trim().length > 0) {
const rawStr = res.data.trim();
return {
id: rawStr,
contractId: rawStr,
awardId: hashedAwardId,
totalValue: payload.totalValue,
effectiveAt: payload.effectiveAt,
expiresAt: payload.expiresAt,
status: ContractStatus.Draft,
} as unknown as Contract;
}
// Case 2: Backend returns object
if (res.data && typeof res.data === 'object') {
const candidateId =
res.data.id ??
res.data.contractId ??
res.data.Id ??
res.data.ContractId ??
headerId;
if (candidateId) {
return {
...res.data,
id: candidateId,
contractId: candidateId,
awardId: res.data.awardId || hashedAwardId,
};
}
}
// Case 3: ID extracted from Location header
if (headerId) {
return {
id: headerId,
contractId: headerId,
awardId: hashedAwardId,
totalValue: payload.totalValue,
effectiveAt: payload.effectiveAt,
expiresAt: payload.expiresAt,
status: ContractStatus.Draft,
} as unknown as Contract;
}
// Case 4: If backend returns empty 200 OK without body, locate newly created contract by awardId
try {
const existing = await getContracts(1, 10);
const matched = existing.find(
(c) =>
String(c.awardId) === String(hashedAwardId) ||
String(c.awardId) === String(payload.awardId)
);
if (matched) return matched;
} catch {}
return (res.data || {
id: hashedAwardId,
contractId: hashedAwardId,
awardId: hashedAwardId,
totalValue: payload.totalValue,
effectiveAt: payload.effectiveAt,
expiresAt: payload.expiresAt,
status: ContractStatus.Draft,
}) as unknown as Contract;
};
export const updateContract = async (payload: UpdateContractRequest): Promise<void> => {
await apiClient.put('/v1/api/grants/contracts', payload);
const hashedContractId = await toolsService.resolveContractId(payload.contractId);
await apiClient.put('/v1/api/grants/contracts', {
...payload,
contractId: hashedContractId,
});
};
export const uploadContractFile = async (
@@ -471,14 +563,19 @@ export const uploadContractFile = async (
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;
const res = await apiClient.get<string | { url?: string; link?: string }>(
`/v1/api/grants/contracts/${hashedContractId}/securelink`
);
if (typeof res.data === 'string') return res.data;
return (res.data as any)?.url || (res.data as any)?.link || String(res.data);
};
export const executeContract = async (payload: ExecuteContractRequest): Promise<void> => {
const userSub = getCurrentUserSub();
const hashedContractId = await toolsService.resolveContractId(payload.contractId);
await apiClient.post('/v1/api/grants/contracts/execute', {
...payload,
contractId: hashedContractId,
executedBy: userSub || undefined,
});
};
@@ -759,7 +856,7 @@ export const updateUserStatus = async (userId: string, status: ActivityStatus |
await apiClient.get(`/v1/api/users/${userId}/status/${status}`);
};
export {
import {
syncUserAccount,
ensureUserSynced,
isUserSyncedInBrowser,
@@ -771,6 +868,18 @@ export {
getActivationPendingReason,
} from './userSyncService';
export {
syncUserAccount,
ensureUserSynced,
isUserSyncedInBrowser,
saveUserSyncInBrowser,
clearUserSyncInBrowser,
getUserSyncRecord,
isActivationPending,
setActivationPending,
getActivationPendingReason,
};
/**
* Synchronizes the authenticated user with the backend to enable the user in the system.
* By default, sync is saved in the browser to prevent hotspotting the sync endpoint.
@@ -786,13 +895,15 @@ export const syncUser = async (
},
force = false
): Promise<boolean> => {
const { ensureUserSynced } = await import('./userSyncService');
return ensureUserSynced(userData?.sub || userData?.id, undefined, userData, force);
};
export const syncIntegration = async (force = true): Promise<void> => {
const { ensureUserSynced } = await import('./userSyncService');
await ensureUserSynced(undefined, undefined, undefined, force);
try {
await apiClient.post('/v1/api/integration/sync', {});
} catch {
await ensureUserSynced(undefined, undefined, undefined, force);
}
};
// ==========================================
@@ -821,26 +932,3 @@ export const getApplicantInvites = async (applicantUserId: string): Promise<Invi
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> => {
const userSub = getCurrentUserSub();
const res = await apiClient.post('/v1/api/governance/users/invite', {
...payload,
invitedBy: userSub || undefined,
});
return res.data;
};
export const revokeUser = async (userId: string): Promise<void> => {
await apiClient.delete(`/v1/api/governance/users/${userId}`);
};
export const getAuditLogs = async (): Promise<AuditLog[]> => {
try {
const res = await apiClient.get<AuditLog[]>('/v1/api/governance/audit');
return res.data || [];
} catch {
return [];
}
};
@@ -1,6 +1,7 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import { getApiBaseUrl } from '../config';
import { triggerSessionExpired, isUserSyncEndpoint } from './sessionExpiration';
import { isUserSyncedInBrowser, isActivationPending, syncUserAccount } from './userSyncService';
export const BASE_API_URL = getApiBaseUrl();
@@ -28,8 +29,6 @@ apiClient.interceptors.request.use(
// All API calls require an authenticated and enabled user in the backend system.
const isSyncRequest = isUserSyncEndpoint(config.url);
if (token && !isSyncRequest) {
const { isUserSyncedInBrowser, isActivationPending, syncUserAccount } = await import('./userSyncService');
// If user account is flagged as pending administrator activation, block downstream API calls
if (isActivationPending()) {
return Promise.reject(new Error('User account is pending administrator activation'));
@@ -57,11 +56,9 @@ apiClient.interceptors.response.use(
const requestUrl = error.config?.url;
const isSyncEndpoint = isUserSyncEndpoint(requestUrl);
// Disambiguation between Session Lockout and Session Expired:
// - Session Expired process monitors ALL OTHER endpoints for HTTP 401 other than the user sync endpoint.
// - User sync endpoint 401 is subordinated to the session lockout / auth initialization flow through call order and priority.
// Handle 401 Unauthorized errors
if (!isSyncEndpoint) {
// Active working endpoint returned HTTP 401: trigger modal with progress bar countdown and landing redirect
// Working endpoint returned HTTP 401: trigger session expiration prompt
const detailMsg =
error?.response?.data?.detail ||
error?.response?.data?.message ||
@@ -69,7 +66,7 @@ apiClient.interceptors.response.use(
triggerSessionExpired(detailMsg, requestUrl);
} else {
// User sync endpoint returned HTTP 401: clean up session data silently for landing navigation
// User sync endpoint returned HTTP 401: clear cached auth state
if (typeof window !== 'undefined') {
localStorage.removeItem('pfm_access_token');
localStorage.removeItem('pfm_access_user');
@@ -58,19 +58,14 @@ export function isSessionExpiredState(): boolean {
/**
* Triggers global session expiration when an endpoint returns HTTP 401.
* Prevents multiple modal popups if multiple parallel requests fail with 401.
*
* NOTE: As established by system call priority and subordination, this process
* strictly monitors all other endpoints EXCEPT the user sync endpoint, which is
* reserved for the session lockout / activation process.
*/
export function triggerSessionExpired(reason?: string, requestUrl?: string): void {
// Disambiguation: Ignore HTTP 401 if originating from the user sync endpoint
// Ignore HTTP 401 if originating from user sync endpoint
if (requestUrl && isUserSyncEndpoint(requestUrl)) {
return;
}
// Priority Subordination: If user is in account activation lockout, session expiration
// is avoided and cancelled in favor of the higher-priority activation modal.
// If user is pending account activation, ignore general session expiration
if (typeof window !== 'undefined' && localStorage.getItem(LOCAL_STORAGE_ACTIVATION_PENDING_KEY)) {
return;
}
@@ -87,7 +82,7 @@ export function triggerSessionExpired(reason?: string, requestUrl?: string): voi
hasTriggeredSessionExpired = true;
// Session expiration modal dismisses all other subordinates when it is displayed
// Dismiss open secondary modals
dismissSubordinateModals();
// Dispatch custom browser event
@@ -172,22 +172,20 @@ export function getUserSyncRecord(): UserSyncRecord | null {
}
export const USER_SYNC_CANDIDATE_ENDPOINTS = [
'/v1/api/users/sync',
'/v1/api/integration/sync',
'/v1/api/users/sync',
'/v1/api/user/sync',
];
/**
* Determines whether a given URL or request path targets a user sync endpoint.
* This is used to differentiate user sync / lockout lifecycle calls from regular
* business working endpoints, ensuring established call priority and subordination.
*/
export function isUserSyncEndpoint(url?: string): boolean {
if (!url) return false;
const clean = url.toLowerCase().split('?')[0];
return (
clean.includes('/users/sync') ||
clean.includes('/integration/sync') ||
clean.includes('/users/sync') ||
clean.includes('/user/sync') ||
clean.endsWith('/sync') ||
clean === '/sync'
@@ -195,9 +193,7 @@ export function isUserSyncEndpoint(url?: string): boolean {
}
/**
* Executes the backend user sync request.
* Tries the primary '/v1/api/users/sync' endpoint, falling back to
* '/v1/api/integration/sync' or '/v1/api/user/sync' if needed.
* Executes the backend user sync request across available candidate endpoints.
*/
async function executeBackendSync(
userSub?: string,
@@ -218,20 +214,33 @@ async function executeBackendSync(
for (const endpoint of candidateEndpoints) {
try {
const res = await apiClient.post(endpoint, payload);
return { endpoint, data: res.data };
let res;
if (endpoint === '/v1/api/integration/sync') {
try {
res = await apiClient.post(endpoint, payload);
} catch (postErr: any) {
const errStatus = postErr?.response?.status;
if (errStatus === 400 || errStatus === 415 || errStatus === 422) {
res = await apiClient.post(endpoint, {});
} else {
throw postErr;
}
}
} else {
res = await apiClient.post(endpoint, payload);
}
return { endpoint, data: res?.data };
} catch (err: any) {
lastError = err;
const status = err?.response?.status;
// 1. If HTTP 401 Unauthorized: token expired or invalid, fail fast
// Fail fast on token expiration/invalid
if (status === 401) {
throw err;
}
// 2. If HTTP 403 Forbidden, 423 Locked, or non-404 client error:
// The endpoint is valid, but the user is blocked/disabled/not activated by the administrator.
if (status === 403 || status === 423 || (status && status >= 400 && status !== 404 && status !== 405)) {
// User blocked or pending activation
if (status === 403 || status === 423) {
throw err;
}
+2 -1
View File
@@ -231,7 +231,8 @@ export interface ProblemDetails {
status?: number | null;
detail?: string | null;
instance?: string | null;
errors?: Record<string, string[]>;
errors?: Record<string, string[] | string> | any;
[key: string]: any;
}
export interface Dashboard {
@@ -19,6 +19,7 @@ export interface FriendlyError {
category: ErrorCategory;
categoryLabel?: string;
fieldErrors?: Array<{ field: string; message: string }>;
errorsList?: string[];
technicalDetails?: string;
}
@@ -80,7 +81,7 @@ function humanizeFieldName(field: string): string {
function cleanTechnicalJargon(text: string): string {
if (!text) return '';
let cleaned = text
const cleaned = text
.replace(/^AxiosError:\s*/i, '')
.replace(/^Error:\s*/i, '')
.replace(/Request failed with status code \d+/i, '')
@@ -90,33 +91,14 @@ function cleanTechnicalJargon(text: string): string {
.replace(/at\s+[a-zA-Z0-9_.]+\s*\(.*?\)/g, '')
.trim();
// If after cleaning it's empty or trivial, return empty
if (cleaned.length < 3 || cleaned === '[object Object]') {
// In this system, ProblemDetails title is always "Error" — ignore trivial or generic "Error" strings
if (cleaned.length < 2 || cleaned === '[object Object]' || cleaned.toLowerCase() === 'error') {
return '';
}
return cleaned;
}
/**
* Checks if a title is a standard framework generic title.
*/
function isGenericFrameworkTitle(title?: string): boolean {
if (!title) return true;
const lower = title.toLowerCase().trim();
return (
lower === 'bad request' ||
lower === 'one or more validation errors occurred.' ||
lower === 'one or more validation errors occurred' ||
lower === 'forbidden' ||
lower === 'not found' ||
lower === 'internal server error' ||
lower === 'an error occurred while processing your request.' ||
lower === 'api request error' ||
lower.startsWith('request failed with')
);
}
/**
* Checks if a response object conforms to RFC 7807 / RFC 9457 ProblemDetails.
*/
@@ -127,7 +109,9 @@ export function isProblemDetailsObject(data: any): data is ProblemDetails {
'detail' in data ||
'status' in data ||
'type' in data ||
'errors' in data
'errors' in data ||
'Errors' in data ||
'errorList' in data
);
}
@@ -180,11 +164,8 @@ function detectOperationIntent(
/**
* Extracts and formats human-friendly error guidance from any error,
* prioritizing the standardised ProblemDetails model when present,
* and handling BadRequest specifically for its multiple purposes:
* - Input validation errors (field-level)
* - Creation errors (prerequisites, business rules, duplicate identifiers)
* - Update errors (lifecycle state restrictions, invalid transitions, permissions)
* accessing the ProblemDetails `errors` array directly to display the error.
* In this system the title field is always "Error", so no generic error testing is performed.
*/
export function parseFriendlyError(
error: unknown,
@@ -193,7 +174,7 @@ export function parseFriendlyError(
let statusCode: number | undefined;
let rawTitle: string | undefined;
let rawDetail: string | undefined;
let rawErrors: Record<string, string[] | string> | undefined;
let rawErrors: any = undefined;
let isProblemDetails = false;
let rawResponseData: any = null;
let technicalSummary = '';
@@ -210,25 +191,31 @@ export function parseFriendlyError(
technicalSummary = `${httpMethod || 'HTTP'} ${requestUrl} -> ${statusCode || axiosErr.code || 'ERR'}`;
if (rawResponseData && typeof rawResponseData === 'object') {
if (isProblemDetailsObject(rawResponseData)) {
const dataObj = rawResponseData as any;
if (isProblemDetailsObject(dataObj)) {
isProblemDetails = true;
rawTitle = rawResponseData.title || undefined;
rawDetail = rawResponseData.detail || undefined;
statusCode = rawResponseData.status || statusCode;
rawErrors = rawResponseData.errors;
} else if (rawResponseData.message || rawResponseData.error) {
rawDetail = rawResponseData.message || rawResponseData.error;
rawTitle = dataObj.title || dataObj.Title || undefined;
rawDetail = dataObj.detail || dataObj.Detail || undefined;
statusCode = dataObj.status || dataObj.Status || statusCode;
rawErrors = dataObj.errors ?? dataObj.Errors ?? dataObj.errorList ?? dataObj.ErrorList;
} else if (dataObj.message || dataObj.error || dataObj.errors || dataObj.Errors) {
rawDetail = dataObj.message || dataObj.error;
rawErrors = dataObj.errors ?? dataObj.Errors;
} else if (Array.isArray(dataObj)) {
rawErrors = dataObj;
}
} else if (typeof rawResponseData === 'string') {
// Check if response data is stringified JSON containing ProblemDetails
try {
const parsed = JSON.parse(rawResponseData);
const parsed = JSON.parse(rawResponseData) as any;
if (isProblemDetailsObject(parsed)) {
isProblemDetails = true;
rawTitle = parsed.title || undefined;
rawDetail = parsed.detail || undefined;
statusCode = parsed.status || statusCode;
rawErrors = parsed.errors;
rawTitle = parsed.title || parsed.Title || undefined;
rawDetail = parsed.detail || parsed.Detail || undefined;
statusCode = parsed.status || parsed.Status || statusCode;
rawErrors = parsed.errors ?? parsed.Errors ?? parsed.errorList ?? parsed.ErrorList;
} else if (Array.isArray(parsed)) {
rawErrors = parsed;
} else {
rawDetail = cleanTechnicalJargon(rawResponseData);
}
@@ -267,16 +254,19 @@ export function parseFriendlyError(
const obj = error as any;
if (isProblemDetailsObject(obj)) {
isProblemDetails = true;
rawTitle = obj.title || undefined;
rawDetail = obj.detail || undefined;
statusCode = obj.status || undefined;
rawErrors = obj.errors;
rawTitle = obj.title || obj.Title || undefined;
rawDetail = obj.detail || obj.Detail || undefined;
statusCode = obj.status || obj.Status || undefined;
rawErrors = obj.errors ?? obj.Errors ?? obj.errorList ?? obj.ErrorList;
} else if (obj.response?.data && isProblemDetailsObject(obj.response.data)) {
isProblemDetails = true;
rawTitle = obj.response.data.title || undefined;
rawDetail = obj.response.data.detail || undefined;
statusCode = obj.response.status || obj.response.data.status;
rawErrors = obj.response.data.errors;
const respData = obj.response.data as any;
rawTitle = respData.title || respData.Title || undefined;
rawDetail = respData.detail || respData.Detail || undefined;
statusCode = obj.response.status || respData.status || respData.Status;
rawErrors = respData.errors ?? respData.Errors ?? respData.errorList ?? respData.ErrorList;
} else if (Array.isArray(obj)) {
rawErrors = obj;
} else if (obj.message) {
rawDetail = obj.message;
const statusMatch = obj.message.match(/status code (\d{3})/i);
@@ -285,7 +275,7 @@ export function parseFriendlyError(
}
}
} else if (typeof error === 'string') {
// 3. String error, e.g. String(err)
// 3. String error
const statusMatch = error.match(/status code (\d{3})/i);
if (statusMatch) {
statusCode = parseInt(statusMatch[1], 10);
@@ -293,187 +283,188 @@ export function parseFriendlyError(
rawDetail = cleanTechnicalJargon(error);
}
// Parse field-level validation errors from ProblemDetails if present
// Extract errors list directly from ProblemDetails errors array / dictionary
const fieldErrors: Array<{ field: string; message: string }> = [];
if (rawErrors && typeof rawErrors === 'object') {
for (const [key, val] of Object.entries(rawErrors)) {
const fieldTitle = humanizeFieldName(key);
if (Array.isArray(val)) {
val.forEach((msg) => {
if (msg) fieldErrors.push({ field: fieldTitle, message: cleanTechnicalJargon(String(msg)) });
});
} else if (typeof val === 'string' && val) {
fieldErrors.push({ field: fieldTitle, message: cleanTechnicalJargon(val) });
const errorsList: string[] = [];
if (rawErrors) {
if (Array.isArray(rawErrors)) {
rawErrors.forEach((item) => {
if (typeof item === 'string' && item.trim()) {
const cleaned = cleanTechnicalJargon(item.trim());
if (cleaned) errorsList.push(cleaned);
} else if (item && typeof item === 'object') {
const msg =
item.message ||
item.errorMessage ||
item.detail ||
item.error ||
item.description ||
JSON.stringify(item);
const cleaned = cleanTechnicalJargon(String(msg));
if (cleaned) errorsList.push(cleaned);
}
});
} else if (typeof rawErrors === 'object') {
for (const [key, val] of Object.entries(rawErrors)) {
const isNumericKey = /^\d+$/.test(key);
const fieldTitle = isNumericKey ? '' : humanizeFieldName(key);
if (Array.isArray(val)) {
val.forEach((msg) => {
if (msg) {
const cleaned = cleanTechnicalJargon(String(msg));
if (cleaned) {
if (fieldTitle && !cleaned.toLowerCase().includes(fieldTitle.toLowerCase())) {
fieldErrors.push({ field: fieldTitle, message: cleaned });
errorsList.push(`${fieldTitle}: ${cleaned}`);
} else {
errorsList.push(cleaned);
}
}
}
});
} else if (typeof val === 'string' && val.trim()) {
const cleaned = cleanTechnicalJargon(val.trim());
if (cleaned) {
if (fieldTitle && !cleaned.toLowerCase().includes(fieldTitle.toLowerCase())) {
fieldErrors.push({ field: fieldTitle, message: cleaned });
errorsList.push(`${fieldTitle}: ${cleaned}`);
} else {
errorsList.push(cleaned);
}
}
}
}
} else if (typeof rawErrors === 'string' && rawErrors.trim()) {
const cleaned = cleanTechnicalJargon(rawErrors.trim());
if (cleaned) errorsList.push(cleaned);
}
}
// Detect whether the operation is creation, update, deletion, or general
// Detect operation intent (creation, update, deletion, general)
const operationIntent = detectOperationIntent(httpMethod, fallbackActionTitle, requestUrl);
// Build user-friendly title, message, and category based on standard HTTP codes and ProblemDetails
let friendlyTitle = fallbackActionTitle;
let friendlyMessage = '';
let category: ErrorCategory = 'general';
let categoryLabel: string | undefined = undefined;
switch (statusCode) {
case 403:
// Forbid: specifically occurs when the hashid used on a call is invalid or access denied
category = 'security';
categoryLabel = 'Invalid Identifier / Forbid';
friendlyTitle = 'Access Not Permitted or Invalid Identifier';
friendlyMessage =
cleanTechnicalJargon(rawDetail || '') ||
'The reference identifier (hash ID) is invalid, expired, or you do not have permission to access this item. Please verify your selection or contact your system administrator.';
break;
const cleanDetail = cleanTechnicalJargon(rawDetail || '');
case 404:
// NotFound: item could not be located
category = 'notFound';
categoryLabel = 'Record Not Found';
friendlyTitle = 'Record Not Found';
friendlyMessage =
cleanTechnicalJargon(rawDetail || '') ||
'The requested record could not be found. It may have been archived, removed, or the link is outdated.';
break;
// In this system, ProblemDetails allows access to the errors array to display the error directly if available
if (errorsList.length > 0) {
category = 'validation';
categoryLabel =
operationIntent === 'creation'
? 'Validation'
: operationIntent === 'update'
? 'Validation'
: 'Validation';
case 400: {
// BadRequest serves MULTIPLE purposes:
// 1) Input validation errors (field-level errors dictionary)
// 2) Creation errors (prerequisites, business rules, duplicate code, invalid entity state)
// 3) Update errors (status transition restrictions, record locked, state mismatch)
const hasSpecificTitle = rawTitle && !isGenericFrameworkTitle(rawTitle);
const cleanDetail = cleanTechnicalJargon(rawDetail || '');
if (fieldErrors.length > 0) {
// Purpose 1: Input Validation Errors
category = 'validation';
categoryLabel =
operationIntent === 'creation'
? 'Creation Validation'
: operationIntent === 'update'
? 'Update Validation'
: 'Input Validation';
if (hasSpecificTitle) {
friendlyTitle = cleanTechnicalJargon(rawTitle!);
} else if (operationIntent === 'creation') {
friendlyTitle = 'Please Check New Record Details';
} else if (operationIntent === 'update') {
friendlyTitle = 'Please Check Updated Values';
} else {
friendlyTitle = 'Please Check Submitted Details';
}
const errorList = fieldErrors.map((f) => `• ${f.field}: ${f.message}`).join('\n');
friendlyMessage = cleanDetail
? `${cleanDetail}\n\n${errorList}`
: `The submitted details could not be validated:\n${errorList}`;
} else if (operationIntent === 'creation') {
// Purpose 2: Creation Error (non-field-validation: business rule, duplicate, or prerequisite failure)
category = 'creation';
categoryLabel = 'Creation Error';
if (hasSpecificTitle) {
friendlyTitle = cleanTechnicalJargon(rawTitle!);
} else if (fallbackActionTitle && fallbackActionTitle !== 'Action Failed') {
friendlyTitle = fallbackActionTitle;
} else {
friendlyTitle = 'Unable to Create Record';
}
friendlyMessage =
cleanDetail ||
'The new record could not be created. This may be due to business rule prerequisites, existing duplicate identifiers, or missing related records.';
} else if (operationIntent === 'update') {
// Purpose 3: Update Error (non-field-validation: status transition, record locked, lifecycle restriction)
category = 'update';
categoryLabel = 'Update Error';
if (hasSpecificTitle) {
friendlyTitle = cleanTechnicalJargon(rawTitle!);
} else if (fallbackActionTitle && fallbackActionTitle !== 'Action Failed') {
friendlyTitle = fallbackActionTitle;
} else {
friendlyTitle = 'Unable to Update Record';
}
friendlyMessage =
cleanDetail ||
'The record could not be updated. It may be locked, in a status that does not allow modifications, or the requested status transition is not permitted.';
} else {
// General Bad Request
category = 'general';
categoryLabel = 'Request Error';
if (hasSpecificTitle) {
friendlyTitle = cleanTechnicalJargon(rawTitle!);
} else {
friendlyTitle = fallbackActionTitle || 'Request Could Not Be Processed';
}
friendlyMessage =
cleanDetail ||
'The request could not be processed due to invalid parameters or business rule requirements. Please review your input and try again.';
}
break;
// Display the error directly
if (errorsList.length === 1) {
friendlyMessage = errorsList[0];
} else {
friendlyMessage = errorsList.map((err) => `• ${err}`).join('\n');
}
case 409:
// Conflict
// If detail provides extra non-duplicate context, include it
if (cleanDetail && !errorsList.some((e) => e.toLowerCase() === cleanDetail.toLowerCase())) {
friendlyMessage = `${cleanDetail}\n\n${friendlyMessage}`;
}
} else if (cleanDetail) {
// If no errors array, but detail is available, display detail directly
friendlyMessage = cleanDetail;
if (statusCode === 403) {
category = 'security';
categoryLabel = 'Access Denied';
} else if (statusCode === 404) {
category = 'notFound';
categoryLabel = 'Not Found';
} else if (statusCode === 409) {
category = 'conflict';
categoryLabel = 'Conflict';
friendlyTitle = 'Update Conflict';
friendlyMessage =
cleanTechnicalJargon(rawDetail || '') ||
'This action conflicts with the current status of the record (for instance, another user may have updated it or a duplicate entry exists). Please refresh the page and try again.';
break;
case 422:
// Unprocessable Entity
category = 'validation';
categoryLabel = 'Incomplete Data';
friendlyTitle = 'Information Incomplete';
friendlyMessage =
cleanTechnicalJargon(rawDetail || '') ||
'The submitted information could not be processed by the system. Please verify that all required fields are complete.';
break;
case 429:
// Too Many Requests
category = 'server';
categoryLabel = 'Rate Limit';
friendlyTitle = 'System Busy';
friendlyMessage = 'The system received too many requests in a short time. Please pause for a moment before trying again.';
break;
case 500:
case 502:
case 503:
case 504:
// Server error
} else if (statusCode && statusCode >= 500) {
category = 'server';
categoryLabel = 'Service Issue';
friendlyTitle = 'Service Temporarily Unavailable';
friendlyMessage =
'The central service encountered an unexpected issue while processing your request. Please try again in a few moments.';
break;
}
} else {
// Fallback status-specific messaging when neither errors array nor detail is available
switch (statusCode) {
case 403:
category = 'security';
categoryLabel = 'Invalid Identifier / Forbid';
friendlyTitle = 'Access Not Permitted or Invalid Identifier';
friendlyMessage =
'The reference identifier (hash ID) is invalid, expired, or you do not have permission to access this item. Please verify your selection or contact your system administrator.';
break;
default:
if (rawTitle && isProblemDetails && !isGenericFrameworkTitle(rawTitle)) {
friendlyTitle = cleanTechnicalJargon(rawTitle) || fallbackActionTitle;
}
if (rawDetail) {
friendlyMessage = cleanTechnicalJargon(rawDetail);
} else if (fieldErrors.length > 0) {
case 404:
category = 'notFound';
categoryLabel = 'Record Not Found';
friendlyTitle = 'Record Not Found';
friendlyMessage =
'The requested record could not be found. It may have been archived, removed, or the link is outdated.';
break;
case 400:
if (operationIntent === 'creation') {
category = 'creation';
categoryLabel = 'Creation Error';
friendlyMessage =
'The new record could not be created. Please review the input parameters and prerequisites.';
} else if (operationIntent === 'update') {
category = 'update';
categoryLabel = 'Update Error';
friendlyMessage =
'The record could not be updated. It may be locked or the requested status transition is not permitted.';
} else {
category = 'general';
categoryLabel = 'Request Error';
friendlyMessage =
'The request could not be processed due to invalid parameters or business rule requirements.';
}
break;
case 409:
category = 'conflict';
categoryLabel = 'Conflict';
friendlyTitle = 'Update Conflict';
friendlyMessage =
'This action conflicts with the current status of the record. Please refresh the page and try again.';
break;
case 422:
category = 'validation';
categoryLabel = 'Validation';
friendlyMessage = fieldErrors.map((f) => `• ${f.field}: ${f.message}`).join('\n');
} else {
friendlyMessage = 'An unexpected issue occurred while processing this request. Please try again or contact support.';
}
break;
categoryLabel = 'Incomplete Data';
friendlyMessage =
'The submitted information could not be processed. Please verify that all required fields are complete.';
break;
case 429:
category = 'server';
categoryLabel = 'Rate Limit';
friendlyTitle = 'System Busy';
friendlyMessage =
'The system received too many requests in a short time. Please pause for a moment before trying again.';
break;
case 500:
case 502:
case 503:
case 504:
category = 'server';
categoryLabel = 'Service Issue';
friendlyTitle = 'Service Temporarily Unavailable';
friendlyMessage =
'The central service encountered an unexpected issue while processing your request. Please try again in a few moments.';
break;
default:
friendlyMessage =
'An unexpected issue occurred while processing this request. Please try again or contact support.';
break;
}
}
// Ensure title is friendly and not an uncaught technical string
@@ -489,6 +480,7 @@ export function parseFriendlyError(
category,
categoryLabel,
fieldErrors: fieldErrors.length > 0 ? fieldErrors : undefined,
errorsList: errorsList.length > 0 ? errorsList : undefined,
technicalDetails: technicalSummary || (statusCode ? `HTTP Status ${statusCode}` : undefined),
};
}
+76 -5
View File
@@ -3,20 +3,91 @@ import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig} from 'vite';
export default defineConfig(() => {
export default defineConfig(async ({ command }) => {
// In containerized cloud environments where the reverse proxy / ingress terminates TLS externally,
// Vite serves over HTTP on port 3000.
// For local development, HTTPS can be enabled.
const isAiStudioContainer = Boolean(
process.env.APPLET_ID ||
process.env.K_SERVICE ||
process.env.CONTROL_PLANE_PORT
);
const isHttps = !isAiStudioContainer && (
process.env.HTTPS === 'true' ||
process.env.VITE_ENABLE_HTTPS === 'true' ||
(process.env.HTTPS !== 'false' && process.env.VITE_ENABLE_HTTPS !== 'false')
);
const plugins: any[] = [
react(),
tailwindcss(),
];
// Only load SSL plugin when running the local dev server with HTTPS enabled
if (command === 'serve' && isHttps) {
try {
const basicSslModule: any = await import('@vitejs/plugin-basic-ssl');
const basicSsl = basicSslModule.default || basicSslModule;
plugins.push(basicSsl());
} catch {
console.warn(
'[vite.config] @vitejs/plugin-basic-ssl is not installed; running dev server over HTTP. Run `yarn add -D @vitejs/plugin-basic-ssl` to enable local HTTPS.'
);
}
}
return {
plugins: [react(), tailwindcss()],
plugins,
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
server: {
// 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',
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
watch: process.env.DISABLE_HMR === 'true' ? null : {},
},
build: {
rollupOptions: {
onwarn(warning, defaultHandler) {
// Suppress benign Rollup warnings where Zod's internal explanatory doc comments mention `@__PURE__`
if (
warning.code === 'INVALID_ANNOTATION' &&
(warning.id?.includes('zod') || warning.message?.includes('@__PURE__') || warning.loc?.file?.includes('zod'))
) {
return;
}
defaultHandler(warning);
},
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('react') || id.includes('scheduler')) {
return 'vendor-react';
}
if (id.includes('@tanstack')) {
return 'vendor-query';
}
if (id.includes('oidc-client-ts')) {
return 'vendor-auth';
}
if (id.includes('lucide-react')) {
return 'vendor-icons';
}
if (id.includes('zod') || id.includes('react-hook-form') || id.includes('@hookform')) {
return 'vendor-forms';
}
if (id.includes('axios')) {
return 'vendor-http';
}
}
},
},
},
},
preview: {
port: 3000,
host: '0.0.0.0',
},
};
});