Refactored UI endpoint usage #84
@@ -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
@@ -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"}
|
||||
+2882
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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 → Contracting → Budgeting → 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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user