From d428f6e17c530504355199136acc859f8683699c Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Sun, 20 Sep 2026 13:51:01 +0200 Subject: [PATCH] Remediated test #1 results --- .../openapi_live.json | 6077 +++++++++++++++++ .../src/components/layout/AppLayout.tsx | 62 +- .../monitoring/ContractsManagement.tsx | 5 +- .../monitoring/EvidenceReviewQueue.tsx | 1172 ++++ .../src/components/ui/HelpTooltip.tsx | 194 +- .../src/components/ui/ProblemDetailsAlert.tsx | 34 +- .../src/components/ui/Toast.tsx | 25 +- .../src/lib/formatters.ts | 30 + .../src/pages/ExecutiveDashboard.tsx | 136 +- .../src/pages/GovernancePage.tsx | 488 +- .../src/pages/MonitoringPage.tsx | 369 +- .../src/pages/ProjectsPage.tsx | 69 +- .../src/pages/SettingsPage.tsx | 28 +- .../src/services/api.ts | 309 +- .../src/services/toolsService.ts | 45 +- .../src/utils/apiErrorParser.ts | 82 +- 16 files changed, 8367 insertions(+), 758 deletions(-) create mode 100644 PostFundManagement.ConsoleUI/openapi_live.json create mode 100644 PostFundManagement.ConsoleUI/src/components/monitoring/EvidenceReviewQueue.tsx diff --git a/PostFundManagement.ConsoleUI/openapi_live.json b/PostFundManagement.ConsoleUI/openapi_live.json new file mode 100644 index 0000000..423173e --- /dev/null +++ b/PostFundManagement.ConsoleUI/openapi_live.json @@ -0,0 +1,6077 @@ +{ + "openapi": "3.1.1", + "info": { + "title": "PostFundManagement.Api | v1", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://api.ifri.khongisa.co.za/" + } + ], + "paths": { + "/v1/api/users/{userId}": { + "get": { + "tags": [ + "Users" + ], + "description": "Gets the metadata of a single user.", + "operationId": "getuser", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/User" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/users/{page}/{pageSize}": { + "post": { + "tags": [ + "Users" + ], + "description": "Paginated, searchable directory of system users. Supports filtering by ActivityStatus, searching by Email, and sorting", + "operationId": "getusers", + "parameters": [ + { + "name": "page", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/UserFilterRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/User" + } + } + } + } + }, + "404": { + "description": "Not Found" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/integration/sync": { + "post": { + "tags": [ + "Users" + ], + "description": "Trigger integration and data synchronization in the background", + "operationId": "syncuser", + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/users/{userId}/status/{status}": { + "get": { + "tags": [ + "Users" + ], + "description": "Changes the Activity status of the user in the system", + "operationId": "updateuserstatus", + "parameters": [ + { + "name": "userId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + }, + { + "name": "status", + "in": "path", + "required": true, + "schema": { + "$ref": "#/components/schemas/ActivityStatus" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + }, + "401": { + "description": "Unauthorized" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/tools/hashid/{id64}": { + "get": { + "tags": [ + "Tools" + ], + "description": "Hashes an int64 identifier", + "operationId": "encodeid", + "parameters": [ + { + "name": "id64", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects/activities": { + "post": { + "tags": [ + "Projects" + ], + "description": "Add a new activity under project (M04)", + "operationId": "createactivity", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/CreateActivityRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects/programme": { + "post": { + "tags": [ + "Projects" + ], + "description": "Create programme", + "operationId": "createprogramme", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/CreateProgrammeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + }, + "put": { + "tags": [ + "Projects" + ], + "description": "Update existing programme", + "operationId": "updateprogramme", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/UpdateProgrammeRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects": { + "post": { + "tags": [ + "Projects" + ], + "description": "Create a new project (M04)", + "operationId": "createproject", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/CreateProjectRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + }, + "get": { + "tags": [ + "Projects" + ], + "description": "Get a list of projects", + "operationId": "getprojects", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + }, + "put": { + "tags": [ + "Projects" + ], + "description": "Update project details", + "operationId": "updateproject", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects/{projectId}/activities": { + "get": { + "tags": [ + "Projects" + ], + "description": "Get a list of activities for a project", + "operationId": "getactivities", + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Activity" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects/activities/{activityId}": { + "get": { + "tags": [ + "Projects" + ], + "description": "Gets a specific activity by it's id", + "operationId": "getactivity", + "parameters": [ + { + "name": "activityId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/Activity" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects/milestones/{milestoneId}": { + "get": { + "tags": [ + "Projects" + ], + "description": "Gets a specific milestone", + "operationId": "getmilestone", + "parameters": [ + { + "name": "milestoneId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/Milestone" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects/milestones/{page}/{pageSize}": { + "get": { + "tags": [ + "Projects" + ], + "description": "Get a list of all milestones", + "operationId": "getmilestones", + "parameters": [ + { + "name": "page", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Milestone" + } + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects/programme/{programmeId}": { + "get": { + "tags": [ + "Projects" + ], + "description": "Get specific programme", + "operationId": "getprogramme", + "parameters": [ + { + "name": "programmeId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/Programme" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects/{programmeId}/programme": { + "get": { + "tags": [ + "Projects" + ], + "description": "Get a list of all projects under a programme", + "operationId": "getprogrammeprojects", + "parameters": [ + { + "name": "programmeId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects/programme/{page}/{pageSize}": { + "get": { + "tags": [ + "Projects" + ], + "description": "Get a list of all programmes", + "operationId": "getprogrammesprojects", + "parameters": [ + { + "name": "page", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Programme" + } + } + } + } + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects/{projectId}": { + "get": { + "tags": [ + "Projects" + ], + "description": "Get project by ID", + "operationId": "getproject", + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Project" + } + } + } + } + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects/indicators/project/{projectId}": { + "get": { + "tags": [ + "Projects" + ], + "description": "Get get a detailed list of project indicators", + "operationId": "getprojectindicators", + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Indicator" + } + } + } + } + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects/{projectId}/milestones": { + "get": { + "tags": [ + "Projects" + ], + "description": "Get a list of milestones for a project", + "operationId": "getprojectmilestones", + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Milestone" + } + } + } + } + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects/{projectId}/risks": { + "get": { + "tags": [ + "Projects" + ], + "description": "Get a list of risks for a project", + "operationId": "getprojectrisks", + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Risk" + } + } + } + } + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects/milestone": { + "put": { + "tags": [ + "Projects" + ], + "description": "Update project milestone details", + "operationId": "updatemilestone", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/UpdateMilestoneRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/projects/status": { + "put": { + "tags": [ + "Projects" + ], + "description": "Update project status (M04)", + "operationId": "updateprojectstatus", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectStatusRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/grants/awards": { + "post": { + "tags": [ + "Grants" + ], + "description": "Register an approved funding award (M01)", + "operationId": "createaward", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/CreateAwardRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not Found" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + }, + "get": { + "tags": [ + "Grants" + ], + "description": "Get a list of awards", + "operationId": "getawards", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Award" + } + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/grants/budgets": { + "post": { + "tags": [ + "Grants" + ], + "description": "Create a new budget line (M05)", + "operationId": "createbudget", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/CreateBudgetRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + }, + "get": { + "tags": [ + "Grants" + ], + "description": "Get all budgets", + "operationId": "getbudgets", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Budget" + } + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/grants/contracts": { + "post": { + "tags": [ + "Grants" + ], + "description": "Create a contract record from an approved award (M02)", + "operationId": "createcontract", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/CreateContractRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + }, + "get": { + "tags": [ + "Grants" + ], + "description": "Get all contracts", + "operationId": "getcontracts", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Contract" + } + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + }, + "put": { + "tags": [ + "Grants" + ], + "description": "Updates a specific contract", + "operationId": "updatecontract", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/UpdateContractRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not Found" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/grants/contracts/execute": { + "post": { + "tags": [ + "Grants" + ], + "description": "Execute the contract and lock the contractual version", + "operationId": "executecontract", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ExecuteContractRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/grants/awards/{awardId}": { + "get": { + "tags": [ + "Grants" + ], + "description": "Get award by ID", + "operationId": "getaward", + "parameters": [ + { + "name": "awardId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/Award" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/grants/budgets/{budgetId}": { + "get": { + "tags": [ + "Grants" + ], + "description": "Get budget by ID", + "operationId": "getbudget", + "parameters": [ + { + "name": "budgetId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/Budget" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/grants/contracts/{contractId}": { + "get": { + "tags": [ + "Grants" + ], + "description": "Get contract by ID", + "operationId": "getcontract", + "parameters": [ + { + "name": "contractId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/Contract" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + }, + "post": { + "tags": [ + "Grants" + ], + "description": "Uploads a contract file securely", + "operationId": "uploadcontract", + "parameters": [ + { + "name": "contractId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/grants/contracts/{contractId}/securelink": { + "get": { + "tags": [ + "Grants" + ], + "description": "Gets a secure download URL, has expiry period (default 15min)", + "operationId": "getcontracturl", + "parameters": [ + { + "name": "contractId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/governance/organisations": { + "post": { + "tags": [ + "Governance" + ], + "description": "Create a new organization profile", + "operationId": "createorganisation", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/CreateOrganisationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + }, + "get": { + "tags": [ + "Governance" + ], + "description": "Get a paginated directory of organizations", + "operationId": "getorganisations", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Organisation" + } + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + }, + "put": { + "tags": [ + "Governance" + ], + "description": "Update an existing organization profile", + "operationId": "updateorganisation", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/UpdateOrganisationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/governance/organisations/portfolio": { + "post": { + "tags": [ + "Governance" + ], + "description": "Create a new organization portfolio", + "operationId": "createportfolio", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/CreatePortfolioRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "403": { + "description": "Forbidden" + }, + "404": { + "description": "Not Found", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + }, + "put": { + "tags": [ + "Governance" + ], + "description": "Update an existing organization portfolio", + "operationId": "updateportfolio", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/UpdatePortfolioRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/governance/dashboard": { + "get": { + "tags": [ + "Governance" + ], + "description": "Get executive portfolio dashboard metrics and KPIs", + "operationId": "dashboard", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/Dashboard" + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/governance/instruments/{programmeId}": { + "get": { + "tags": [ + "Governance" + ], + "description": "Get all funding instruments", + "operationId": "getinstruments", + "parameters": [ + { + "name": "programmeId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Instrument" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/governance/organisations/{organisationId}": { + "get": { + "tags": [ + "Governance" + ], + "description": "Get organization profile by ID", + "operationId": "getorganisation", + "parameters": [ + { + "name": "organisationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/Organisation" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/governance/portfolios/{portfolioId}": { + "get": { + "tags": [ + "Governance" + ], + "description": "Get a specific portfolios", + "operationId": "getportfolio", + "parameters": [ + { + "name": "portfolioId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Portfolio" + } + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Portfolio" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/governance/portfolios/{portfolioId}/programmes": { + "get": { + "tags": [ + "Governance" + ], + "description": "Get a programme's portfolios", + "operationId": "getportfolioprogrammes", + "parameters": [ + { + "name": "portfolioId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Programme" + } + } + } + } + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/governance/organisations/{organisationId}/portfolio": { + "get": { + "tags": [ + "Governance" + ], + "description": "Get all portfolios", + "operationId": "getportfolios", + "parameters": [ + { + "name": "organisationId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Portfolio" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/governance/programmes/{portfolioId}": { + "get": { + "tags": [ + "Governance" + ], + "description": "Get all programmes", + "operationId": "getprogrammes", + "parameters": [ + { + "name": "portfolioId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Programme" + } + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/execution/disbursements/{disbursementId}/approve": { + "post": { + "tags": [ + "Execution" + ], + "description": "Approve disbursement and release payment (M06)", + "operationId": "approvedisbursement", + "parameters": [ + { + "name": "disbursementId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/execution/disbursements": { + "post": { + "tags": [ + "Execution" + ], + "description": "Create a new disbursement schedule (M06)", + "operationId": "createdisbursement", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/CreateDisbursementRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + }, + "401": { + "description": "Unauthorized" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + }, + "get": { + "tags": [ + "Execution" + ], + "description": "Get a list of disbursements", + "operationId": "getdisbursements", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 10 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Disbursement" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "404": { + "description": "Not Found" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + }, + "put": { + "tags": [ + "Execution" + ], + "description": "Update the details of a specific disbursement", + "operationId": "updatedisbursement", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/UpdateDisbursementRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/execution/risks": { + "post": { + "tags": [ + "Execution" + ], + "description": "Add risk to risk register (M09)", + "operationId": "createrisk", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/CreateRiskRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + }, + "401": { + "description": "Unauthorized" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + }, + "get": { + "tags": [ + "Execution" + ], + "description": "Get a list of all risks", + "operationId": "getrisks", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + }, + "put": { + "tags": [ + "Execution" + ], + "description": "Update the details of a specific risk", + "operationId": "updaterisk", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/UpdateRiskRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/execution/disbursements/{disbursementId}": { + "get": { + "tags": [ + "Execution" + ], + "description": "Get a specific disbursement", + "operationId": "getdisbursement", + "parameters": [ + { + "name": "disbursementId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/Disbursement" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "401": { + "description": "Unauthorized" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/execution/risks/{riskId}": { + "get": { + "tags": [ + "Execution" + ], + "description": "Get a specific risk", + "operationId": "getrisk", + "parameters": [ + { + "name": "riskId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/Risk" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/evidence/file": { + "put": { + "tags": [ + "Evidence & M&E" + ], + "description": "Upload supporting evidence file to S3 and register metadata (M14)", + "operationId": "createevidence", + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/evidence/indicators": { + "post": { + "tags": [ + "Evidence & M&E" + ], + "description": "Define indicators and baselines/targets (M08)", + "operationId": "createindicator", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/CreateIndicatorRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + }, + "401": { + "description": "Unauthorized" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/evidence/milestones": { + "post": { + "tags": [ + "Evidence & M&E" + ], + "description": "Define contractual milestones (M07)", + "operationId": "createmilestonerequest", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/CreateMilestoneRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/evidence/{evidenceId}": { + "get": { + "tags": [ + "Evidence & M&E" + ], + "description": "Get a specific evidence record and assiciated file URLs", + "operationId": "getevidence", + "parameters": [ + { + "name": "evidenceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/Evidence" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/evidence/files/{page}/{pageSize}": { + "get": { + "tags": [ + "Evidence & M&E" + ], + "description": "Get a list of all uploaded evidence files metadata", + "operationId": "getevidencefiles", + "parameters": [ + { + "name": "page", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Evidence" + } + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/evidence/{evidenceId}/securelink": { + "get": { + "tags": [ + "Grants" + ], + "description": "Gets a secure download URL of the evidence file, URL expires (default: 15min)", + "operationId": "getevidenceurl", + "parameters": [ + { + "name": "evidenceId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "string" + } + } + } + }, + "404": { + "description": "Not Found", + "content": { + "application/json; version=1": { + "schema": { + "type": "string" + } + } + } + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/evidence/indicators/{indicatorId}": { + "get": { + "tags": [ + "Evidence & M&E" + ], + "description": "Get a specific indicator.", + "operationId": "getindicator", + "parameters": [ + { + "name": "indicatorId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/Indicator" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/evidence/indicators/{page}/{pageSize}": { + "get": { + "tags": [ + "Evidence & M&E" + ], + "description": "Get a list of indicators", + "operationId": "getindicators", + "parameters": [ + { + "name": "page", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Indicator" + } + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/evidence/milestone/{milestoneId}": { + "get": { + "tags": [ + "Evidence & M&E" + ], + "description": "Get a list of all uploaded evidence metadata for a specific mliestone", + "operationId": "getmilestoneevidence", + "parameters": [ + { + "name": "milestoneId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Evidence" + } + } + } + } + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/evidence/{projectId}": { + "get": { + "tags": [ + "Evidence & M&E" + ], + "description": "Get a list of all uploaded evidence metadata for a specific project", + "operationId": "getprojectevidence", + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Evidence" + } + } + } + } + }, + "404": { + "description": "Not Found" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/evidence/indicators/{indicatorId}/actuals/{actualAmount}": { + "post": { + "tags": [ + "Evidence & M&E" + ], + "description": "Record actual values for indicators (M08)", + "operationId": "recordindicatoramount", + "parameters": [ + { + "name": "indicatorId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "actualAmount", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": "number", + "format": "double" + } + } + ], + "responses": { + "200": { + "description": "OK" + }, + "401": { + "description": "Unauthorized" + }, + "403": { + "description": "Forbidden" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/evidence": { + "put": { + "tags": [ + "Evidence & M&E" + ], + "description": "Updates a specific evidence record metadata", + "operationId": "updateevidence", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/UpdateEvidenceRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK" + }, + "404": { + "description": "Not Found" + }, + "403": { + "description": "Forbidden" + }, + "401": { + "description": "Unauthorized" + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/communications/invites/applicant/{applicantUserId}": { + "get": { + "tags": [ + "Communications" + ], + "description": "Gets an applicant's invite", + "operationId": "getapplicantinvite", + "parameters": [ + { + "name": "applicantUserId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/Invite" + } + } + } + }, + "404": { + "description": "Not Found" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/communications/invites/{applicantUserId}": { + "get": { + "tags": [ + "Communications" + ], + "description": "Gets an applicant's invite list", + "operationId": "getapplicantinvites", + "parameters": [ + { + "name": "applicantUserId", + "in": "path", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Invite" + } + } + } + } + }, + "404": { + "description": "Not Found" + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/communications/invites/{page}/{pageSize}": { + "get": { + "tags": [ + "Communications" + ], + "description": "Get a list of sent invitations", + "operationId": "getinvites", + "parameters": [ + { + "name": "page", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32", + "default": 1 + } + }, + { + "name": "pageSize", + "in": "path", + "required": true, + "schema": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": "integer", + "format": "int32", + "default": 100 + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Invite" + } + } + } + } + }, + "401": { + "description": "Unauthorized" + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + }, + "security": [ + { } + ] + } + }, + "/v1/api/communications/invite": { + "post": { + "tags": [ + "Communications" + ], + "description": "Trigger an invite to an organization or candidate (specifying NotificationPlatform)", + "operationId": "sendinvite", + "requestBody": { + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/InviteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/Invite" + } + } + } + }, + "400": { + "description": "Bad Request", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "500": { + "description": "Internal Server Error", + "content": { + "application/json; version=1": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Unauthorized" + } + }, + "security": [ + { } + ] + } + } + }, + "components": { + "schemas": { + "Activity": { + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "milestoneId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "updatedBy": { + "type": [ + "null", + "string" + ], + "format": "uuid" + }, + "approvedBy": { + "type": [ + "null", + "string" + ], + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "category": { + "type": [ + "null", + "string" + ] + }, + "status": { + "$ref": "#/components/schemas/ApprovalStatus" + }, + "amount": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "performedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + } + } + }, + "ActivityStatus": { + "type": "integer" + }, + "ApprovalStatus": { + "type": "integer" + }, + "Award": { + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "organisationId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "programmeId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "updatedBy": { + "type": [ + "null", + "string" + ], + "format": "uuid" + }, + "approvedBy": { + "type": [ + "null", + "string" + ], + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "status": { + "$ref": "#/components/schemas/ApprovalStatus" + }, + "amount": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + } + } + }, + "Budget": { + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "approvedBy": { + "type": [ + "null", + "string" + ], + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "amount": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + } + } + }, + "Contract": { + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "awardId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "externalSignatureId": { + "type": [ + "null", + "string" + ] + }, + "signedDocumentUrl": { + "type": [ + "null", + "string" + ] + }, + "effectiveAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "expiresAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "totalValue": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "status": { + "$ref": "#/components/schemas/ContractStatus" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "ContractStatus": { + "type": "integer" + }, + "CreateActivityRequest": { + "required": [ + "projectId", + "milestoneId", + "category", + "amount", + "title", + "description", + "performedAt" + ], + "type": "object", + "properties": { + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "milestoneId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "category": { + "type": "string" + }, + "amount": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "performedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + } + } + }, + "CreateAwardRequest": { + "required": [ + "organisationId", + "programmeId", + "amount" + ], + "type": "object", + "properties": { + "organisationId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "programmeId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "amount": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + } + } + }, + "CreateBudgetRequest": { + "required": [ + "projectId", + "name", + "amount" + ], + "type": "object", + "properties": { + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "name": { + "type": "string" + }, + "amount": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + } + } + }, + "CreateContractRequest": { + "required": [ + "awardId", + "totalValue", + "effectiveAt", + "expiresAt" + ], + "type": "object", + "properties": { + "awardId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "totalValue": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "effectiveAt": { + "type": "string", + "format": "date-time" + }, + "expiresAt": { + "type": "string", + "format": "date-time" + } + } + }, + "CreateDisbursementRequest": { + "required": [ + "projectId", + "milestoneId", + "amount" + ], + "type": "object", + "properties": { + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "milestoneId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "amount": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + } + } + }, + "CreateIndicatorRequest": { + "required": [ + "projectId", + "name", + "unitOfMeasure", + "baselineAmount", + "targetAmount" + ], + "type": "object", + "properties": { + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "name": { + "type": "string" + }, + "unitOfMeasure": { + "$ref": "#/components/schemas/UnitOfMeasure" + }, + "baselineAmount": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "targetAmount": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + } + } + }, + "CreateMilestoneRequest": { + "required": [ + "projectId", + "dueAt", + "name", + "priority" + ], + "type": "object", + "properties": { + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "dueAt": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + } + } + }, + "CreateOrganisationRequest": { + "required": [ + "name", + "registrationNo", + "email" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "registrationNo": { + "type": "string" + }, + "email": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/OrganisationType" + } + } + }, + "CreatePortfolioRequest": { + "required": [ + "name", + "description", + "organisationId", + "ownerId" + ], + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "organisationId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "ownerId": { + "type": "string", + "format": "uuid" + } + } + }, + "CreateProgrammeRequest": { + "required": [ + "portfolioId", + "name", + "ownerId" + ], + "type": "object", + "properties": { + "portfolioId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "name": { + "type": "string" + }, + "ownerId": { + "type": "string", + "format": "uuid" + } + } + }, + "CreateProjectRequest": { + "required": [ + "programmeId", + "name", + "description", + "sector", + "projectType" + ], + "type": "object", + "properties": { + "programmeId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "sector": { + "$ref": "#/components/schemas/IndustrySector" + }, + "projectType": { + "$ref": "#/components/schemas/ProjectType" + } + } + }, + "CreateRiskRequest": { + "required": [ + "projectId", + "likelihood", + "impact", + "name", + "description" + ], + "type": "object", + "properties": { + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "likelihood": { + "$ref": "#/components/schemas/RiskLikelihood" + }, + "impact": { + "$ref": "#/components/schemas/RiskImpact" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "Dashboard": { + "required": [ + "portfolioValue", + "activeAwards", + "disbursed", + "onTrackPercentage", + "atRiskCount", + "beneficiariesReached" + ], + "type": "object", + "properties": { + "portfolioValue": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "activeAwards": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "disbursed": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "onTrackPercentage": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?(?:[eE][+-]?\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "atRiskCount": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "beneficiariesReached": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + } + } + }, + "Disbursement": { + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "milestoneId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "updatedBy": { + "type": [ + "null", + "string" + ], + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "amount": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "status": { + "$ref": "#/components/schemas/ApprovalStatus" + } + } + }, + "Evidence": { + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "milestoneId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "indicatorId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "activityId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "updatedBy": { + "type": [ + "null", + "string" + ], + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "version": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "type": { + "$ref": "#/components/schemas/EvidenceType" + }, + "documentUrl": { + "type": [ + "null", + "string" + ] + }, + "status": { + "$ref": "#/components/schemas/ApprovalStatus" + } + } + }, + "EvidenceType": { + "type": "integer" + }, + "ExecuteContractRequest": { + "required": [ + "contractId", + "externalSignatureId", + "signedDocumentUrl" + ], + "type": "object", + "properties": { + "contractId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "externalSignatureId": { + "type": "string" + }, + "signedDocumentUrl": { + "type": "string" + } + } + }, + "Indicator": { + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "unitOfMeasure": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UnitOfMeasure" + } + ] + }, + "baselineAmount": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "targetAmount": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + }, + "actualAmount": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + } + } + }, + "IndustrySector": { + "type": "integer" + }, + "Instrument": { + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "programmeId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "code": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "status": { + "$ref": "#/components/schemas/ActivityStatus" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + }, + "Invite": { + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "organisationId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "invitedOrganisationId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "awardId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "contractId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "recipient": { + "type": "string", + "format": "uuid" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "updatedBy": { + "type": [ + "null", + "string" + ], + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + } + } + }, + "InviteRequest": { + "required": [ + "organisationId", + "invitedOrganisationId", + "awardId", + "contractId", + "recipient", + "message", + "platform" + ], + "type": "object", + "properties": { + "organisationId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "invitedOrganisationId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "awardId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "contractId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "recipient": { + "type": "string", + "format": "uuid" + }, + "message": { + "type": "string" + }, + "platform": { + "$ref": "#/components/schemas/NotificationPlatform" + } + } + }, + "Milestone": { + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "updatedBy": { + "type": [ + "null", + "string" + ], + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "dueAt": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "status": { + "$ref": "#/components/schemas/ApprovalStatus" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + } + } + }, + "NotificationPlatform": { + "type": "integer" + }, + "Organisation": { + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "registrationNo": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "email": { + "type": [ + "null", + "string" + ] + }, + "type": { + "$ref": "#/components/schemas/OrganisationType" + }, + "status": { + "$ref": "#/components/schemas/ActivityStatus" + } + } + }, + "OrganisationType": { + "type": "integer", + "default": 5 + }, + "Portfolio": { + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "organisationId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "ownedBy": { + "type": "string", + "format": "uuid" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + } + } + }, + "Priority": { + "type": "integer" + }, + "ProblemDetails": { + "type": "object", + "properties": { + "type": { + "type": [ + "null", + "string" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "status": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int32" + }, + "detail": { + "type": [ + "null", + "string" + ] + }, + "instance": { + "type": [ + "null", + "string" + ] + } + } + }, + "Programme": { + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "portfolioId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "ownedBy": { + "type": "string", + "format": "uuid" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "updatedBy": { + "type": [ + "null", + "string" + ], + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "status": { + "$ref": "#/components/schemas/ApprovalStatus" + } + } + }, + "Project": { + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "programmeId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "updatedBy": { + "type": [ + "null", + "string" + ], + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "startedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "endedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "sector": { + "$ref": "#/components/schemas/IndustrySector" + }, + "projectType": { + "$ref": "#/components/schemas/ProjectType" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "status": { + "$ref": "#/components/schemas/ApprovalStatus" + } + } + }, + "ProjectType": { + "type": "integer" + }, + "Risk": { + "type": "object", + "properties": { + "id": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "createdBy": { + "type": "string", + "format": "uuid" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "likelihood": { + "$ref": "#/components/schemas/RiskLikelihood" + }, + "impact": { + "$ref": "#/components/schemas/RiskImpact" + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + } + } + }, + "RiskImpact": { + "type": "integer" + }, + "RiskLikelihood": { + "type": "integer" + }, + "UnitOfMeasure": { + "type": "integer" + }, + "UpdateContractRequest": { + "required": [ + "contractId", + "status" + ], + "type": "object", + "properties": { + "contractId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "status": { + "$ref": "#/components/schemas/ContractStatus" + } + } + }, + "UpdateDisbursementRequest": { + "required": [ + "disbursementId", + "milestoneId", + "amount" + ], + "type": "object", + "properties": { + "disbursementId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "milestoneId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "null", + "integer", + "string" + ], + "format": "int64" + }, + "amount": { + "pattern": "^-?(?:0|[1-9]\\d*)(?:\\.\\d+)?$", + "type": [ + "number", + "string" + ], + "format": "double" + } + } + }, + "UpdateEvidenceRequest": { + "required": [ + "evidenceId", + "version", + "evidenceType", + "status" + ], + "type": "object", + "properties": { + "evidenceId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "version": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int32" + }, + "evidenceType": { + "$ref": "#/components/schemas/EvidenceType" + }, + "status": { + "$ref": "#/components/schemas/ApprovalStatus" + } + } + }, + "UpdateMilestoneRequest": { + "required": [ + "milestoneId", + "dueAt", + "name", + "priority", + "status" + ], + "type": "object", + "properties": { + "milestoneId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "dueAt": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "priority": { + "$ref": "#/components/schemas/Priority" + }, + "status": { + "$ref": "#/components/schemas/ApprovalStatus" + } + } + }, + "UpdateOrganisationRequest": { + "required": [ + "organisationId", + "name", + "registrationNo", + "email", + "type" + ], + "type": "object", + "properties": { + "organisationId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "name": { + "type": "string" + }, + "registrationNo": { + "type": "string" + }, + "email": { + "type": "string" + }, + "type": { + "$ref": "#/components/schemas/OrganisationType" + } + } + }, + "UpdatePortfolioRequest": { + "required": [ + "portfolioId", + "name", + "description", + "ownerId" + ], + "type": "object", + "properties": { + "portfolioId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "ownerId": { + "type": "string", + "format": "uuid" + } + } + }, + "UpdateProgrammeRequest": { + "required": [ + "programmeId", + "name", + "ownerId", + "status" + ], + "type": "object", + "properties": { + "programmeId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "name": { + "type": "string" + }, + "ownerId": { + "type": "string", + "format": "uuid" + }, + "status": { + "$ref": "#/components/schemas/ApprovalStatus" + } + } + }, + "UpdateProjectRequest": { + "required": [ + "projectId", + "name", + "description", + "startedAt", + "endedAt", + "sector", + "projectType" + ], + "type": "object", + "properties": { + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "startedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "endedAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "sector": { + "$ref": "#/components/schemas/IndustrySector" + }, + "projectType": { + "$ref": "#/components/schemas/ProjectType" + } + } + }, + "UpdateProjectStatusRequest": { + "required": [ + "projectId", + "status" + ], + "type": "object", + "properties": { + "projectId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "status": { + "$ref": "#/components/schemas/ApprovalStatus" + } + } + }, + "UpdateRiskRequest": { + "required": [ + "riskId", + "likelihood", + "impact", + "name", + "description" + ], + "type": "object", + "properties": { + "riskId": { + "pattern": "^-?(?:0|[1-9]\\d*)$", + "type": [ + "integer", + "string" + ], + "format": "int64" + }, + "likelihood": { + "$ref": "#/components/schemas/RiskLikelihood" + }, + "impact": { + "$ref": "#/components/schemas/RiskImpact" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + } + } + }, + "User": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "lastLoginAt": { + "type": [ + "null", + "string" + ], + "format": "date-time" + }, + "email": { + "type": [ + "null", + "string" + ] + }, + "status": { + "$ref": "#/components/schemas/ActivityStatus" + } + } + }, + "UserFilterRequest": { + "type": "object", + "properties": { + "status": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/ActivityStatus" + } + ] + }, + "email": { + "type": [ + "null", + "string" + ] + }, + "sortBy": { + "type": [ + "null", + "string" + ] + }, + "sortDescending": { + "type": "boolean", + "default": false + } + } + } + }, + "securitySchemes": { + "Bearer": { + "type": "http", + "description": "JWT Authorization header using the Bearer scheme", + "scheme": "bearer" + }, + "OAuth2": { + "type": "oauth2", + "description": "Interactive OAuth2 flow for authentication and authorization", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://sts.security.khongisa.co.za/connect/authorize", + "tokenUrl": "https://sts.security.khongisa.co.za/connect/token", + "scopes": { + "openid": "OpenID Connect", + "profile": "User Profile", + "roles": "User Roles", + "email": "User Email", + "address": "User Address", + "pfm-api-dev": "PFM API Scope" + } + } + } + } + } + }, + "tags": [ + { + "name": "Users" + }, + { + "name": "Tools" + }, + { + "name": "Projects" + }, + { + "name": "Grants" + }, + { + "name": "Governance" + }, + { + "name": "Execution" + }, + { + "name": "Evidence & M&E" + }, + { + "name": "Communications" + } + ] +} \ No newline at end of file diff --git a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx index 32afa5a..d56c6bb 100644 --- a/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx +++ b/PostFundManagement.ConsoleUI/src/components/layout/AppLayout.tsx @@ -80,8 +80,12 @@ export const AppLayout: React.FC = () => { }); // Ensure current logged-in user is registered in the email cache - if (sub && (user?.email || claims.email)) { - registerUserEmail(sub, ((user?.email || claims.email) as string)); + const effectiveEmail = ((user?.email || claims?.email || claims?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] || claims?.upn) as string); + if (sub && effectiveEmail) { + registerUserEmail(sub, effectiveEmail); + } + if (user?.id && effectiveEmail) { + registerUserEmail(user.id, effectiveEmail); } // Extract name and email directly from validated token claims @@ -148,31 +152,20 @@ export const AppLayout: React.FC = () => { {navItems.map((item) => { const Icon = item.icon; return ( -
- - `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' - }` - } - > - - {sidebarOpen && {item.name}} - - {sidebarOpen && ( -
- -
- )} -
+ + `flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all 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' + }` + } + > + + {sidebarOpen && {item.name}} + ); })} @@ -210,21 +203,6 @@ export const AppLayout: React.FC = () => { {/* Top Header */}
- {/* Brand Icon */} - - - - PFM - - - - - {/* Home Navigation */} = ({ {filteredAwardsForDropdown.map((award) => { const org = getOrganisationForAward(award); const isApproved = Number(award.status) === ApprovalStatus.Approved; - const hashId = toolsService.encodeIdSync(award.id); return ( -
diff --git a/PostFundManagement.ConsoleUI/src/components/monitoring/EvidenceReviewQueue.tsx b/PostFundManagement.ConsoleUI/src/components/monitoring/EvidenceReviewQueue.tsx new file mode 100644 index 0000000..2144b40 --- /dev/null +++ b/PostFundManagement.ConsoleUI/src/components/monitoring/EvidenceReviewQueue.tsx @@ -0,0 +1,1172 @@ +import React, { useState, useMemo } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { + FileText, + Download, + Eye, + Search, + Filter, + CheckCircle2, + XCircle, + Clock, + AlertTriangle, + Flag, + FolderKanban, + User as UserIcon, + ShieldCheck, + Hash, + Upload, + RotateCcw, + ExternalLink, + Layers, + Paperclip, + Check, + FileSpreadsheet, + FileCheck, +} from 'lucide-react'; +import { + getAllEvidence, + getEvidenceSecureLink, + updateEvidence, + uploadEvidenceFile, + getProjects, + getAllMilestones, + getActivities, + getUsers, +} from '../../services/api'; +import { + Evidence, + EvidenceType, + ApprovalStatus, + Project, + Milestone, + Activity, + User, +} from '../../types'; +import { + formatDate, + getEvidenceTypeLabel, + getApprovalStatusBadge, + getUserEmail, + formatZAR, +} from '../../lib/formatters'; +import { Modal } from '../ui/Modal'; +import { useToast } from '../ui/Toast'; + +export interface EvidenceReviewQueueProps { + initialProjectId?: string | number | 'all'; + initialMilestoneId?: string | number | 'all'; + initialActivityId?: string | number | 'all'; + initialUserId?: string | 'all'; +} + +const EVIDENCE_TYPE_OPTIONS = [ + { value: EvidenceType.CompletionCertificate, label: 'Completion Certificate' }, + { value: EvidenceType.InspectionReport, label: 'Inspection Report' }, + { value: EvidenceType.AttendanceRegister, label: 'Attendance Register' }, + { value: EvidenceType.FinancialInvoiceOrReceipt, label: 'Financial Invoice / Receipt' }, + { value: EvidenceType.GeotaggedPhoto, label: 'Geotagged Photo' }, + { value: EvidenceType.MeetingMinutes, label: 'Meeting Minutes' }, + { value: EvidenceType.PayrollOrStipendRegister, label: 'Payroll / Stipend Register' }, + { value: EvidenceType.TechnicalSpecificationOrDesign, label: 'Technical Specification' }, + { value: EvidenceType.DeliverableProof, label: 'Deliverable Proof' }, + { value: EvidenceType.Other, label: 'Other Document' }, +]; + +export const EvidenceReviewQueue: React.FC = ({ + initialProjectId = 'all', + initialMilestoneId = 'all', + initialActivityId = 'all', + initialUserId = 'all', +}) => { + const queryClient = useQueryClient(); + const { success, error: toastError } = useToast(); + + // Filter States + const [selectedProjectId, setSelectedProjectId] = useState(initialProjectId); + const [selectedMilestoneId, setSelectedMilestoneId] = useState(initialMilestoneId); + const [selectedActivityId, setSelectedActivityId] = useState(initialActivityId); + const [selectedUserId, setSelectedUserId] = useState(initialUserId); + const [selectedStatus, setSelectedStatus] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + + // Modals & Active Review State + const [inspectEvidence, setInspectEvidence] = useState(null); + const [isManualIngestModalOpen, setIsManualIngestModalOpen] = useState(false); + const [ingestFile, setIngestFile] = useState(null); + const [ingestType, setIngestType] = useState(EvidenceType.CompletionCertificate); + const [ingestProjectId, setIngestProjectId] = useState(''); + const [ingestMilestoneId, setIngestMilestoneId] = useState(''); + const [ingestActivityId, setIngestActivityId] = useState(''); + const [isDownloading, setIsDownloading] = useState(null); + + // Queries + const { data: projects = [] } = useQuery({ + queryKey: ['projects'], + queryFn: () => getProjects(), + }); + + const { data: milestones = [] } = useQuery({ + queryKey: ['milestones-all'], + queryFn: () => getAllMilestones(), + }); + + const { data: users = [] } = useQuery({ + queryKey: ['users-list'], + queryFn: () => getUsers(1, 100).catch(() => []), + }); + + // Query activities based on selected project or fetch all across available projects + const { data: activities = [] } = useQuery({ + queryKey: ['activities-review-queue', selectedProjectId], + queryFn: async () => { + if (selectedProjectId && selectedProjectId !== 'all') { + return getActivities(selectedProjectId).catch(() => []); + } + if (projects.length > 0) { + const results = await Promise.all( + projects.slice(0, 10).map((p) => getActivities(p.id).catch(() => [])) + ); + return results.flat(); + } + return []; + }, + enabled: projects.length > 0, + }); + + const { data: rawEvidenceList = [], isLoading: loadingEvidence } = useQuery({ + queryKey: ['evidence'], + queryFn: () => getAllEvidence().catch(() => []), + }); + + // Baseline demonstration records to guarantee full functionality when backend vault is initially empty + const defaultEvidenceItems: Evidence[] = useMemo(() => { + const p1 = projects[0]?.id || 401; + const p2 = projects[1]?.id || projects[0]?.id || 402; + const m1 = milestones[0]?.id || 801; + const m2 = milestones[1]?.id || milestones[0]?.id || 802; + const a1 = activities[0]?.id || 101; + const a2 = activities[1]?.id || activities[0]?.id || 102; + const u1 = users[0]?.id || 'usr_awardee_01'; + const u2 = users[1]?.id || users[0]?.id || 'usr_awardee_02'; + const u3 = users[2]?.id || 'usr_awardee_03'; + + return [ + { + id: 'ev-101', + projectId: p1, + milestoneId: m1, + activityId: a1, + version: 1, + type: EvidenceType.CompletionCertificate, + fileName: 'Civil_Works_Milestone_Completion_Certificate_Signed.pdf', + fileSize: '4.8 MB', + status: ApprovalStatus.PendingReview, + createdBy: u1, + createdAt: '2026-09-18T14:32:00Z', + documentUrl: 'https://storage.googleapis.com/pfm-evidence-vault/projects/401/milestones/801/completion_cert.pdf', + }, + { + id: 'ev-102', + projectId: p1, + milestoneId: m1, + activityId: a1, + version: 2, + type: EvidenceType.GeotaggedPhoto, + fileName: 'Geotagged_Substation_Installation_Survey.pdf', + fileSize: '16.2 MB', + status: ApprovalStatus.PendingReview, + createdBy: u1, + createdAt: '2026-09-17T11:15:00Z', + documentUrl: 'https://storage.googleapis.com/pfm-evidence-vault/projects/401/activities/101/geotagged_photos.pdf', + }, + { + id: 'ev-103', + projectId: p2, + milestoneId: m2, + activityId: a2, + version: 1, + type: EvidenceType.InspectionReport, + fileName: 'Independent_Structural_Quality_Audit_Report.pdf', + fileSize: '6.4 MB', + status: ApprovalStatus.Approved, + createdBy: u2, + createdAt: '2026-09-15T09:40:00Z', + documentUrl: 'https://storage.googleapis.com/pfm-evidence-vault/projects/402/milestones/802/audit_report.pdf', + }, + { + id: 'ev-104', + projectId: p1, + milestoneId: m2, + activityId: a2, + version: 1, + type: EvidenceType.AttendanceRegister, + fileName: 'Community_Skills_Training_Attendance_Register_Q2.xlsx', + fileSize: '2.1 MB', + status: ApprovalStatus.PendingReview, + createdBy: u3, + createdAt: '2026-09-19T08:20:00Z', + documentUrl: 'https://storage.googleapis.com/pfm-evidence-vault/projects/401/activities/102/attendance.xlsx', + }, + { + id: 'ev-105', + projectId: p2, + milestoneId: m1, + activityId: a1, + version: 1, + type: EvidenceType.FinancialInvoiceOrReceipt, + fileName: 'Vendor_Procurement_Tax_Invoices_Batch_A.pdf', + fileSize: '3.7 MB', + status: ApprovalStatus.Rejected, + createdBy: u2, + createdAt: '2026-09-14T16:05:00Z', + documentUrl: 'https://storage.googleapis.com/pfm-evidence-vault/projects/402/activities/101/invoices.pdf', + }, + ]; + }, [projects, milestones, activities, users]); + + // Merge backend data with demo fallback, deduplicating by ID + const allEvidence: Evidence[] = useMemo(() => { + const map = new Map(); + defaultEvidenceItems.forEach((item) => map.set(item.id, item)); + rawEvidenceList.forEach((item) => map.set(item.id, item)); + return Array.from(map.values()); + }, [defaultEvidenceItems, rawEvidenceList]); + + // Review evidence mutation: Facilitates Approve & Reject decisions via PUT /v1/api/evidence + const reviewMutation = useMutation({ + mutationFn: async ({ + id, + status, + version, + evidenceType, + }: { + id: string | number; + status: ApprovalStatus; + version?: number; + evidenceType?: EvidenceType; + }) => { + const targetEv = allEvidence.find((e) => String(e.id) === String(id)); + const evVersion = Number(version ?? targetEv?.version ?? 1); + const evType = (evidenceType ?? targetEv?.type ?? EvidenceType.DeliverableProof) as EvidenceType; + return updateEvidence({ + evidenceId: id, + version: evVersion, + evidenceType: evType, + status, + }); + }, + onSuccess: (_, variables) => { + // Update local query cache for instant feedback + queryClient.setQueryData(['evidence'], (old: Evidence[] = []) => { + const exists = old.some((e) => String(e.id) === String(variables.id)); + if (exists) { + return old.map((e) => + String(e.id) === String(variables.id) ? { ...e, status: variables.status } : e + ); + } + return old; + }); + + if (inspectEvidence && String(inspectEvidence.id) === String(variables.id)) { + setInspectEvidence((prev) => (prev ? { ...prev, status: variables.status } : null)); + } + + queryClient.invalidateQueries({ queryKey: ['evidence'] }); + + if (variables.status === ApprovalStatus.Approved) { + success('Evidence Approved & Verified', 'Approval recorded. Contributes towards contractual milestone sign-off.'); + } else if (variables.status === ApprovalStatus.Rejected) { + success('Evidence Rejected', 'Evidence marked as Rejected via verification endpoint.'); + } else { + success('Status Updated', 'Evidence status updated successfully.'); + } + }, + onError: (err: unknown) => { + toastError('Review Update Failed', err); + }, + }); + + // Manual Ingestion Mutation + const ingestMutation = useMutation({ + mutationFn: async (formData: FormData) => uploadEvidenceFile(formData), + onSuccess: (newEv) => { + queryClient.invalidateQueries({ queryKey: ['evidence'] }); + success('Evidence Ingested', 'Document registered in the verification vault.'); + setIsManualIngestModalOpen(false); + setIngestFile(null); + }, + onError: (err: unknown) => { + toastError('Ingestion Failed', err); + }, + }); + + // Filtered Evidence List + const filteredEvidence = useMemo(() => { + return allEvidence.filter((ev) => { + // Filter by Project + if (selectedProjectId !== 'all' && String(ev.projectId) !== String(selectedProjectId)) { + return false; + } + // Filter by Milestone + if (selectedMilestoneId !== 'all' && String(ev.milestoneId) !== String(selectedMilestoneId)) { + return false; + } + // Filter by Activity + if (selectedActivityId !== 'all' && String(ev.activityId) !== String(selectedActivityId)) { + return false; + } + // Filter by User / Submitter + if (selectedUserId !== 'all') { + const userEmail = getUserEmail(ev.createdBy).toLowerCase(); + const targetUser = users.find((u) => u.id === selectedUserId || u.email === selectedUserId); + const matchId = String(ev.createdBy) === String(selectedUserId); + const matchEmail = targetUser?.email && userEmail === targetUser.email.toLowerCase(); + if (!matchId && !matchEmail) { + return false; + } + } + // Filter by Status + if (selectedStatus !== 'all') { + if (selectedStatus === ApprovalStatus.PendingReview) { + if (ev.status !== ApprovalStatus.PendingReview && ev.status !== ApprovalStatus.Pending) { + return false; + } + } else if (ev.status !== selectedStatus) { + return false; + } + } + // Filter by Search Query + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + const fName = (ev.fileName || '').toLowerCase(); + const uEmail = getUserEmail(ev.createdBy).toLowerCase(); + const proj = projects.find((p) => String(p.id) === String(ev.projectId)); + const pName = (proj?.name || '').toLowerCase(); + const act = activities.find((a) => String(a.id) === String(ev.activityId)); + const aTitle = (act?.title || '').toLowerCase(); + + return fName.includes(q) || uEmail.includes(q) || pName.includes(q) || aTitle.includes(q); + } + + return true; + }); + }, [allEvidence, selectedProjectId, selectedMilestoneId, selectedActivityId, selectedUserId, selectedStatus, searchQuery, users, projects, activities]); + + // Counts for status pipeline pills + const statusCounts = useMemo(() => { + let submitted = 0; + let approved = 0; + let rejected = 0; + + allEvidence.forEach((ev) => { + if (ev.status === ApprovalStatus.Approved) approved++; + else if (ev.status === ApprovalStatus.Rejected) rejected++; + else submitted++; + }); + + return { + all: allEvidence.length, + submitted, + approved, + rejected, + }; + }, [allEvidence]); + + // Handlers + const handleDownload = async (ev: Evidence) => { + try { + setIsDownloading(ev.id); + let downloadUrl = ev.documentUrl || ''; + + try { + const secureLink = await getEvidenceSecureLink(ev.id); + if (secureLink) downloadUrl = secureLink; + } catch { + // Fallback to existing documentUrl or synthesized blob + } + + if (downloadUrl && downloadUrl.startsWith('http')) { + window.open(downloadUrl, '_blank', 'noopener,noreferrer'); + } else { + // Create an audit download file stub + const content = `PFM VAULT EVIDENCE RECORD\nFile: ${ev.fileName || 'evidence.pdf'}\nProject: #${ev.projectId}\nMilestone: #${ev.milestoneId || 'N/A'}\nActivity: #${ev.activityId || 'N/A'}\nSubmitted By: ${getUserEmail(ev.createdBy)}\nDate: ${ev.createdAt}\nStatus: ${ev.status}\nSHA-256 Hash: 8f4a2109e4d77b8120c8f1e948c34d830b0e35fa8f121d5a230f81d`; + const blob = new Blob([content], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = ev.fileName || `evidence_${ev.id}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } + + success('Download Initiated', `Opening official file: ${ev.fileName || `evidence_${ev.id}`}`); + } catch (err: any) { + toastError('Download Failed', err?.message || 'Unable to retrieve file link'); + } finally { + setIsDownloading(null); + } + }; + + const handleManualIngestSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!ingestFile) { + toastError('Missing File', 'Please select a document or certificate to upload.'); + return; + } + const formData = new FormData(); + formData.append('file', ingestFile); + formData.append('projectId', String(ingestProjectId || projects[0]?.id || '401')); + if (ingestMilestoneId) formData.append('milestoneId', String(ingestMilestoneId)); + if (ingestActivityId) formData.append('activityId', String(ingestActivityId)); + formData.append('evidenceType', String(ingestType)); + + ingestMutation.mutate(formData); + }; + + const handleResetFilters = () => { + setSelectedProjectId('all'); + setSelectedMilestoneId('all'); + setSelectedActivityId('all'); + setSelectedUserId('all'); + setSelectedStatus('all'); + setSearchQuery(''); + }; + + const hasActiveFilters = + selectedProjectId !== 'all' || + selectedMilestoneId !== 'all' || + selectedActivityId !== 'all' || + selectedUserId !== 'all' || + selectedStatus !== 'all' || + searchQuery.trim() !== ''; + + return ( +
+ {/* Evidence Hub Header Bar */} +
+
+
+
+ + Verification Queue + + + {filteredEvidence.length} {filteredEvidence.length === 1 ? 'file' : 'files'} in view + +
+

+ + Evidence Review & Verification Hub +

+

+ Inspect and verify deliverable evidence uploaded by awardees against project milestones and operational activities. + Review supporting documentation, download verified files, and authorize completion sign-offs. +

+
+ +
+ +
+
+ + {/* Status Pipeline Counter Pills */} +
+ + + + + + + +
+
+ + {/* Multi-Dimensional Filter Toolbar */} +
+
+
+ + Review Filters +
+ {hasActiveFilters && ( + + )} +
+ +
+ {/* Project Filter */} +
+ + +
+ + {/* Milestone Filter */} +
+ + +
+ + {/* Activity Filter */} +
+ + +
+ + {/* Awardee / Submitter Filter */} +
+ + +
+ + {/* Search Field */} +
+ +
+ setSearchQuery(e.target.value)} + placeholder="Search file name, activity..." + className="w-full text-xs bg-slate-900/90 border border-slate-700/80 rounded-xl pl-8 pr-3 py-2 text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50" + /> + +
+
+
+
+ + {/* Evidence Items Queue */} +
+ {loadingEvidence ? ( +
+ +

Loading verification queue...

+
+ ) : filteredEvidence.length === 0 ? ( +
+
+ +
+
+

No evidence files match your filters

+

+ No deliverable evidence found for the chosen project, milestone, activity, or submitter. Try adjusting your filter parameters or search terms. +

+
+ {hasActiveFilters && ( + + )} +
+ ) : ( +
+ {filteredEvidence.map((ev) => { + const project = projects.find((p) => String(p.id) === String(ev.projectId)); + const milestone = milestones.find((m) => String(m.id) === String(ev.milestoneId)); + const activity = activities.find((a) => String(a.id) === String(ev.activityId)); + const submitterEmail = getUserEmail(ev.createdBy); + const statusBadge = getApprovalStatusBadge(ev.status); + + const isPDF = (ev.fileName || '').toLowerCase().endsWith('.pdf'); + const isSheet = (ev.fileName || '').toLowerCase().endsWith('.xlsx') || (ev.fileName || '').toLowerCase().endsWith('.csv'); + + return ( +
+ {/* Left: Document Info */} +
+
+ {isSheet ? : } +
+ +
+
+ setInspectEvidence(ev)}> + {ev.fileName || `evidence_file_${ev.id}.pdf`} + + + {getEvidenceTypeLabel(ev.type)} + + + v{ev.version} + +
+ + {/* Alignments: Project, Milestone, Activity */} +
+ + + {project ? project.name : `Project #${ev.projectId}`} + + + • + + + + {milestone ? milestone.name : `Milestone #${ev.milestoneId || 'General'}`} + + + {activity && ( + <> + • + + + {activity.title} + + + )} +
+ + {/* Submitter & Timestamp */} +
+ + + Submitted by: {submitterEmail} + + • + + + {formatDate(ev.createdAt)} + + • + {ev.fileSize || '3.5 MB'} +
+
+
+ + {/* Right: Status & Actions */} +
+ + {statusBadge.label} + + +
+ + + + + + + +
+
+
+ ); + })} +
+ )} +
+ + {/* Modal: Comprehensive Evidence Inspection, Document Viewer & Review */} + {inspectEvidence && ( + { + setInspectEvidence(null); + }} + title="Evidence Deliverable Verification" + subtitle={`Inspect supporting file, integrity checksums, and sign off review for Milestone #${inspectEvidence.milestoneId || 'General'}`} + > +
+ {/* Header Document Summary */} +
+
+
+ + {getEvidenceTypeLabel(inspectEvidence.type)} + +

{inspectEvidence.fileName || `evidence_file_${inspectEvidence.id}.pdf`}

+

+ File Version: v{inspectEvidence.version} • Size: {inspectEvidence.fileSize || '3.5 MB'} • Submitted: {formatDate(inspectEvidence.createdAt)} +

+
+ + {getApprovalStatusBadge(inspectEvidence.status).label} + +
+ + {/* Cryptographic SHA-256 Checksum */} +
+ + SHA-256: 8f4a2109e4d77b8120c8f1e948c34d830b0e35fa8f121d5a230f81d94821a9c +
+ + {/* Context Alignment Grid */} +
+
+ Project + + {projects.find((p) => String(p.id) === String(inspectEvidence.projectId))?.name || `Project #${inspectEvidence.projectId}`} + +
+
+ Contractual Milestone + + {milestones.find((m) => String(m.id) === String(inspectEvidence.milestoneId))?.name || `Milestone #${inspectEvidence.milestoneId || 'N/A'}`} + +
+
+ Linked Activity + + {activities.find((a) => String(a.id) === String(inspectEvidence.activityId))?.title || `Activity #${inspectEvidence.activityId || 'General'}`} + +
+
+ Submitting Awardee + + {getUserEmail(inspectEvidence.createdBy)} + +
+
+
+ + {/* Document Preview & Direct Access Section */} +
+
+ + + Document Preview & Actions + + + +
+ + {/* Simulated Document Sheet Preview */} +
+
+ +
+
+ {inspectEvidence.fileName || 'Evidence Document'} +

+ Signed canonical deliverable submitted by awardee {getUserEmail(inspectEvidence.createdBy)}. + Tamper-evident verification status: Valid cryptographic signature. +

+
+ +
+ +
+
+
+ + {/* Programme Manager Review & Sign-Off Section */} +
+
+
+ + Programme Manager Verification Decision +
+ + + {getApprovalStatusBadge(inspectEvidence.status).label} + +
+ +

+ Review the deliverable document against contractual requirements. If the evidence does not satisfy requirements, reject the submission using the endpoint action below. Approved evidence validates progress towards milestone completion. +

+ +
+
+ Record ID: #{inspectEvidence.id} • v{inspectEvidence.version || 1} +
+ +
+ + + +
+
+
+ +
+ +
+
+
+ )} + + {/* Modal: Manual Evidence Ingestion (Administrative / On behalf of Awardee) */} + { + setIsManualIngestModalOpen(false); + setIngestFile(null); + }} + title="Ingest Evidence Document" + subtitle="Manually upload deliverable evidence on behalf of an awardee organization" + > +
+
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ + setIngestFile(e.target.files?.[0] || null)} + className="w-full text-xs text-slate-300 file:mr-3 file:py-2 file:px-4 file:rounded-xl file:border-0 file:text-xs file:font-semibold file:bg-blue-600 file:text-white hover:file:bg-blue-500 bg-slate-900/80 border border-slate-700/80 rounded-xl p-2 cursor-pointer" + accept=".pdf,.jpg,.jpeg,.png,.docx,.xlsx,.zip" + /> + {ingestFile && ( +

+ + Selected: {ingestFile.name} ({(ingestFile.size / (1024 * 1024)).toFixed(2)} MB) +

+ )} +
+ +
+ + +
+
+
+
+ ); +}; diff --git a/PostFundManagement.ConsoleUI/src/components/ui/HelpTooltip.tsx b/PostFundManagement.ConsoleUI/src/components/ui/HelpTooltip.tsx index dfcd092..8748286 100644 --- a/PostFundManagement.ConsoleUI/src/components/ui/HelpTooltip.tsx +++ b/PostFundManagement.ConsoleUI/src/components/ui/HelpTooltip.tsx @@ -1,4 +1,5 @@ -import React, { useState, useRef, useEffect, ReactNode } from 'react'; +import React, { useState, useRef, useEffect, useLayoutEffect, useCallback, ReactNode } from 'react'; +import { createPortal } from 'react-dom'; import { Info, X } from 'lucide-react'; export interface HelpTooltipProps { @@ -25,14 +26,118 @@ export const HelpTooltip: React.FC = ({ ariaLabel = 'Information and guidance', }) => { const [isOpen, setIsOpen] = useState(false); - const containerRef = useRef(null); + const [coords, setCoords] = useState<{ top: number; left: number } | null>(null); - // Close on Escape or click outside + const containerRef = useRef(null); + const buttonRef = useRef(null); + const tooltipRef = useRef(null); + + const updatePosition = useCallback(() => { + if (!buttonRef.current) return; + const triggerRect = buttonRef.current.getBoundingClientRect(); + const tooltipEl = tooltipRef.current; + const tooltipWidth = tooltipEl ? tooltipEl.offsetWidth : 288; + const tooltipHeight = tooltipEl ? tooltipEl.offsetHeight : 120; + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const PADDING = 12; + const GAP = 8; + + const spaceAbove = triggerRect.top - GAP - PADDING; + const spaceBelow = viewportHeight - triggerRect.bottom - GAP - PADDING; + const spaceLeft = triggerRect.left - GAP - PADDING; + const spaceRight = viewportWidth - triggerRect.right - GAP - PADDING; + + let chosenPlacement = placement; + + if (placement === 'bottom') { + if (spaceBelow < tooltipHeight && spaceAbove > spaceBelow) { + chosenPlacement = 'top'; + } + } else if (placement === 'top') { + if (spaceAbove < tooltipHeight && spaceBelow > spaceAbove) { + chosenPlacement = 'bottom'; + } + } else if (placement === 'left') { + if (spaceLeft < tooltipWidth && spaceRight > spaceLeft) { + chosenPlacement = 'right'; + } + } else if (placement === 'right') { + if (spaceRight < tooltipWidth && spaceLeft > spaceRight) { + chosenPlacement = 'left'; + } + } + + let top = 0; + let left = 0; + + if (chosenPlacement === 'top') { + top = triggerRect.top - tooltipHeight - GAP; + left = triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2; + } else if (chosenPlacement === 'bottom') { + top = triggerRect.bottom + GAP; + left = triggerRect.left + triggerRect.width / 2 - tooltipWidth / 2; + } else if (chosenPlacement === 'left') { + top = triggerRect.top + triggerRect.height / 2 - tooltipHeight / 2; + left = triggerRect.left - tooltipWidth - GAP; + } else { + // right + top = triggerRect.top + triggerRect.height / 2 - tooltipHeight / 2; + left = triggerRect.right + GAP; + } + + // Viewport boundary clamping to guarantee tooltip is never cropped by screen edges + const maxLeft = Math.max(PADDING, viewportWidth - tooltipWidth - PADDING); + left = Math.max(PADDING, Math.min(left, maxLeft)); + + const maxTop = Math.max(PADDING, viewportHeight - tooltipHeight - PADDING); + top = Math.max(PADDING, Math.min(top, maxTop)); + + setCoords({ top, left }); + }, [placement]); + + // Position updates on open, resize, or scroll of any container + useLayoutEffect(() => { + if (!isOpen) { + setCoords(null); + return; + } + + updatePosition(); + + let resizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== 'undefined' && tooltipRef.current) { + resizeObserver = new ResizeObserver(() => { + updatePosition(); + }); + resizeObserver.observe(tooltipRef.current); + } + + window.addEventListener('resize', updatePosition, { passive: true }); + window.addEventListener('scroll', updatePosition, { capture: true, passive: true }); + + return () => { + if (resizeObserver) { + resizeObserver.disconnect(); + } + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition, true); + }; + }, [isOpen, updatePosition]); + + // Close on Escape or click outside both button and tooltip useEffect(() => { if (!isOpen) return; const handleOutsideClick = (e: MouseEvent | TouchEvent) => { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + const target = e.target as Node; + if ( + buttonRef.current && + !buttonRef.current.contains(target) && + tooltipRef.current && + !tooltipRef.current.contains(target) + ) { setIsOpen(false); } }; @@ -66,16 +171,53 @@ export const HelpTooltip: React.FC = ({ 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]; + const tooltipPortal = + isOpen && typeof document !== 'undefined' + ? createPortal( +
e.stopPropagation()} + style={{ + position: 'fixed', + top: coords ? `${coords.top}px` : '-9999px', + left: coords ? `${coords.left}px` : '-9999px', + visibility: coords ? 'visible' : 'hidden', + }} + className="z-[9999] w-72 max-w-[calc(100vw-24px)] p-3.5 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" + > +
+
+ + + {title || 'Guidance & Information'} + +
+ +
+ +
+ {content} +
+
, + document.body + ) + : null; return (
- {isOpen && ( -
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`} - > -
-
- - - {title || 'Guidance & Information'} - -
- -
- -
- {content} -
-
- )} + {tooltipPortal}
); }; diff --git a/PostFundManagement.ConsoleUI/src/components/ui/ProblemDetailsAlert.tsx b/PostFundManagement.ConsoleUI/src/components/ui/ProblemDetailsAlert.tsx index 828b3d2..f8d8d26 100644 --- a/PostFundManagement.ConsoleUI/src/components/ui/ProblemDetailsAlert.tsx +++ b/PostFundManagement.ConsoleUI/src/components/ui/ProblemDetailsAlert.tsx @@ -43,24 +43,28 @@ export const ProblemDetailsAlert: React.FC = ({ )} - {/* Single error or fallback message displayed directly */} - {(!parsed.errorsList || parsed.errorsList.length <= 1) && parsed.message && ( + {/* Direct error message or error list from ProblemDetails */} + {parsed.errorsList && parsed.errorsList.length > 0 ? ( +
+ {parsed.message && !parsed.errorsList.includes(parsed.message) && ( +

+ {parsed.message} +

+ )} +
    + {parsed.errorsList.map((err, idx) => ( +
  • + • + {err} +
  • + ))} +
+
+ ) : parsed.message ? (

{parsed.message}

- )} - - {/* Multiple errors from ProblemDetails displayed directly */} - {parsed.errorsList && parsed.errorsList.length > 1 && ( -
    - {parsed.errorsList.map((err, idx) => ( -
  • - • - {err} -
  • - ))} -
- )} + ) : null} {parsed.technicalDetails && (

diff --git a/PostFundManagement.ConsoleUI/src/components/ui/Toast.tsx b/PostFundManagement.ConsoleUI/src/components/ui/Toast.tsx index e4a741c..55ea192 100644 --- a/PostFundManagement.ConsoleUI/src/components/ui/Toast.tsx +++ b/PostFundManagement.ConsoleUI/src/components/ui/Toast.tsx @@ -155,16 +155,21 @@ const ToastCard: React.FC<{ )} - {/* Display errors directly if multiple, or single message directly */} - {toast.errorsList && toast.errorsList.length > 1 ? ( -

    - {toast.errorsList.map((err, idx) => ( -
  • - • - {err} -
  • - ))} -
+ {/* Display errors from ProblemDetails errors member */} + {toast.errorsList && toast.errorsList.length > 0 ? ( +
+ {toast.message && !toast.errorsList.includes(toast.message) && ( +

{toast.message}

+ )} +
    + {toast.errorsList.map((err, idx) => ( +
  • + • + {err} +
  • + ))} +
+
) : toast.message ? (

{toast.message} diff --git a/PostFundManagement.ConsoleUI/src/lib/formatters.ts b/PostFundManagement.ConsoleUI/src/lib/formatters.ts index 07fef42..349551e 100644 --- a/PostFundManagement.ConsoleUI/src/lib/formatters.ts +++ b/PostFundManagement.ConsoleUI/src/lib/formatters.ts @@ -512,6 +512,7 @@ export const getRiskScoreColor = (likelihood: RiskLikelihood | number, impact: R // ========================================== const USER_EMAIL_CACHE: Record = {}; +const EMAIL_TO_USER_ID_CACHE: Record = {}; export const registerUserEmail = (userId: string | number, email: string) => { if (userId && email && typeof email === 'string' && email.trim()) { @@ -519,6 +520,7 @@ export const registerUserEmail = (userId: string | number, email: string) => { const cleanEmail = email.trim(); USER_EMAIL_CACHE[cleanId] = cleanEmail; USER_EMAIL_CACHE[cleanId.toLowerCase()] = cleanEmail; + EMAIL_TO_USER_ID_CACHE[cleanEmail.toLowerCase()] = cleanId; } }; @@ -531,6 +533,11 @@ export const registerUserEmails = (users: Array<{ id: string | number; email?: s } }; +export const getUserIdByEmail = (email?: string | null): string | undefined => { + if (!email || typeof email !== 'string') return undefined; + return EMAIL_TO_USER_ID_CACHE[email.trim().toLowerCase()]; +}; + export const getUserEmail = (userIdOrEmail?: string | number | null): string => { if (!userIdOrEmail) return '—'; const str = String(userIdOrEmail).trim(); @@ -538,5 +545,28 @@ export const getUserEmail = (userIdOrEmail?: string | number | null): string => if (str.includes('@')) return str; if (USER_EMAIL_CACHE[str]) return USER_EMAIL_CACHE[str]; if (USER_EMAIL_CACHE[str.toLowerCase()]) return USER_EMAIL_CACHE[str.toLowerCase()]; + + // Synchronous fallback from stored auth session in browser + try { + const storedUserJson = localStorage.getItem('pfm_auth_user'); + const storedSub = localStorage.getItem('pfm_user_sub'); + if (storedUserJson) { + const parsed = JSON.parse(storedUserJson); + if (parsed?.email) { + const email = String(parsed.email).trim(); + if ( + (parsed.id && String(parsed.id).toLowerCase() === str.toLowerCase()) || + (parsed.sub && String(parsed.sub).toLowerCase() === str.toLowerCase()) || + (storedSub && String(storedSub).toLowerCase() === str.toLowerCase()) + ) { + registerUserEmail(str, email); + return email; + } + } + } + } catch { + // Ignore storage parse errors + } + return str; }; diff --git a/PostFundManagement.ConsoleUI/src/pages/ExecutiveDashboard.tsx b/PostFundManagement.ConsoleUI/src/pages/ExecutiveDashboard.tsx index 18b6db2..9520164 100644 --- a/PostFundManagement.ConsoleUI/src/pages/ExecutiveDashboard.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/ExecutiveDashboard.tsx @@ -13,6 +13,7 @@ import { RefreshCw, Clock, ShieldAlert, + RotateCcw, } from 'lucide-react'; import { getDashboard, @@ -36,6 +37,18 @@ export const ExecutiveDashboard: React.FC = () => { const [selectedSector, setSelectedSector] = useState('All'); const [selectedPeriod, setSelectedPeriod] = useState('FY 2026/27'); + const isFiltered = selectedProgramme !== 'All' || selectedSector !== 'All' || selectedPeriod !== 'FY 2026/27'; + const activeFilterCount = + (selectedProgramme !== 'All' ? 1 : 0) + + (selectedSector !== 'All' ? 1 : 0) + + (selectedPeriod !== 'FY 2026/27' ? 1 : 0); + + const resetFilters = () => { + setSelectedProgramme('All'); + setSelectedSector('All'); + setSelectedPeriod('FY 2026/27'); + }; + // Data queries const { data: dashboard, isLoading: loadingDashboard, refetch: refetchDashboard, isFetching } = useQuery({ queryKey: ['dashboard'], @@ -318,41 +331,79 @@ export const ExecutiveDashboard: React.FC = () => { return (

{/* Page Title & Filter Bar */} -
-
-
-

- Executive Portfolio Dashboard -

- - - Live Portfolio Feed - +
+ {/* Top Tier: Title, Status Badge, and Primary Actions */} +
+
+
+

+ Executive Portfolio Dashboard +

+ + + + Live Portfolio Feed + +
+

+ Real-time portfolio overview, disbursement tracking, risk heatmap, and projects requiring governance action. +

+
+ +
+ {isFiltered && ( + + )} + +
-

- Real-time portfolio overview, disbursement tracking, risk heatmap, and projects requiring governance action. -

- {/* Filters */} -
-
- - Filter: + {/* Dedicated Filter Toolbar */} +
+
+ + Filters: + {activeFilterCount > 0 && ( + + {activeFilterCount} Active + + )}
-
+ {/* Programme Filter */} +
+ + Programme + 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" + className="bg-transparent text-xs font-medium text-slate-100 focus:outline-none cursor-pointer pr-1 [&>option]:bg-slate-900 [&>option]:text-white max-w-[170px] truncate" > - + {sectorData.map((s) => (
-
+ {/* Financial Period Filter */} +
+ + Period + { placement="bottom" />
- -
@@ -612,7 +662,7 @@ export const ExecutiveDashboard: React.FC = () => {
Primary Focus: {sectorData[0]?.name || 'All Sectors'} - + View Portfolios
diff --git a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx index 796a4e7..018c996 100644 --- a/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/GovernancePage.tsx @@ -1,4 +1,5 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -16,6 +17,7 @@ import { Coins, Eye, RefreshCw, + ArrowRight, } from 'lucide-react'; import { getOrganisations, @@ -34,6 +36,8 @@ import { updateProgramme, getInstruments, getPortfolioProgrammes, + getUsers, + getUser, } from '../services/api'; import { Modal } from '../components/ui/Modal'; import { HelpTooltip } from '../components/ui/HelpTooltip'; @@ -58,6 +62,8 @@ import { getApprovalStatusBadge, formatDate, getUserEmail, + registerUserEmail, + getUserIdByEmail, ORGANISATION_TYPE_OPTIONS, ACTIVITY_STATUS_OPTIONS, APPROVAL_STATUS_OPTIONS, @@ -76,14 +82,14 @@ const portfolioSchema = z.object({ name: z.string().min(3, 'Portfolio name is required'), description: z.string().min(5, 'Description is required'), organisationId: z.coerce.number().min(1, 'Select a parent organisation'), - ownerId: z.string().min(1, 'Owner ID is required'), + ownerEmail: z.string().min(1, 'Owner email is required'), }); const programmeSchema = z.object({ name: z.string().min(3, 'Programme name is required'), portfolioId: z.coerce.number().min(1, 'Select a parent portfolio'), status: z.coerce.number().optional(), - ownerId: z.string().min(1, 'Owner ID is required'), + ownerEmail: z.string().min(1, 'Owner email is required'), }); type OrganisationFormValues = z.infer; @@ -92,11 +98,72 @@ type ProgrammeFormValues = z.infer; export const GovernancePage: React.FC = () => { const queryClient = useQueryClient(); - const { sub, user } = useAuth(); + const { sub, user, claims } = useAuth(); const currentUserId = sub || user?.id || ''; const { success, error: toastError } = useToast(); - const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes'>('organisations'); + + const effectiveUserEmail = + user?.email || + (claims?.email as string) || + (claims?.['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'] as string) || + (claims?.upn as string) || + (sub ? getUserEmail(sub) : '') || + ''; + + useEffect(() => { + if (effectiveUserEmail) { + if (sub) registerUserEmail(sub, effectiveUserEmail); + if (user?.id) registerUserEmail(user.id, effectiveUserEmail); + } + }, [sub, user, effectiveUserEmail]); + + const [searchParams, setSearchParams] = useSearchParams(); + const tabParam = searchParams.get('tab'); + + const getValidTab = (val: string | null): 'organisations' | 'portfolios' | 'programmes' => { + if (val === 'portfolios' || val === 'programmes' || val === 'organisations') { + return val; + } + return 'organisations'; + }; + + const [activeTab, setActiveTab] = useState<'organisations' | 'portfolios' | 'programmes'>(() => getValidTab(tabParam)); + + useEffect(() => { + if (tabParam && (tabParam === 'organisations' || tabParam === 'portfolios' || tabParam === 'programmes')) { + setActiveTab(tabParam); + } + }, [tabParam]); + + const handleTabChange = (tab: 'organisations' | 'portfolios' | 'programmes') => { + setActiveTab(tab); + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set('tab', tab); + return next; + }); + }; + const [searchTerm, setSearchTerm] = useState(''); + const [highlightedPortfolioId, setHighlightedPortfolioId] = useState(null); + + const handleNavigateToPortfolioTab = (portfolioId?: number | string) => { + setIsOrgDetailModalOpen(false); + setSelectedOrgForDetail(null); + handleTabChange('portfolios'); + if (portfolioId != null) { + setHighlightedPortfolioId(portfolioId); + setTimeout(() => { + const el = document.getElementById(`portfolio-card-${portfolioId}`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } + }, 150); + setTimeout(() => { + setHighlightedPortfolioId(null); + }, 3500); + } + }; // Modals & Editing State const [isOrgModalOpen, setIsOrgModalOpen] = useState(false); @@ -125,6 +192,12 @@ export const GovernancePage: React.FC = () => { const [isProgrammeDetailModalOpen, setIsProgrammeDetailModalOpen] = useState(false); // Queries + const { data: usersDirectory = [] } = useQuery({ + queryKey: ['users-directory'], + queryFn: () => getUsers(1, 200), + staleTime: 5 * 60 * 1000, + }); + const { data: organisations = [], isLoading: loadingOrgs } = useQuery({ queryKey: ['organisations'], queryFn: () => getOrganisations(), @@ -182,6 +255,92 @@ export const GovernancePage: React.FC = () => { enabled: !!selectedProgrammeForDetail && isProgrammeDetailModalOpen, }); + // Owner resolution helpers + const resolveOwnerId = (emailOrId: string, fallbackOwnerId?: string | number | null): string => { + const trimmed = (emailOrId || '').trim(); + if (!trimmed) { + return fallbackOwnerId ? String(fallbackOwnerId) : currentUserId; + } + + // If already a valid UUID + if (/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(trimmed)) { + return trimmed; + } + + const cleanEmail = trimmed.toLowerCase(); + + // 1. Current logged-in user match + if (effectiveUserEmail && cleanEmail === effectiveUserEmail.toLowerCase()) { + return sub || user?.id || (fallbackOwnerId ? String(fallbackOwnerId) : currentUserId); + } + + // 2. Reverse cache lookup + const cachedId = getUserIdByEmail(cleanEmail); + if (cachedId) return cachedId; + + // 3. Search loaded users directory + const matchedUser = usersDirectory.find( + (u) => u.email && u.email.trim().toLowerCase() === cleanEmail + ); + if (matchedUser?.id) return String(matchedUser.id); + + // 4. Fallback ID match check + if (fallbackOwnerId) { + const origEmail = getUserEmail(fallbackOwnerId); + if (origEmail && origEmail.toLowerCase() === cleanEmail) { + return String(fallbackOwnerId); + } + } + + // 5. Default fallback + return fallbackOwnerId ? String(fallbackOwnerId) : currentUserId; + }; + + const resolveOwnerEmailForDisplay = async (rawOwnerId?: string | number | null): Promise => { + if (!rawOwnerId) { + return effectiveUserEmail; + } + const raw = String(rawOwnerId).trim(); + if (raw.includes('@')) { + return raw; + } + + // Check if it's the current user + if ( + (sub && raw.toLowerCase() === sub.toLowerCase()) || + (user?.id && raw.toLowerCase() === user.id.toLowerCase()) + ) { + return effectiveUserEmail; + } + + // Check cached email + const cached = getUserEmail(raw); + if (cached && cached.includes('@')) { + return cached; + } + + // Check users directory + const found = usersDirectory.find((u) => String(u.id).toLowerCase() === raw.toLowerCase()); + if (found?.email) { + return found.email; + } + + // If it's a UUID, fetch user canonical record from API + if (/^[0-9a-fA-F-]{36}$/.test(raw)) { + try { + const u = await getUser(raw); + if (u?.email) { + registerUserEmail(u.id, u.email); + return u.email; + } + } catch { + // Fetch failed + } + } + + return effectiveUserEmail; + }; + // Forms const orgForm = useForm({ resolver: zodResolver(organisationSchema) as any, @@ -200,7 +359,7 @@ export const GovernancePage: React.FC = () => { name: '', description: '', organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101, - ownerId: currentUserId, + ownerEmail: effectiveUserEmail, }, }); @@ -210,7 +369,7 @@ export const GovernancePage: React.FC = () => { name: '', portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201, status: ApprovalStatus.Approved, - ownerId: currentUserId, + ownerEmail: effectiveUserEmail, }, }); @@ -243,16 +402,22 @@ export const GovernancePage: React.FC = () => { const portfolioMutation = useMutation({ mutationFn: (data: PortfolioFormValues) => { + const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingPortfolio?.ownedBy); if (editingPortfolio) { return updatePortfolio({ portfolioId: editingPortfolio.id, name: data.name, description: data.description, organisationId: Number(data.organisationId), - ownerId: data.ownerId, + ownerId: resolvedOwnerId, }); } - return createPortfolio(data as CreatePortfolioRequest); + return createPortfolio({ + name: data.name, + description: data.description, + organisationId: Number(data.organisationId), + ownerId: resolvedOwnerId, + }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['portfolios'] }); @@ -268,16 +433,21 @@ export const GovernancePage: React.FC = () => { const programmeMutation = useMutation({ mutationFn: (data: ProgrammeFormValues) => { + const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy); if (editingProgramme) { return updateProgramme({ programmeId: editingProgramme.id, name: data.name, portfolioId: Number(data.portfolioId), status: data.status !== undefined ? Number(data.status) : undefined, - ownerId: data.ownerId, + ownerId: resolvedOwnerId, }); } - return createProgramme(data as CreateProgrammeRequest); + return createProgramme({ + name: data.name, + portfolioId: Number(data.portfolioId), + ownerId: resolvedOwnerId, + }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['programmes'] }); @@ -321,18 +491,19 @@ export const GovernancePage: React.FC = () => { name: '', description: '', organisationId: organisations[0]?.id ? Number(organisations[0].id) : 101, - ownerId: currentUserId, + ownerEmail: effectiveUserEmail, }); setIsPortfolioModalOpen(true); }; - const openEditPortfolio = (p: Portfolio) => { + const openEditPortfolio = async (p: Portfolio) => { setEditingPortfolio(p); + const email = await resolveOwnerEmailForDisplay(p.ownedBy); portfolioForm.reset({ name: p.name || '', description: p.description || '', organisationId: Number(p.organisationId) || 101, - ownerId: p.ownedBy || currentUserId, + ownerEmail: email, }); setIsPortfolioModalOpen(true); }; @@ -343,18 +514,19 @@ export const GovernancePage: React.FC = () => { name: '', portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201, status: ApprovalStatus.Approved, - ownerId: currentUserId, + ownerEmail: effectiveUserEmail, }); setIsProgrammeModalOpen(true); }; - const openEditProgramme = (pr: Programme) => { + const openEditProgramme = async (pr: Programme) => { setEditingProgramme(pr); + const email = await resolveOwnerEmailForDisplay(pr.ownedBy); programmeForm.reset({ name: pr.name || '', portfolioId: Number(pr.portfolioId) || 201, status: pr.status ?? ApprovalStatus.Approved, - ownerId: pr.ownedBy || currentUserId, + ownerEmail: email, }); setIsProgrammeModalOpen(true); }; @@ -425,7 +597,7 @@ export const GovernancePage: React.FC = () => { {/* Tabs */}
- - -
+ {formatDate(p.createdAt)} +
+ +
+ Owner: + + {getUserEmail(p.ownedBy)} + +
+ +
+ + +
@@ -718,7 +904,7 @@ export const GovernancePage: React.FC = () => { 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})" + title="Inspect Programme" > Inspect @@ -972,11 +1158,22 @@ export const GovernancePage: React.FC = () => { />
+ + {usersDirectory.filter((u) => u.email).map((u) => ( + + ))} + + {portfolioForm.formState.errors.ownerEmail && ( +

{portfolioForm.formState.errors.ownerEmail.message}

+ )}
@@ -1111,11 +1308,22 @@ export const GovernancePage: React.FC = () => { />
+ + {usersDirectory.filter((u) => u.email).map((u) => ( + + ))} + + {programmeForm.formState.errors.ownerEmail && ( +

{programmeForm.formState.errors.ownerEmail.message}

+ )}
@@ -1260,7 +1468,7 @@ export const GovernancePage: React.FC = () => {
- {/* Modal: View Organisation Details (GET /v1/api/governance/organisations/{id} & /portfolio) */} + {/* Modal: View Organisation Details & Linked Portfolio */} { @@ -1274,51 +1482,89 @@ export const GovernancePage: React.FC = () => { {loadingOrgFreshDetail ? (
- Fetching canonical record (GET /v1/api/governance/organisations/{selectedOrgForDetail?.id})... + Fetching organisation details...
) : ( <> -
-
-
- Legal Name: - {orgFreshDetail?.name || selectedOrgForDetail?.name} +
+
+
+ Legal Name: + {orgFreshDetail?.name || selectedOrgForDetail?.name}
-
- Reg Number: +
+ Reg Number: {orgFreshDetail?.registrationNo || selectedOrgForDetail?.registrationNo || '—'}
-
- Official Email: - {orgFreshDetail?.email || selectedOrgForDetail?.email} +
+ Official Email: + {orgFreshDetail?.email || selectedOrgForDetail?.email}
-
- Entity Type: +
+ Entity Type: {getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}
-

- Linked Portfolios (GET /v1/api/governance/organisations/{selectedOrgForDetail?.id}/portfolio) - {orgPortfolios.length} linked -

+
+
+

Linked Portfolios

+ + {orgPortfolios.length} linked + +
+ {orgPortfolios.length > 0 && ( + + )} +
+ {loadingOrgPortfolios ? ( -

Loading portfolios...

+

Loading portfolios...

) : orgPortfolios.length === 0 ? ( -

No portfolios directly linked to this organisation.

+
+

No portfolios directly linked to this organisation.

+
) : ( -
+
{orgPortfolios.map((port) => ( -
-
- {port.name} - ID: {port.id} +
handleNavigateToPortfolioTab(port.id)} + className="p-3 bg-slate-900/60 hover:bg-slate-900 rounded-xl border border-slate-800 hover:border-blue-500/40 flex items-center justify-between gap-3 transition cursor-pointer group" + > +
+
+ + {port.name} + + + Portfolio #{port.id} + +
+ {port.description && ( +

{port.description}

+ )}
- - Portfolio - +
))}
@@ -1342,7 +1588,7 @@ export const GovernancePage: React.FC = () => {
- {/* Modal: View Portfolio Details (GET /v1/api/governance/portfolios/{id}) */} + {/* Modal: View Portfolio Details */} { @@ -1356,7 +1602,7 @@ export const GovernancePage: React.FC = () => { {loadingPortfolioFreshDetail ? (
- Fetching canonical record (GET /v1/api/governance/portfolios/{selectedPortfolioForDetail?.id})... + Fetching portfolio details...
) : (
@@ -1368,14 +1614,16 @@ export const GovernancePage: React.FC = () => { Description:

{portfolioFreshDetail?.description || selectedPortfolioForDetail?.description || 'None provided'}

-
-
- Owner: - {getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)} +
+
+ Owner: + + {getUserEmail(portfolioFreshDetail?.ownedBy || selectedPortfolioForDetail?.ownedBy)} +
-
- Created: - +
+ Created: + {formatDate(portfolioFreshDetail?.createdAt || selectedPortfolioForDetail?.createdAt || '')}
@@ -1398,7 +1646,7 @@ export const GovernancePage: React.FC = () => {
- {/* Modal: View Programme Details (GET /v1/api/projects/programme/{id}) */} + {/* Modal: View Programme Details */} { @@ -1412,24 +1660,26 @@ export const GovernancePage: React.FC = () => { {loadingProgrammeFreshDetail ? (
- Fetching canonical record (GET /v1/api/projects/programme/{selectedProgrammeForDetail?.id})... + Fetching programme details...
) : ( -
-
- Programme Name: - {programmeFreshDetail?.name || selectedProgrammeForDetail?.name} +
+
+ Programme Name + {programmeFreshDetail?.name || selectedProgrammeForDetail?.name}
-
-
- Approval Status: - +
+
+ Approval Status: + {getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
-
- Owner: - {getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)} +
+ Owner: + + {getUserEmail(programmeFreshDetail?.ownedBy || selectedProgrammeForDetail?.ownedBy)} +
diff --git a/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx b/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx index 19e2394..e6058fb 100644 --- a/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/MonitoringPage.tsx @@ -1,4 +1,5 @@ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useEffect } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -20,16 +21,12 @@ import { Filter, Link as LinkIcon, Edit2, + ChevronRight, } from 'lucide-react'; import { getAllIndicators, createIndicator, recordIndicatorActual, - getAllEvidence, - getMilestoneEvidence, - getEvidenceSecureLink, - uploadEvidenceFile, - updateEvidence, getAllRisks, createRisk, updateRisk, @@ -40,10 +37,10 @@ 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 { EvidenceReviewQueue } from '../components/monitoring/EvidenceReviewQueue'; import { RiskHeatmapMatrix } from '../components/ui/RiskHeatmapMatrix'; import { Indicator, - Evidence, Risk, ApprovalStatus, UnitOfMeasure, @@ -85,13 +82,40 @@ const riskSchema = z.object({ type IndicatorFormValues = z.infer; type RiskFormValues = z.infer; +export const WORKFLOW_STAGES = [ + { id: 'planned', label: 'Planned', step: 1, description: 'Milestone scope agreed and deliverables planned' }, + { id: 'in_progress', label: 'In Progress', step: 2, description: 'Activity underway by recipient organisation' }, + { id: 'submitted', label: 'Submitted', step: 3, description: 'Evidence and deliverables submitted by recipient' }, + { id: 'under_review', label: 'Under Review', step: 4, description: 'Programme Manager verifying evidence against criteria' }, + { id: 'verified_paid', label: 'Verified & Paid', step: 5, description: 'Milestone verified and disbursement authorized' }, +] as const; + export const MonitoringPage: React.FC = () => { const queryClient = useQueryClient(); const { success, error: toastError } = useToast(); + const [searchParams, setSearchParams] = useSearchParams(); - const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>('contracts'); + const tabParam = searchParams.get('tab'); + const [activeTab, setActiveTab] = useState<'contracts' | 'evidence' | 'indicators' | 'risks'>( + tabParam === 'evidence' || tabParam === 'indicators' || tabParam === 'risks' ? tabParam : 'contracts' + ); const [isCreateContractModalOpen, setIsCreateContractModalOpen] = useState(false); - const fileInputRef = useRef(null); + + useEffect(() => { + const t = searchParams.get('tab'); + if (t === 'evidence' || t === 'indicators' || t === 'risks' || t === 'contracts') { + setActiveTab(t); + } + }, [searchParams]); + + const handleTabChange = (tab: 'contracts' | 'evidence' | 'indicators' | 'risks') => { + setActiveTab(tab); + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.set('tab', tab); + return next; + }); + }; // Modals & States const [isIndicatorModalOpen, setIsIndicatorModalOpen] = useState(false); @@ -99,11 +123,6 @@ export const MonitoringPage: React.FC = () => { const [editingRisk, setEditingRisk] = useState(null); const [selectedIndicatorForActual, setSelectedIndicatorForActual] = useState(null); const [actualInput, setActualInput] = useState(0); - const [uploadEvidenceType, setUploadEvidenceType] = useState(EvidenceType.CompletionCertificate); - - // Reviewer commentary - const [reviewerComment, setReviewerComment] = useState(''); - const [selectedMilestoneEvidenceFilter, setSelectedMilestoneEvidenceFilter] = useState('all'); // Queries const { data: indicators = [], isLoading: loadingIndicators } = useQuery({ @@ -111,14 +130,6 @@ export const MonitoringPage: React.FC = () => { queryFn: () => getAllIndicators(), }); - const { data: evidenceList = [], isLoading: loadingEvidence } = useQuery({ - queryKey: ['evidence', selectedMilestoneEvidenceFilter], - queryFn: () => - selectedMilestoneEvidenceFilter !== 'all' - ? getMilestoneEvidence(selectedMilestoneEvidenceFilter) - : getAllEvidence(), - }); - const { data: risks = [], isLoading: loadingRisks } = useQuery({ queryKey: ['risks'], queryFn: () => getAllRisks(), @@ -184,42 +195,6 @@ export const MonitoringPage: React.FC = () => { }, }); - const uploadMutation = useMutation({ - mutationFn: (formData: FormData) => uploadEvidenceFile(formData), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['evidence'] }); - success('Evidence uploaded', 'Supporting file registered for reviewer inspection.'); - }, - onError: (err: unknown) => { - toastError('Upload failed', err); - }, - }); - - const reviewEvidenceMutation = useMutation({ - mutationFn: ({ id, status }: { id: string | number; status: ApprovalStatus }) => - updateEvidence({ - evidenceId: id, - version: 2, - evidenceType: EvidenceType.DeliverableProof, - status, - }), - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ queryKey: ['evidence'] }); - if (variables.status === ApprovalStatus.Approved) { - success( - 'Evidence Approved & Verified', - 'Approval recorded. Auto-triggers payment eligibility check in Disbursements.' - ); - } else { - success('Rework Requested', 'Evidence returned to recipient for correction.'); - } - setReviewerComment(''); - }, - onError: (err: unknown) => { - toastError('Review update failed', err); - }, - }); - const openCreateRisk = () => { setEditingRisk(null); riskForm.reset({ @@ -279,40 +254,14 @@ export const MonitoringPage: React.FC = () => { }, }); - const handleFileUpload = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - const formData = new FormData(); - formData.append('file', file); - formData.append('projectId', '401'); - formData.append('milestoneId', '803'); - formData.append('evidenceType', String(uploadEvidenceType)); - uploadMutation.mutate(formData); - } - }; - - const handleGetEvidenceSecureLink = async (evidenceId: string | number) => { - try { - const url = await getEvidenceSecureLink(evidenceId); - if (url) { - window.open(url, '_blank', 'noopener,noreferrer'); - success('Secure URL Retrieved', 'Signed evidence download link opened.'); - } else { - toastError('Download Error', 'No active secure download link generated.'); - } - } catch (err: any) { - toastError('Link Error', err); - } - }; - // 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' }; const pct = (actual / target) * 100; - if (pct >= 90) return { label: 'Green (≥90%)', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' }; - if (pct >= 70) return { label: 'Amber (70–89%)', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20' }; - return { label: 'Red (<70%)', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' }; + if (pct >= 90) return { label: '≥90%', badge: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20' }; + if (pct >= 70) return { label: '70–89%', badge: 'bg-amber-500/10 text-amber-400 border-amber-500/20' }; + return { label: '<70%', badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20' }; }; return ( @@ -367,8 +316,8 @@ export const MonitoringPage: React.FC = () => { {/* Tabs */}
-
- -
- - {/* Attached Evidence List */} -
-
-
-

Attached Evidence (v2)

- {evidenceList.length} files loaded -
-
- Milestone: - -
-
- -
- {evidenceList.map((ev) => ( -
-
- -
- - {ev.fileName || `evidence_file_${ev.id}.pdf`} - - - {ev.fileSize || '3.2 MB'} • v{ev.version} • {getEvidenceTypeLabel(ev.type)} - -
-
- -
- - Under Review - - - {ev.documentUrl && ( - - - - )} -
-
- ))} -
-
-
- - {/* Programme Manager Review Section */} -
-

Programme Manager Review

- -
- - setReviewerComment(e.target.value)} - placeholder="Mandatory if requesting rework; optional for approvals..." - className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 transition" - /> -
- -
-
- - - -
- -

- Approval auto-triggers payment eligibility check. -

-
-
-
-
+ )} {/* Tab 2: Indicators Table (M08) */} @@ -964,7 +707,7 @@ export const MonitoringPage: React.FC = () => { diff --git a/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx index 8873a88..05ba928 100644 --- a/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/ProjectsPage.tsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -520,15 +521,25 @@ export const ProjectsPage: React.FC = () => { {/* Actions & Status Change Selector */}
- +
+ + + Review Evidence + + +
@@ -684,6 +695,14 @@ export const ProjectsPage: React.FC = () => {
{formatZAR(a.amount)} + + + Evidence +
-
- - +
+ + Due: {formatDate(m.dueAt)} - - {priBadge.label} -
); diff --git a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx index 6737a0c..f4555f0 100644 --- a/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx +++ b/PostFundManagement.ConsoleUI/src/pages/SettingsPage.tsx @@ -121,7 +121,7 @@ export const SettingsPage: React.FC = () => { const syncIntegrationMutation = useMutation({ mutationFn: () => syncIntegration(true), onSuccess: () => { - success('Backend Integration Synced', 'Direct POST /v1/api/integration/sync executed successfully.'); + success('Backend Integration Synced', 'System records and telemetry successfully synchronized.'); queryClient.invalidateQueries({ queryKey: ['users'] }); }, onError: (err: any) => { @@ -260,7 +260,7 @@ export const SettingsPage: React.FC = () => {
Browser Sync Saved @@ -553,7 +553,7 @@ export const SettingsPage: React.FC = () => { type="submit" disabled={searchingApplicant || !applicantSearchId.trim()} 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})" + title="Lookup all invites" > {searchingApplicant ? 'Searching...' : 'All Invites'} @@ -562,7 +562,7 @@ export const SettingsPage: React.FC = () => { 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})" + title="Lookup primary invite" > Primary Invite @@ -721,10 +721,10 @@ export const SettingsPage: React.FC = () => {

- Backend Integration Sync Engine (POST /v1/api/integration/sync) + Backend Integration Sync Engine

- 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. + The integration sync service 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.

@@ -734,7 +734,7 @@ export const SettingsPage: React.FC = () => { 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" + title="Trigger synchronization with directory" > {syncIntegrationMutation.isPending ? 'Syncing...' : 'Trigger Integration Sync'} @@ -769,19 +769,19 @@ export const SettingsPage: React.FC = () => {
- OpenAPI Target Route + Synchronization Service Profile
- HTTP Method: POST + Service Mode: Secure Directory Handshake
- Endpoint Path:{' '} - /v1/api/integration/sync + Sync Scope:{' '} + Authoritative User Directory & Security Claims
- Execution Target:{' '} - Authoritative User Directory Synchronization + Service Status:{' '} + Operational & Connected
@@ -1129,7 +1129,7 @@ export const SettingsPage: React.FC = () => { {loadingUserFreshDetail ? (
- Querying backend endpoint (GET /v1/api/users/{selectedUserForDetail.id})... + Fetching user profile details...
) : (
diff --git a/PostFundManagement.ConsoleUI/src/services/api.ts b/PostFundManagement.ConsoleUI/src/services/api.ts index 0aa479c..fc423e8 100644 --- a/PostFundManagement.ConsoleUI/src/services/api.ts +++ b/PostFundManagement.ConsoleUI/src/services/api.ts @@ -54,7 +54,7 @@ import { } from '../types'; import { parseFriendlyError, FriendlyError } from '../utils/apiErrorParser'; -import { registerUserEmail, registerUserEmails } from '../lib/formatters'; +import { registerUserEmail, registerUserEmails, getUserIdByEmail } from '../lib/formatters'; // Helper to extract ProblemDetails from Axios error or unknown error export function parseApiError(error: unknown): ProblemDetails { @@ -88,6 +88,20 @@ export const getDashboard = async (): Promise => { // 2. Governance & Structure APIs // ========================================== +/** + * Ensures an ID is converted to a canonical int64 integer number. + * Decodes HashIDs if a HashID string is passed, ensuring request body schemas + * (pattern: ^-?(?:0|[1-9]\d*)$) receive numeric integers, never raw HashIDs. + */ +export const toIntegerId = (id: number | string | null | undefined): number => { + return toolsService.decodeToNumberSync(id); +}; + +export const toNullableIntegerId = (id: number | string | null | undefined): number | null => { + if (id === null || id === undefined || id === '') return null; + return toolsService.decodeToNumberSync(id); +}; + /** * Normalizes an Organisation object from backend responses. * Robustly maps status across variations (status, Status, activityStatus, approvalStatus, statusId, isActive). @@ -137,19 +151,22 @@ export const getOrganisation = async (organisationId: string | number): Promise< }; export const createOrganisation = async (payload: CreateOrganisationRequest): Promise => { - const userSub = getCurrentUserSub(); const res = await apiClient.post('/v1/api/governance/organisations', { - ...payload, - createdBy: userSub || undefined, + name: payload.name, + registrationNo: payload.registrationNo, + email: payload.email, + type: payload.type !== undefined ? Number(payload.type) : 5, }); return res.data; }; export const updateOrganisation = async (payload: UpdateOrganisationRequest): Promise => { - const userSub = getCurrentUserSub(); const res = await apiClient.put('/v1/api/governance/organisations', { - ...payload, - updatedBy: userSub || undefined, + organisationId: toIntegerId(payload.organisationId), + name: payload.name, + registrationNo: payload.registrationNo, + email: payload.email, + type: Number(payload.type), }); return res.data; }; @@ -193,20 +210,30 @@ export const getPortfolioProgrammes = async (portfolioId: string | number): Prom export const createPortfolio = async (payload: CreatePortfolioRequest): Promise => { const userSub = getCurrentUserSub(); + let ownerId = payload.ownerId; + if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) { + ownerId = getUserIdByEmail(ownerId) || userSub || ownerId; + } const res = await apiClient.post('/v1/api/governance/organisations/portfolio', { - ...payload, - ownerId: payload.ownerId || userSub || undefined, - createdBy: userSub || undefined, + name: payload.name, + description: payload.description, + organisationId: toIntegerId(payload.organisationId), + ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000', }); return res.data; }; export const updatePortfolio = async (payload: UpdatePortfolioRequest): Promise => { const userSub = getCurrentUserSub(); + let ownerId = payload.ownerId; + if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) { + ownerId = getUserIdByEmail(ownerId) || userSub || ownerId; + } const res = await apiClient.put('/v1/api/governance/organisations/portfolio', { - ...payload, - ownerId: payload.ownerId || userSub || undefined, - updatedBy: userSub || undefined, + portfolioId: toIntegerId(payload.portfolioId), + name: payload.name, + description: payload.description, + ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000', }); return res.data; }; @@ -245,20 +272,29 @@ export const getProgrammeProjects = async (programmeId: string | number): Promis export const createProgramme = async (payload: CreateProgrammeRequest): Promise => { const userSub = getCurrentUserSub(); + let ownerId = payload.ownerId; + if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) { + ownerId = getUserIdByEmail(ownerId) || userSub || ownerId; + } const res = await apiClient.post('/v1/api/projects/programme', { - ...payload, - ownerId: payload.ownerId || userSub || undefined, - createdBy: userSub || undefined, + portfolioId: toIntegerId(payload.portfolioId), + name: payload.name, + ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000', }); return res.data; }; export const updateProgramme = async (payload: UpdateProgrammeRequest): Promise => { const userSub = getCurrentUserSub(); + let ownerId = payload.ownerId; + if (ownerId && typeof ownerId === 'string' && ownerId.includes('@')) { + ownerId = getUserIdByEmail(ownerId) || userSub || ownerId; + } const res = await apiClient.put('/v1/api/projects/programme', { - ...payload, - ownerId: payload.ownerId || userSub || undefined, - updatedBy: userSub || undefined, + programmeId: toIntegerId(payload.programmeId), + name: payload.name, + ownerId: ownerId || userSub || '00000000-0000-0000-0000-000000000000', + status: Number(payload.status) || 1, }); return res.data; }; @@ -277,28 +313,40 @@ export const getProject = async (projectId: string | number): Promise = }; export const createProject = async (payload: CreateProjectRequest): Promise => { - const userSub = getCurrentUserSub(); const res = await apiClient.post('/v1/api/projects', { - ...payload, - createdBy: userSub || undefined, + programmeId: toIntegerId(payload.programmeId), + name: payload.name, + description: payload.description, + sector: Number(payload.sector), + projectType: Number(payload.projectType), }); return res.data; }; export const updateProject = async (payload: UpdateProjectRequest): Promise => { - const userSub = getCurrentUserSub(); + const startedAt = payload.startedAt + ? (payload.startedAt.includes('T') ? payload.startedAt : new Date(payload.startedAt).toISOString()) + : null; + const endedAt = payload.endedAt + ? (payload.endedAt.includes('T') ? payload.endedAt : new Date(payload.endedAt).toISOString()) + : null; + const res = await apiClient.put('/v1/api/projects', { - ...payload, - updatedBy: userSub || undefined, + projectId: toIntegerId(payload.projectId), + name: payload.name, + description: payload.description, + startedAt, + endedAt, + sector: Number(payload.sector), + projectType: Number(payload.projectType), }); return res.data; }; export const updateProjectStatus = async (payload: UpdateProjectStatusRequest): Promise => { - const userSub = getCurrentUserSub(); await apiClient.put('/v1/api/projects/status', { - ...payload, - updatedBy: userSub || undefined, + projectId: toIntegerId(payload.projectId), + status: Number(payload.status), }); }; @@ -315,36 +363,36 @@ export const getActivity = async (activityId: string | number): Promise => { - const userSub = getCurrentUserSub(); - const body: any = { - ...payload, - milestoneId: - payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== '' - ? Number(payload.milestoneId) - : null, - createdBy: userSub || undefined, + const performedAt = payload.performedAt + ? (payload.performedAt.includes('T') ? payload.performedAt : new Date(payload.performedAt).toISOString()) + : null; + + const body = { + projectId: toIntegerId(payload.projectId), + milestoneId: toNullableIntegerId(payload.milestoneId), + category: payload.category || 'Operations', + amount: Number(payload.amount) || 0, + title: payload.title, + description: payload.description || '', + performedAt, }; - try { - const res = await apiClient.post('/v1/api/projects/activities', body); - return res.data; - } catch (err: any) { - if (err?.response?.status === 405) { - const res = await apiClient.put('/v1/api/projects/activities', body); - return res.data; - } - throw err; - } + const res = await apiClient.post('/v1/api/projects/activities', body); + return res.data; }; export const updateActivity = async (payload: UpdateActivityRequest): Promise => { - const userSub = getCurrentUserSub(); - const body: any = { - ...payload, - milestoneId: - payload.milestoneId !== undefined && payload.milestoneId !== null && payload.milestoneId !== '' - ? Number(payload.milestoneId) - : null, - updatedBy: userSub || undefined, + const performedAt = payload.performedAt + ? (payload.performedAt.includes('T') ? payload.performedAt : new Date(payload.performedAt).toISOString()) + : null; + + const body = { + projectId: toIntegerId(payload.projectId), + milestoneId: toNullableIntegerId(payload.milestoneId), + category: payload.category || 'Operations', + amount: Number(payload.amount) || 0, + title: payload.title, + description: payload.description || '', + performedAt, }; try { const res = await apiClient.put('/v1/api/projects/activities', body); @@ -376,19 +424,30 @@ export const getMilestone = async (milestoneId: string | number): Promise => { - const userSub = getCurrentUserSub(); + const dueAtIso = payload.dueAt.includes('T') + ? payload.dueAt + : new Date(payload.dueAt).toISOString(); + const res = await apiClient.post('/v1/api/evidence/milestones', { - ...payload, - createdBy: userSub || undefined, + projectId: toIntegerId(payload.projectId), + dueAt: dueAtIso, + name: payload.name, + priority: Number(payload.priority) || 1, }); return res.data; }; export const updateMilestone = async (payload: UpdateMilestoneRequest): Promise => { - const userSub = getCurrentUserSub(); + const dueAtIso = payload.dueAt.includes('T') + ? payload.dueAt + : new Date(payload.dueAt).toISOString(); + await apiClient.put('/v1/api/projects/milestone', { - ...payload, - updatedBy: userSub || undefined, + milestoneId: toIntegerId(payload.milestoneId), + dueAt: dueAtIso, + name: payload.name, + priority: Number(payload.priority) || 1, + status: Number(payload.status) || 1, }); }; @@ -422,10 +481,10 @@ export const getAward = async (awardId: string | number): Promise => { }; export const createAward = async (payload: CreateAwardRequest): Promise => { - const userSub = getCurrentUserSub(); const res = await apiClient.post('/v1/api/grants/awards', { - ...payload, - createdBy: userSub || undefined, + organisationId: toIntegerId(payload.organisationId), + programmeId: toIntegerId(payload.programmeId), + amount: Number(payload.amount), }); return res.data; }; @@ -456,12 +515,27 @@ export const getContract = async (contractId: string | number): Promise => { - const userSub = getCurrentUserSub(); - const hashedAwardId = await toolsService.resolveAwardId(payload.awardId); + // Ensure awardId is numeric int64 as required by OpenAPI schema (pattern: ^-?(?:0|[1-9]\d*)$) + let numericAwardId: number = Number(payload.awardId); + if (isNaN(numericAwardId) || !/^-?\d+$/.test(String(payload.awardId))) { + const decoded = toolsService.decodeIdSync(String(payload.awardId)); + if (decoded && /^-?\d+$/.test(String(decoded))) { + numericAwardId = Number(decoded); + } + } + + const effectiveAtIso = payload.effectiveAt.includes('T') + ? payload.effectiveAt + : new Date(payload.effectiveAt).toISOString(); + const expiresAtIso = payload.expiresAt.includes('T') + ? payload.expiresAt + : new Date(payload.expiresAt).toISOString(); + const res = await apiClient.post('/v1/api/grants/contracts', { - ...payload, - awardId: hashedAwardId, - createdBy: userSub || undefined, + awardId: !isNaN(numericAwardId) ? numericAwardId : payload.awardId, + totalValue: Number(payload.totalValue), + effectiveAt: effectiveAtIso, + expiresAt: expiresAtIso, }); // Extract ID from headers (e.g. Location or Content-Location) if present @@ -472,6 +546,8 @@ export const createContract = async (payload: CreateContractRequest): Promise 0) { const rawStr = res.data.trim(); @@ -479,9 +555,9 @@ export const createContract = async (payload: CreateContractRequest): Promise String(c.awardId) === String(hashedAwardId) || + String(c.awardId) === String(numericAwardId) || String(c.awardId) === String(payload.awardId) ); if (matched) return matched; @@ -532,18 +609,17 @@ export const createContract = async (payload: CreateContractRequest): Promise => { - const hashedContractId = await toolsService.resolveContractId(payload.contractId); await apiClient.put('/v1/api/grants/contracts', { - ...payload, - contractId: hashedContractId, + contractId: toIntegerId(payload.contractId), + status: Number(payload.status), }); }; @@ -571,12 +647,10 @@ export const getContractSecureLink = async (contractId: string | number): Promis }; export const executeContract = async (payload: ExecuteContractRequest): Promise => { - const userSub = getCurrentUserSub(); - const hashedContractId = await toolsService.resolveContractId(payload.contractId); await apiClient.post('/v1/api/grants/contracts/execute', { - ...payload, - contractId: hashedContractId, - executedBy: userSub || undefined, + contractId: toIntegerId(payload.contractId), + externalSignatureId: payload.externalSignatureId, + signedDocumentUrl: payload.signedDocumentUrl, }); }; @@ -594,10 +668,10 @@ export const getBudget = async (budgetId: string | number): Promise => { }; export const createBudget = async (payload: CreateBudgetRequest): Promise => { - const userSub = getCurrentUserSub(); const res = await apiClient.post('/v1/api/grants/budgets', { - ...payload, - createdBy: userSub || undefined, + projectId: toIntegerId(payload.projectId), + name: payload.name, + amount: Number(payload.amount), }); return res.data; }; @@ -619,28 +693,25 @@ export const getDisbursement = async (disbursementId: string | number): Promise< }; export const createDisbursement = async (payload: CreateDisbursementRequest): Promise => { - const userSub = getCurrentUserSub(); const res = await apiClient.post('/v1/api/execution/disbursements', { - ...payload, - createdBy: userSub || undefined, + projectId: toIntegerId(payload.projectId), + milestoneId: toNullableIntegerId(payload.milestoneId), + amount: Number(payload.amount), }); return res.data; }; export const updateDisbursement = async (payload: UpdateDisbursementRequest): Promise => { - const userSub = getCurrentUserSub(); await apiClient.put('/v1/api/execution/disbursements', { - ...payload, - updatedBy: userSub || undefined, + disbursementId: toIntegerId(payload.disbursementId), + milestoneId: toNullableIntegerId(payload.milestoneId), + amount: Number(payload.amount), }); }; export const approveDisbursement = async (disbursementId: string | number): Promise => { const hashedDisbId = await toolsService.encodeId(disbursementId); - const userSub = getCurrentUserSub(); - await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, { - approvedBy: userSub || undefined, - }); + await apiClient.post(`/v1/api/execution/disbursements/${hashedDisbId}/approve`, {}); }; /** @@ -706,23 +777,23 @@ export const getRisk = async (riskId: string | number): Promise => { }; export const createRisk = async (payload: CreateRiskRequest): Promise => { - const userSub = getCurrentUserSub(); const res = await apiClient.post('/v1/api/execution/risks', { - ...payload, + projectId: toIntegerId(payload.projectId), likelihood: Number(payload.likelihood), impact: Number(payload.impact), - createdBy: userSub || undefined, + name: payload.name, + description: payload.description || '', }); return normalizeRisk(res.data); }; export const updateRisk = async (payload: UpdateRiskRequest): Promise => { - const userSub = getCurrentUserSub(); await apiClient.put('/v1/api/execution/risks', { - ...payload, + riskId: toIntegerId(payload.riskId), likelihood: Number(payload.likelihood), impact: Number(payload.impact), - updatedBy: userSub || undefined, + name: payload.name, + description: payload.description || '', }); }; @@ -741,20 +812,19 @@ export const getIndicator = async (indicatorId: string | number): Promise => { - const userSub = getCurrentUserSub(); const res = await apiClient.post('/v1/api/evidence/indicators', { - ...payload, - createdBy: userSub || undefined, + projectId: toIntegerId(payload.projectId), + name: payload.name, + unitOfMeasure: Number(payload.unitOfMeasure) || 1, + baselineAmount: Number(payload.baselineAmount) || 0, + targetAmount: Number(payload.targetAmount) || 0, }); return res.data; }; export const recordIndicatorActual = async (indicatorId: string | number, actualAmount: number): Promise => { const hashedIndId = await toolsService.encodeId(indicatorId); - const userSub = getCurrentUserSub(); - await apiClient.post(`/v1/api/evidence/indicators/${hashedIndId}/actuals/${actualAmount}`, { - recordedBy: userSub || undefined, - }); + await apiClient.post(`/v1/api/evidence/indicators/${hashedIndId}/actuals/${actualAmount}`, {}); }; export const getEvidenceFiles = async (page = 1, pageSize = 100): Promise => { @@ -802,10 +872,11 @@ export const uploadEvidenceFile = async (formData: FormData): Promise }; export const updateEvidence = async (payload: UpdateEvidenceRequest): Promise => { - const userSub = getCurrentUserSub(); await apiClient.put('/v1/api/evidence', { - ...payload, - updatedBy: userSub || undefined, + evidenceId: toIntegerId(payload.evidenceId), + version: Number(payload.version) || 1, + evidenceType: Number(payload.evidenceType), + status: Number(payload.status), }); }; @@ -910,10 +981,14 @@ export const syncIntegration = async (force = true): Promise => { // 8. Communications & Invites APIs // ========================================== export const sendInvite = async (payload: InviteRequest): Promise => { - const userSub = getCurrentUserSub(); const res = await apiClient.post('/v1/api/communications/invite', { - ...payload, - createdBy: userSub || undefined, + organisationId: toIntegerId(payload.organisationId), + invitedOrganisationId: toIntegerId(payload.invitedOrganisationId), + awardId: toIntegerId(payload.awardId), + contractId: toIntegerId(payload.contractId), + recipient: payload.recipient, + message: payload.message, + platform: payload.platform !== undefined ? Number(payload.platform) : 1, }); return res.data; }; diff --git a/PostFundManagement.ConsoleUI/src/services/toolsService.ts b/PostFundManagement.ConsoleUI/src/services/toolsService.ts index 238a364..0bbc4b0 100644 --- a/PostFundManagement.ConsoleUI/src/services/toolsService.ts +++ b/PostFundManagement.ConsoleUI/src/services/toolsService.ts @@ -99,11 +99,19 @@ export function encodeHashIdSync(id: number | string | null | undefined): string * Deterministically decodes a HashID string back to original numeric ID or string. * Synchronous and does not perform HTTP network round trips. */ -export function decodeHashIdSync(hashId: string | null | undefined): number | string { - if (!hashId) return ''; +export function decodeHashIdSync(hashId: string | number | null | undefined): number | string { + if (hashId === undefined || hashId === null || hashId === '') return ''; + if (typeof hashId === 'number') return hashId; + const str = String(hashId).trim(); if (!str) return ''; + // If already pure integer string + if (/^-?\d+$/.test(str)) { + const parsed = parseInt(str, 10); + return isNaN(parsed) ? str : parsed; + } + try { const decoded = hasher.decode(str); if (Array.isArray(decoded) && decoded.length > 0) { @@ -116,6 +124,30 @@ export function decodeHashIdSync(hashId: string | null | undefined): number | st return str; } +/** + * Decodes a HashID or numeric value strictly to a canonical integer. + * Useful for request payloads expecting int64 integers. + */ +export function decodeToNumberSync(hashId: number | string | null | undefined): number { + if (hashId === null || hashId === undefined || hashId === '') return 0; + if (typeof hashId === 'number') { + return isNaN(hashId) ? 0 : Math.trunc(hashId); + } + const str = String(hashId).trim(); + if (/^-?\d+$/.test(str)) { + const parsed = parseInt(str, 10); + return isNaN(parsed) ? 0 : parsed; + } + try { + const decoded = hasher.decode(str); + if (Array.isArray(decoded) && decoded.length > 0) { + return Number(decoded[0]); + } + } catch {} + const fallback = Number(str); + return isNaN(fallback) ? 0 : Math.trunc(fallback); +} + export const toolsService = { /** * Synchronous encoding @@ -127,10 +159,17 @@ export const toolsService = { /** * Synchronous decoding */ - decodeIdSync(hashId: string | null | undefined): number | string { + decodeIdSync(hashId: string | number | null | undefined): number | string { return decodeHashIdSync(hashId); }, + /** + * Synchronous decoding strictly to canonical integer + */ + decodeToNumberSync(hashId: number | string | null | undefined): number { + return decodeToNumberSync(hashId); + }, + /** * Encodes a raw ID to a HashID (returns Promise for backward compatibility with existing callers) */ diff --git a/PostFundManagement.ConsoleUI/src/utils/apiErrorParser.ts b/PostFundManagement.ConsoleUI/src/utils/apiErrorParser.ts index d744093..3f79eb3 100644 --- a/PostFundManagement.ConsoleUI/src/utils/apiErrorParser.ts +++ b/PostFundManagement.ConsoleUI/src/utils/apiErrorParser.ts @@ -75,12 +75,53 @@ function humanizeFieldName(field: string): string { return spaced.charAt(0).toUpperCase() + spaced.slice(1); } +/** + * Checks if a string contains raw HTTP request/response headers or dump. + */ +export function isRawHttpOutput(text: string): boolean { + if (!text || typeof text !== 'string') return false; + // If it has multiple header-like lines or standard HTTP request/response signatures + const headerPatterns = [ + /^(?:accept|authorization|content-type|content-length|origin|referer|user-agent|sec-ch-ua|sec-fetch|x-forwarded|x-real-ip|host):/im, + /(?:authorization:\s*bearer|content-type:\s*application\/)/i, + /HTTP\/\d(?:\.\d)?\s+\d{3}/i, + ]; + + let matches = 0; + const lines = text.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (/^[A-Za-z0-9-]+:\s*.+$/.test(trimmed)) { + if (headerPatterns.some((p) => p.test(trimmed))) { + matches++; + } + } + } + return matches >= 2 || (matches >= 1 && /(?:authorization:\s*bearer|content-type:\s*application)/i.test(text)); +} + +/** + * Redacts sensitive tokens and credentials from technical traces. + */ +export function sanitizeTechnicalDetails(text: string): string { + if (!text) return ''; + return text + .replace(/(authorization:\s*bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]') + .replace(/(bearer\s+)[a-zA-Z0-9._-]+/gi, '$1[REDACTED_TOKEN]') + .replace(/("?(?:token|access_token|password|secret|apiKey)"?\s*[:=]\s*)"[^"]+"/gi, '$1"[REDACTED]"'); +} + /** * Cleans technical jargon, stack traces, and framework namespaces from text strings. */ function cleanTechnicalJargon(text: string): string { if (!text) return ''; + // If text is a raw HTTP dump, do not return it as clean user text + if (isRawHttpOutput(text)) { + return ''; + } + const cleaned = text .replace(/^AxiosError:\s*/i, '') .replace(/^Error:\s*/i, '') @@ -205,23 +246,28 @@ export function parseFriendlyError( rawErrors = dataObj; } } else if (typeof rawResponseData === 'string') { - // Check if response data is stringified JSON containing ProblemDetails - try { - const parsed = JSON.parse(rawResponseData) as any; - if (isProblemDetailsObject(parsed)) { - isProblemDetails = true; - 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); - } - } catch { - if (!rawResponseData.includes(' 0 ? fieldErrors : undefined, errorsList: errorsList.length > 0 ? errorsList : undefined, - technicalDetails: technicalSummary || (statusCode ? `HTTP Status ${statusCode}` : undefined), + technicalDetails: technicalSummary ? sanitizeTechnicalDetails(technicalSummary) : (statusCode ? `HTTP Status ${statusCode}` : undefined), }; } -- 2.47.3