196 Commits
Author SHA1 Message Date
hermes 5ac317cbe3 Fixed partnerui hashing and over all theming 2026-10-25 19:44:46 +02:00
hermes 0bcc3484dc Updated evidence handling 2026-10-25 18:58:30 +02:00
hermes fddf400ddd Updated partner project listing to use returned data 2026-10-25 18:30:32 +02:00
hermes a1b8410bc2 Updated invite creation 2026-10-25 17:46:59 +02:00
hermes cb0da32ce4 Fixed project status update on approval to do perform compliance check 2026-10-25 17:14:42 +02:00
hermes 70e286670e Light refactor 2026-10-25 13:48:15 +02:00
hermes c6075a5c39 Fixed update organisation endpoint constraints 2026-10-25 13:47:33 +02:00
hermes 4e1211a1e1 Fixed idNumber missed assignment when updating organisation 2026-10-25 13:34:52 +02:00
hermes c8e54815ef Fixed update modal linked user assignment 2026-10-25 13:23:25 +02:00
hermes 31d775c917 Fixed frozen org editor modal 2026-10-25 12:50:35 +02:00
hermes 78d147b002 Fix UI freeze by removing cyclical useEffect and stabilizing executive candidate memo 2026-10-25 12:13:15 +02:00
hermes 07018c3ce4 Auto-assign linked executive user and persist userId in organisation update modal 2026-10-25 12:02:30 +02:00
hermes db08bbf162 Added admin override when updating organisation 2026-10-25 11:19:59 +02:00
hermes ac357285c3 Enhanced shieldcheck 2026-10-25 11:07:05 +02:00
hermes a4927f9742 Fix vite env type declarations and tsconfig in PartnerUI 2026-10-25 11:04:29 +02:00
hermes fac88f3471 Fix missing lucide icon imports in GovernancePage and null-safety in DashboardPage 2026-10-25 10:35:27 +02:00
hermes aeb7542cfe Optimised cach usage, and enforced maker checker 2026-10-25 10:11:57 +02:00
hermes 9724bbac24 Removed award validation from budget creation process 2026-10-25 09:17:00 +02:00
hermes deb21dbb9a Redesigned dashboard 2026-10-25 08:52:14 +02:00
khwezi 98fe1e3e3d Merge pull request 'Moved top bar to top' (#173) from finishline into main
Reviewed-on: #173
2026-10-25 08:27:23 +02:00
hermes 48edfa1cbb Moved top bar to top 2026-10-25 08:26:37 +02:00
khwezi d9b65010cf Merge pull request 'Redesigned PartnerUi dashboard' (#172) from finishline into main
Reviewed-on: #172
2026-10-25 08:02:27 +02:00
hermes 3e1d8bc1cc Redesigned PartnerUi dashboard 2026-10-25 08:01:34 +02:00
khwezi bc441c1255 Merge pull request 'Fixed ConsoleUi salt handling' (#171) from finishline into main
Reviewed-on: #171
2026-10-24 18:10:42 +02:00
hermes 17a4149a73 Fixed ConsoleUi salt handling 2026-10-24 18:09:59 +02:00
khwezi 6e786ca796 Merge pull request 'Finishline' (#170) from finishline into main
Reviewed-on: #170
2026-10-24 17:37:15 +02:00
khwezi 90631d22ae Merge pull request 'Applied extensive leaked secret protection' (#169) from finishline into main
Reviewed-on: #169
2026-10-24 15:57:01 +02:00
khwezi 010f2d5eaa Merge pull request 'Stopped leaking secrets' (#168) from finishline into main
Reviewed-on: #168
2026-10-24 15:44:08 +02:00
khwezi b8f5ebf69a Merge pull request 'Finishline' (#167) from finishline into main
Reviewed-on: #167
2026-10-24 15:23:51 +02:00
khwezi 957e75a853 Merge pull request 'Applied CORS fix' (#166) from finishline into main
Reviewed-on: #166
2026-10-24 14:59:28 +02:00
khwezi 8a06a86eb3 Merge pull request 'Fixed Console user lockout and unlock implementation' (#165) from finishline into main
Reviewed-on: #165
2026-10-24 14:39:40 +02:00
khwezi f8f826979d Merge pull request 'Finishline' (#164) from finishline into main
Reviewed-on: #164
2026-10-24 12:51:15 +02:00
khwezi c4a0469bae Merge pull request 'Finishline' (#163) from finishline into main
Reviewed-on: #163
2026-10-24 12:13:11 +02:00
khwezi a3f461478c Merge pull request 'PartnerUi fixes' (#162) from finishline into main
Reviewed-on: #162
2026-10-24 11:49:45 +02:00
khwezi cc62696017 Merge pull request 'Fixed sync handshake' (#161) from finishline into main
Reviewed-on: #161
2026-10-23 10:20:48 +02:00
khwezi d4598e0c6c Merge pull request 'Implemented partnerui' (#160) from finishline into main
Reviewed-on: #160
2026-10-23 00:59:39 +02:00
khwezi 43656e848a Merge pull request 'Applied mailbox constraints' (#159) from finishline into main
Reviewed-on: #159
2026-10-22 23:58:12 +02:00
khwezi 3a3245b643 Merge pull request 'Fixed notification composition' (#158) from finishline into main
Reviewed-on: #158
2026-10-22 23:36:39 +02:00
khwezi fdaf12a183 Merge pull request 'Implemented notification strategy' (#157) from finishline into main
Reviewed-on: #157
2026-10-22 23:01:14 +02:00
khwezi 6cdd03a02f Merge pull request 'Implemented markdown based notification message body formatting' (#156) from finishline into main
Reviewed-on: #156
2026-10-22 18:19:36 +02:00
khwezi 1d1060d031 Merge pull request 'Implemented mailbox in ConsoleUI' (#155) from finishline into main
Reviewed-on: #155
2026-10-22 18:01:54 +02:00
khwezi b0c89362d0 Merge pull request 'Fixed beneficiary page type error' (#154) from finishline into main
Reviewed-on: #154
2026-10-22 17:15:04 +02:00
khwezi dee9ccf0b1 Merge pull request 'Fixed beneficiary outcomes misconfiguration' (#153) from finishline into main
Reviewed-on: #153
2026-10-22 17:08:35 +02:00
khwezi d9b3e80f7c Merge pull request 'Finishline' (#152) from finishline into main
Reviewed-on: #152
2026-10-22 16:08:08 +02:00
khwezi 8688c752d6 Merge pull request 'Enforced relationships between activities and indicators, fixed actuals recording' (#151) from finishline into main
Reviewed-on: #151
2026-10-22 13:49:32 +02:00
khwezi 937a5595d8 Merge pull request 'Implemented audit logging' (#150) from finishline into main
Reviewed-on: #150
2026-10-22 12:37:14 +02:00
khwezi 58ce606410 Merge pull request 'Enforced approval rights' (#149) from finishline into main
Reviewed-on: #149
2026-10-22 11:26:49 +02:00
khwezi a98bfa4308 Merge pull request 'Refactored project view into modal' (#148) from finishline into main
Reviewed-on: #148
2026-10-22 09:42:26 +02:00
khwezi 6e56a4c223 Merge pull request 'Refactored projects page, fixed security user modal' (#147) from finishline into main
Reviewed-on: #147
2026-10-22 07:40:34 +02:00
khwezi e38edd24a0 Merge pull request 'Finishline' (#146) from finishline into main
Reviewed-on: #146
2026-10-22 06:24:18 +02:00
khwezi 1ac0018926 Merge pull request 'Finishline' (#145) from finishline into main
Reviewed-on: #145
2026-10-21 11:07:27 +02:00
khwezi 5ad59f6569 Merge pull request 'Restructured .net solutions' (#144) from finishline into main
Reviewed-on: #144
2026-10-21 10:41:52 +02:00
khwezi 71a0abf0b2 Merge pull request 'Fixed new user username formating' (#143) from finishline into main
Reviewed-on: #143
2026-10-21 10:18:50 +02:00
khwezi 627885e36b Merge pull request 'Finishline' (#142) from finishline into main
Reviewed-on: #142
2026-10-21 08:55:56 +02:00
khwezi 447ffb366e Merge pull request 'Implemented rbac' (#141) from finishline into main
Reviewed-on: #141
2026-10-11 11:17:48 +02:00
khwezi a9bb319eb1 Merge pull request 'Fixed skoruba api value assignment in docker compose' (#140) from finishline into main
Reviewed-on: #140
2026-10-11 08:29:59 +02:00
khwezi bbba869cdd Merge pull request 'Fixed skoruba api address configs' (#139) from finishline into main
Reviewed-on: #139
2026-10-11 08:18:06 +02:00
khwezi 5ae2622454 Merge pull request 'Finishline' (#138) from finishline into main
Reviewed-on: #138
2026-10-11 08:02:57 +02:00
khwezi bd0f03344d Merge pull request 'Finishline' (#137) from finishline into main
Reviewed-on: #137
2026-10-11 07:50:36 +02:00
khwezi 1af860fdc1 Merge pull request 'Redesigned nav bar' (#136) from finishline into main
Reviewed-on: #136
2026-10-04 18:27:34 +02:00
khwezi c04ae9a933 Merge pull request 'Refactored org modal logic' (#135) from finishline into main
Reviewed-on: #135
2026-10-04 18:22:13 +02:00
khwezi 0e853811a9 Merge pull request 'Finishline' (#134) from finishline into main
Reviewed-on: #134
2026-10-04 18:05:14 +02:00
khwezi 3a3d5daefc Merge pull request 'Refactored org create and update models' (#133) from finishline into main
Reviewed-on: #133
2026-10-04 11:13:57 +02:00
khwezi fe9b9052e9 Merge pull request 'Refactored tenant organisatio listing in tenant page' (#132) from finishline into main
Reviewed-on: #132
2026-10-04 11:09:29 +02:00
khwezi d25545ddad Merge pull request 'Corrected boolean logic on bypass filter logic' (#131) from finishline into main
Reviewed-on: #131
2026-10-04 10:58:11 +02:00
khwezi 7effc32498 Merge pull request 'Fine tuned organisation onboarding and tenant management' (#130) from finishline into main
Reviewed-on: #130
2026-10-04 10:35:31 +02:00
khwezi 8909bcf5c4 Merge pull request 'Refactored organisation entities and endpoints' (#129) from finishline into main
Reviewed-on: #129
2026-10-04 10:12:05 +02:00
khwezi 411e603f51 Merge pull request 'Added tenant management' (#128) from finishline into main
Reviewed-on: #128
2026-10-04 09:14:34 +02:00
khwezi 138ef84973 Merge pull request 'Hardened API and catered for Partner role query filter bypass' (#127) from finishline into main
Reviewed-on: #127
2026-10-04 08:19:48 +02:00
khwezi 312721bf51 Merge pull request 'Redesigned dash' (#126) from finishline into main
Reviewed-on: #126
2026-09-29 12:24:27 +02:00
khwezi dd59294911 Merge pull request 'Refactored dashboard' (#125) from finishline into main
Reviewed-on: #125
2026-09-29 11:53:13 +02:00
khwezi 28a3a17a15 Merge pull request 'Fixed missing dependency' (#124) from finishline into main
Reviewed-on: #124
2026-09-28 16:47:20 +02:00
khwezi 4c2b0cd7d5 Merge pull request 'Added dockerfile to partner ui' (#123) from finishline into main
Reviewed-on: #123
2026-09-28 16:33:44 +02:00
khwezi 275fd109e6 Merge pull request 'Finishline' (#122) from finishline into main
Reviewed-on: #122
2026-09-28 16:27:54 +02:00
khwezi 248cc2fa2d Merge pull request 'Fixed timeline height' (#121) from finishline into main
Reviewed-on: #121
2026-09-28 11:56:15 +02:00
khwezi 761d14beb9 Merge pull request 'Light projects page refactor' (#120) from finishline into main
Reviewed-on: #120
2026-09-28 11:45:35 +02:00
khwezi 931e1a8698 Merge pull request 'Refactored projects and milestones page' (#119) from finishline into main
Reviewed-on: #119
2026-09-28 11:34:32 +02:00
khwezi aff58caeb0 Merge pull request 'Removed duplicate filter in settings page' (#118) from finishline into main
Reviewed-on: #118
2026-09-28 11:13:41 +02:00
khwezi 1c5080bf04 Merge pull request 'Added Role column to user directory' (#117) from finishline into main
Reviewed-on: #117
2026-09-28 10:59:34 +02:00
khwezi 1fb157aa0a Merge pull request 'Finishline' (#116) from finishline into main
Reviewed-on: #116
2026-09-28 10:40:39 +02:00
khwezi 0adfae1616 Merge pull request 'Refactored get compliance item endpoint' (#115) from finishline into main
Reviewed-on: #115
2026-09-27 15:06:14 +02:00
khwezi 4856b6e6f4 Merge pull request 'fixed create site visit error' (#114) from finishline into main
Reviewed-on: #114
2026-09-27 14:41:30 +02:00
khwezi 0ba47269aa Merge pull request 'Finishline' (#113) from finishline into main
Reviewed-on: #113
2026-09-27 14:36:16 +02:00
khwezi 5e25fd7a11 Merge pull request 'fixed new change request logic' (#112) from finishline into main
Reviewed-on: #112
2026-09-27 14:16:31 +02:00
khwezi f8fff04065 Merge pull request 'Fixed mismatched endpoint calling' (#111) from finishline into main
Reviewed-on: #111
2026-09-27 14:08:14 +02:00
khwezi aca5882ad3 Merge pull request 'Fixed change management rendering error' (#110) from finishline into main
Reviewed-on: #110
2026-09-27 13:43:34 +02:00
khwezi 6b027193d1 Merge pull request 'Updated api usage, corrected table designs' (#109) from finishline into main
Reviewed-on: #109
2026-09-27 13:32:01 +02:00
khwezi 81702a4bf8 Merge pull request 'Refactored docker compose' (#108) from finishline into main
Reviewed-on: #108
2026-09-27 13:08:13 +02:00
khwezi 5eeb1cbfc7 Merge pull request 'Refactored endpoints' (#107) from finishline into main
Reviewed-on: #107
2026-09-27 13:07:18 +02:00
khwezi fb00986f59 Merge pull request 'Issue creation, reolution is optional' (#106) from finishline into main
Reviewed-on: #106
2026-09-27 12:56:05 +02:00
khwezi 0338737385 Merge pull request 'Fixed UI inconsistencies' (#105) from finishline into main
Reviewed-on: #105
2026-09-27 12:04:56 +02:00
khwezi 82c0936b4c Merge pull request 'Added, beneficiaries, site visits and change management' (#104) from finishline into main
Reviewed-on: #104
2026-09-27 11:42:03 +02:00
khwezi 5d2a886f6f Merge pull request 'Removed settings page mock data and replaced with api calls' (#103) from finishline into main
Reviewed-on: #103
2026-09-27 10:58:50 +02:00
khwezi dc4047ca6d Merge pull request 'Added audit log endpoint' (#102) from finishline into main
Reviewed-on: #102
2026-09-27 10:13:33 +02:00
khwezi 46fe14d9ef Merge pull request 'Revised all UIs' (#101) from finishline into main
Reviewed-on: #101
2026-09-26 16:37:16 +02:00
khwezi f17368d939 Merge pull request 'Added budget update' (#100) from finishline into main
Reviewed-on: #100
2026-09-26 15:15:08 +02:00
khwezi 7aea3fa8de Merge pull request 'Added postcss' (#99) from finishline into main
Reviewed-on: #99
2026-09-26 13:55:49 +02:00
khwezi d43eb17aa3 Merge pull request 'Implemented data export' (#98) from finishline into main
Reviewed-on: #98
2026-09-26 13:46:36 +02:00
khwezi 01547d8c3f Merge pull request 'Fixed portfolio retrieval' (#97) from finishline into main
Reviewed-on: #97
2026-09-26 12:04:37 +02:00
khwezi 18dd9131cb Merge pull request 'Added missing instrument endpoints' (#96) from finishline into main
Reviewed-on: #96
2026-09-26 11:21:27 +02:00
khwezi f82a5cef76 Merge pull request 'Fixed update organisation' (#95) from finishline into main
Reviewed-on: #95
2026-09-26 10:39:45 +02:00
khwezi 614730caab Merge pull request 'Fixed API calls on governance page' (#94) from finishline into main
Reviewed-on: #94
2026-09-26 10:29:52 +02:00
khwezi 9823cd7ea6 Merge pull request 'Surfaced programme rules' (#93) from finishline into main
Reviewed-on: #93
2026-09-26 07:56:38 +02:00
khwezi a7a68fc290 Merge pull request 'Finishline' (#92) from finishline into main
Reviewed-on: #92
2026-09-25 19:06:37 +02:00
khwezi 4dbc74e488 Merge pull request 'Finishline' (#91) from finishline into main
Reviewed-on: #91
2026-09-24 16:08:03 +02:00
khwezi d658497545 Merge pull request 'Finishline' (#90) from finishline into main
Reviewed-on: #90
2026-09-24 12:01:36 +02:00
khwezi b947959724 Merge pull request 'Added linkage to primary tenant entities' (#89) from finishline into main
Reviewed-on: #89
2026-09-22 07:09:20 +02:00
khwezi 58126e7573 Merge pull request 'Make retrieval of contract file auditable' (#88) from finishline into main
Reviewed-on: #88
2026-09-22 06:29:58 +02:00
khwezi 2fede443cc Merge pull request 'Added automatic audit feature' (#87) from finishline into main
Reviewed-on: #87
2026-09-22 06:11:18 +02:00
khwezi 80f220fcae Merge pull request 'Test 2 reoslutions' (#86) from garages3 into main
Reviewed-on: #86
2026-09-20 15:26:35 +02:00
khwezi 06e2c3ee6a Merge pull request 'Remediated test #1 results' (#85) from garages3 into main
Reviewed-on: #85
2026-09-20 13:52:03 +02:00
khwezi 3c0fd8844c Merge pull request 'Refactored UI endpoint usage' (#84) from garages3 into main
Reviewed-on: #84
2026-09-20 08:27:14 +02:00
khwezi b7c135f0fc Merge pull request 'Garages3' (#83) from garages3 into main
Reviewed-on: #83
2026-09-18 16:12:06 +02:00
khwezi db053e3e5f Merge pull request 'Garages3' (#82) from garages3 into main
Reviewed-on: #82
2026-09-17 07:39:34 +02:00
khwezi d7177e1a72 Merge pull request 'Removed package.json' (#81) from garages3 into main
Reviewed-on: #81
2026-09-17 07:23:54 +02:00
khwezi c67041faea Merge pull request 'Refactored UI and compose file' (#80) from garages3 into main
Reviewed-on: #80
2026-09-17 07:08:26 +02:00
khwezi 0e8a5e1169 Merge pull request 'Removed docker debug code' (#79) from garages3 into main
Reviewed-on: #79
2026-09-15 12:13:47 +02:00
khwezi 41fdb067fe Merge pull request 'Refactored ApiVersioning service registration' (#78) from garages3 into main
Reviewed-on: #78
2026-09-15 12:09:47 +02:00
khwezi b4b687dfcc Merge pull request 'Added OPTIONS header handling' (#77) from garages3 into main
Reviewed-on: #77
2026-09-15 08:52:23 +02:00
khwezi f200ccbf60 Merge pull request 'Added RequireCors() to RouteGroup' (#76) from garages3 into main
Reviewed-on: #76
2026-09-15 08:45:25 +02:00
khwezi 27f57150e1 Merge pull request 'Moved .RequireAuthentication() to endpoints, removed it from EndpointGroup' (#75) from garages3 into main
Reviewed-on: #75
2026-09-15 08:37:43 +02:00
khwezi 79c63107e9 Merge pull request 'Applied configurable CORS policy to API' (#74) from garages3 into main
Reviewed-on: #74
2026-09-15 07:30:29 +02:00
khwezi d945731252 Merge pull request 'Fixed UI' (#73) from garages3 into main
Reviewed-on: #73
2026-09-14 11:44:00 +02:00
khwezi c979a4632e Merge pull request 'Debug refactor' (#72) from garages3 into main
Reviewed-on: #72
2026-09-14 10:46:59 +02:00
khwezi 9eed3cff6b Merge pull request 'Debugs refactor' (#71) from garages3 into main
Reviewed-on: #71
2026-09-14 10:39:04 +02:00
khwezi 6db1a2f367 Merge pull request 'Garages3' (#70) from garages3 into main
Reviewed-on: #70
2026-09-14 10:34:46 +02:00
khwezi 06d9952cd7 Merge pull request 'Rebuilt pfm-api dockerfile' (#69) from garages3 into main
Reviewed-on: #69
2026-09-14 10:29:13 +02:00
khwezi 92d481f75d Merge pull request 'Light refactor' (#68) from garages3 into main
Reviewed-on: #68
2026-09-14 10:23:19 +02:00
khwezi 1c36602995 Merge pull request 'Simplified fpm-api dockerfile' (#67) from garages3 into main
Reviewed-on: #67
2026-09-14 10:21:10 +02:00
khwezi 5ae0c500ac Merge pull request 'Added debug code' (#66) from garages3 into main
Reviewed-on: #66
2026-09-14 10:13:58 +02:00
khwezi db04f800e9 Merge pull request 'Changed pfm-api dockerfile paths' (#65) from garages3 into main
Reviewed-on: #65
2026-09-14 10:10:49 +02:00
khwezi e7a19611f0 Merge pull request 'Simplified dockerfile (pfm-api)' (#64) from garages3 into main
Reviewed-on: #64
2026-09-14 10:04:10 +02:00
khwezi 5ad24da7cf Merge pull request 'Refactored compose file to assign same context to both services' (#63) from garages3 into main
Reviewed-on: #63
2026-09-14 10:02:01 +02:00
khwezi 156dae40b7 Merge pull request 'Fixed missing file copy' (#62) from garages3 into main
Reviewed-on: #62
2026-09-14 09:50:32 +02:00
khwezi 1b67d9cf8b Merge pull request 'Debug refactor on dockerfile' (#61) from garages3 into main
Reviewed-on: #61
2026-09-14 09:47:00 +02:00
khwezi 1bd05e64e5 Merge pull request 'Added better dotnet build handling' (#60) from garages3 into main
Reviewed-on: #60
2026-09-14 09:42:10 +02:00
khwezi 303dba3c6f Merge pull request 'Added more verbosity to dotnet build' (#59) from garages3 into main
Reviewed-on: #59
2026-09-14 09:35:30 +02:00
khwezi 1dd4f54a1d Merge pull request 'Added .dockerignore' (#58) from garages3 into main
Reviewed-on: #58
2026-09-14 09:32:21 +02:00
khwezi fae1e080c4 Merge pull request 'Added stream redirection to dotnet commands' (#57) from garages3 into main
Reviewed-on: #57
2026-09-14 09:14:17 +02:00
khwezi 4de23d27b3 Merge pull request 'Commented out pfm-console to see if the api image will build' (#56) from garages3 into main
Reviewed-on: #56
2026-09-14 09:06:50 +02:00
khwezi d4bce12dbb Merge pull request 'Removed stream redirection' (#55) from garages3 into main
Reviewed-on: #55
2026-09-13 18:15:33 +02:00
khwezi 299b876e1c Merge pull request 'Fixed Api docker file paths' (#54) from garages3 into main
Reviewed-on: #54
2026-09-13 18:13:13 +02:00
khwezi dde1dbe056 Merge pull request 'Added debug code to dotnet build and publish lines' (#53) from garages3 into main
Reviewed-on: #53
2026-09-13 18:09:51 +02:00
khwezi b81e6feb45 Merge pull request 'Moved api dockerfile to project folder' (#52) from garages3 into main
Reviewed-on: #52
2026-09-13 18:07:52 +02:00
khwezi 4138a1941f Merge pull request 'Corrected compose service contexts' (#51) from garages3 into main
Reviewed-on: #51
2026-09-13 18:01:52 +02:00
khwezi 17af31d810 Merge pull request 'Fixed dockerfile paths' (#50) from garages3 into main
Reviewed-on: #50
2026-09-13 17:56:19 +02:00
khwezi b85cf6a1cb Merge pull request 'Light refactor' (#49) from garages3 into main
Reviewed-on: #49
2026-09-13 17:51:45 +02:00
khwezi 81ddc11746 Merge pull request 'Refactored API dockerfile' (#48) from garages3 into main
Reviewed-on: #48
2026-09-13 17:50:29 +02:00
khwezi 5b94672ff5 Merge pull request 'Stable API run' (#47) from garages3 into main
Reviewed-on: #47
2026-09-13 17:41:42 +02:00
khwezi c535392ef1 Merge pull request 'Added debug term' (#46) from garages3 into main
Reviewed-on: #46
2026-09-12 12:22:43 +02:00
khwezi 0607de1802 Merge pull request 'Second fix' (#45) from garages3 into main
Reviewed-on: #45
2026-09-12 12:19:34 +02:00
khwezi 4c1e52e452 Merge pull request 'Light refactor' (#44) from garages3 into main
Reviewed-on: #44
2026-09-12 12:14:13 +02:00
khwezi 2414d74d1c Merge pull request 'Light refactor' (#43) from garages3 into main
Reviewed-on: #43
2026-09-12 12:10:17 +02:00
khwezi 93d9661a7f Merge pull request 'Rewrite pfm-api docker file' (#42) from garages3 into main
Reviewed-on: #42
2026-09-12 12:04:47 +02:00
khwezi dba401fe94 Merge pull request 'Fixed publish alias' (#41) from garages3 into main
Reviewed-on: #41
2026-09-12 11:58:37 +02:00
khwezi 42d73fe6cb Merge pull request 'Added error output to dotnet build line' (#40) from garages3 into main
Reviewed-on: #40
2026-09-12 11:54:57 +02:00
khwezi 1475887a7f Merge pull request 'Added working directory switch' (#39) from garages3 into main
Reviewed-on: #39
2026-09-12 11:49:52 +02:00
khwezi 730664d9c4 Merge pull request 'PFM Api docker refactor' (#38) from garages3 into main
Reviewed-on: #38
2026-09-12 11:45:32 +02:00
khwezi f38e569a34 Merge pull request 'Implemented missing file handling endpoints' (#37) from garages3 into main
Reviewed-on: #37
2026-09-12 11:26:45 +02:00
khwezi 7e79171f97 Merge pull request 'Added image name to compose file for console' (#36) from endpoints into main
Reviewed-on: #36
2026-09-11 16:24:11 +02:00
khwezi d7b868f2c1 Merge pull request 'Added more cors origins' (#35) from endpoints into main
Reviewed-on: #35
2026-09-11 16:17:07 +02:00
khwezi d43a09b371 Merge pull request 'Refactored console dockerfile' (#34) from endpoints into main
Reviewed-on: #34
2026-09-11 16:05:21 +02:00
khwezi 0dc0de1502 Merge pull request 'Fixed dockerfile paths' (#33) from endpoints into main
Reviewed-on: #33
2026-09-11 15:47:30 +02:00
khwezi de47dd4a4f Merge pull request 'Supplied defaults to compose env variables' (#32) from endpoints into main
Reviewed-on: #32
2026-09-11 15:33:47 +02:00
khwezi 2810cc669b Merge pull request 'Fixed compose syntax error' (#31) from endpoints into main
Reviewed-on: #31
2026-09-11 15:30:40 +02:00
khwezi 734af8c86f Merge pull request 'Refactored compose file to include the ui in deployment' (#30) from endpoints into main
Reviewed-on: #30
2026-09-11 15:29:21 +02:00
khwezi a754c42d14 Merge pull request 'Updated Console compose file to use variables' (#29) from endpoints into main
Reviewed-on: #29
2026-09-11 15:19:31 +02:00
khwezi bd7ca9372f Merge pull request 'Added specific origin' (#28) from endpoints into main
Reviewed-on: #28
2026-09-11 14:59:34 +02:00
khwezi 0d01d91ab4 Merge pull request 'Added cors after routes' (#27) from endpoints into main
Reviewed-on: #27
2026-09-11 14:54:47 +02:00
khwezi 2c817a8818 Merge pull request 'Endpoints' (#26) from endpoints into main
Reviewed-on: #26
2026-09-11 14:35:02 +02:00
khwezi 4c3a96917b Merge pull request 'Improved results handling in service layer' (#25) from endpoints into main
Reviewed-on: #25
2026-09-11 10:33:47 +02:00
khwezi e5c3a75795 Merge pull request 'Stable run with validations' (#24) from endpoints into main
Reviewed-on: #24
2026-09-06 10:49:57 +02:00
khwezi 1bc7e65960 Merge pull request 'Added tooling' (#23) from endpoints into main
Reviewed-on: #23
2026-09-05 18:27:35 +02:00
khwezi 094126d84b Merge pull request 'Fixed endpointname uniqueness' (#22) from endpoints into main
Reviewed-on: #22
2026-09-05 13:07:41 +02:00
khwezi 2506241a25 Merge pull request 'Fixed endpoint uniqueness' (#21) from endpoints into main
Reviewed-on: #21
2026-09-05 12:58:17 +02:00
khwezi cfe3530639 Merge pull request 'Endpoints' (#20) from endpoints into main
Reviewed-on: #20
2026-09-05 12:50:04 +02:00
khwezi 985557bee4 Merge pull request 'Implemented caching on all get endpoints and service layer' (#19) from endpoints into main
Reviewed-on: #19
2026-09-05 08:30:16 +02:00
khwezi f0b8b3051a Merge pull request 'Improved getusers cache key' (#18) from endpoints into main
Reviewed-on: #18
2026-09-04 06:08:39 +02:00
khwezi 6c88b9e533 Merge pull request 'Endpoints' (#17) from endpoints into main
Reviewed-on: #17
2026-09-04 05:54:30 +02:00
khwezi 7d67408d2c Merge pull request 'Additional redis configs' (#16) from endpoints into main
Reviewed-on: #16
2026-09-04 05:39:44 +02:00
khwezi d1d58a6183 Merge pull request 'Added service level caching to IdentityService, Communication and Evidence' (#15) from endpoints into main
Reviewed-on: #15
2026-09-03 16:38:08 +02:00
khwezi 60ad154841 Merge pull request 'Implemented distributed caching' (#14) from endpoints into main
Reviewed-on: #14
2026-09-03 11:09:52 +02:00
khwezi 2c13170859 Merge pull request 'Endpoints' (#13) from endpoints into main
Reviewed-on: #13
2026-09-02 16:14:34 +02:00
khwezi d87c8aa3c9 Merge pull request 'Updated configurations' (#12) from endpoints into main
Reviewed-on: #12
2026-09-02 09:45:38 +02:00
khwezi 6af7815ad1 Merge pull request 'Removed node tooling install' (#11) from endpoints into main
Reviewed-on: #11
2026-09-02 09:30:06 +02:00
khwezi a169f6c3f6 Merge pull request 'Endpoints' (#10) from endpoints into main
Reviewed-on: #10
2026-09-02 09:28:17 +02:00
khwezi 65ea753c19 Merge pull request 'Endpoints' (#9) from endpoints into main
Reviewed-on: #9
2026-09-01 12:29:47 +02:00
khwezi e6d2663265 Merge pull request 'Endpoints' (#8) from endpoints into main
Reviewed-on: #8
2026-08-30 17:47:24 +02:00
khwezi 91b5814b60 Merge pull request 'Endpoints' (#7) from endpoints into main
Reviewed-on: #7
2026-08-30 16:27:37 +02:00
khwezi 0c4c848568 Merge pull request 'Endpoints' (#6) from endpoints into main
Reviewed-on: #6
2026-08-30 16:11:53 +02:00
khwezi fb05150ebf Merge pull request 'Endpoints' (#5) from endpoints into main
Reviewed-on: #5
2026-08-30 15:47:32 +02:00
khwezi 2f9c20020b Merge pull request 'Reogranised API and Security assets' (#4) from endpoints into main
Reviewed-on: #4
2026-08-30 15:09:32 +02:00
khwezi a771957db3 Merge pull request 'Endpoints' (#3) from endpoints into main
Reviewed-on: #3
2026-08-30 14:54:58 +02:00
khwezi 46267a2a34 Merge pull request 'Endpoints' (#2) from endpoints into main
Reviewed-on: #2
2026-08-30 14:26:08 +02:00
khwezi a7f360cc2a Merge pull request 'Security' (#1) from security into main
Reviewed-on: #1
2026-08-21 11:47:36 +02:00
hermes 67c08b40ca Refactored gitignore 2026-08-21 11:45:33 +02:00
46 changed files with 1954 additions and 2465 deletions
@@ -11,7 +11,7 @@ public class GetProjectEvidenceEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapGet("api/evidence/{projectId}", async (string projectId, IEvidenceService service,
builder.MapGet("api/evidence/projects/{projectId}", async (string projectId, IEvidenceService service,
CancellationToken cancellationToken = default) =>
await service.GetProjectEvidencesAsync(projectId, cancellationToken) switch
{
@@ -20,6 +20,7 @@ public sealed record InviteRequest([Required] long OrganisationId,
[Required] long InvitedOrganisationId,
[Required] long AwardId,
[Required] long ContractId,
[Required] long ProjectId,
[Required] Guid Recipient,
[Required][MaxLength(LabelLength)] string Message,
[Required] NotificationPlatform Platform);
@@ -182,7 +182,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
if (!await context.Users.AnyAsync(u => u.Id == request.OrganisationUser, cancellationToken))
return Result.Fail(ValidationError.Create("Organisation user not found"));
if (!await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
if (!tenantContext.IsAdmin && !await context.TenantUsers.AnyAsync(u => u.UserId == request.OrganisationUser && u.TenantId == tenantContext.TenantId, cancellationToken))
return Result.Fail(ValidationError.Create("Organisation user does not belong to the current tenant"));
var changedTotal = await context.Organisations
@@ -192,6 +192,7 @@ public sealed class GovernanceService(IDbContextFactory<ApplicationDbContext> co
.SetProperty(u => u.RegistrationNo, request.RegistrationNo.Clean())
.SetProperty(u => u.Email, request.Email.ToLower(CultureInfo.InvariantCulture).Clean())
.SetProperty(u => u.Type, request.Type)
.SetProperty(u => u.UserId, request.OrganisationUser)
.SetProperty(u => u.Status, request.Status), cancellationToken);
var tenantOrganisation = await context.TenantOrganisations.AsNoTracking().FirstOrDefaultAsync(f => f.OrganisationId == request.OrganisationId, cancellationToken);
@@ -159,12 +159,7 @@ public sealed class GrantsService(IDbContextFactory<ApplicationDbContext> contex
await using var context = await contextFactory.CreateDbContextAsync(cancellationToken);
if (!await context.Projects.AnyAsync(p => p.Id == request.ProjectId, cancellationToken))
return Result.Fail(NotFoundError.New);
var hasApprovedAward = await context.Awards
.AnyAsync(a => a.ProjectId == request.ProjectId && a.Status == ApprovalStatus.Approved, cancellationToken);
if (!hasApprovedAward)
return Result.Fail(ValidationError.Create("Cannot create budget: The project's linked award is not approved or does not exist."));
return Result.Fail(NotFoundError.New);
var budget = new Domain.Entities.Budget
{
@@ -20,27 +20,6 @@ public sealed class ProjectService(IDbContextFactory<ApplicationDbContext> conte
if (!await context.Users.AnyAsync(u => u.Id == userId, cancellationToken))
return Result.Fail(NotFoundError.New);
if (request.Status == ApprovalStatus.Approved)
{
var hasUnmetCompliance = await context.ComplianceItems
.AnyAsync(c => c.ProjectId == request.ProjectId && c.Status == ComplianceStatus.NotMet, cancellationToken);
if (hasUnmetCompliance)
return Result.Fail(ValidationError.Create("Project cannot be approved because some compliance items are not met."));
var projectBeneficiaryIds = await context.Beneficiaries
.Where(b => b.ProjectId == request.ProjectId)
.Select(b => b.Id)
.ToListAsync(cancellationToken);
if (projectBeneficiaryIds.Count > 0)
{
var hasUnmetOutcomes = await context.Outcomes
.AnyAsync(o => projectBeneficiaryIds.Contains(o.BeneficiaryId) && o.Status == ComplianceStatus.NotMet, cancellationToken);
if (hasUnmetOutcomes)
return Result.Fail(ValidationError.Create("Project cannot be approved because some beneficiary outcomes are not met."));
}
}
var changedTotal = await context.Projects
.Where(f => f.Id == request.ProjectId)
.ExecuteUpdateAsync(f => f
@@ -51,8 +51,10 @@ public sealed partial class HashService(IHashids hasher) : IService
int[] decoded = hasher.Decode(hash);
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Int hash token."))
.ToResult(decoded[0]);
if (decoded.Length <= 0)
return Result.Fail<int>(ValidationError.Create("Invalid or modified Int hash token."));
return Result.Ok(decoded[0]);
}
public Result<long> DecodeLongIdHash(string hash)
@@ -62,8 +64,10 @@ public sealed partial class HashService(IHashids hasher) : IService
long[] decoded = hasher.DecodeLong(hash);
return Result.FailIf(decoded.Length <= 0, ValidationError.Create("Invalid or modified Long hash token."))
.ToResult(decoded[0]);
if (decoded.Length <= 0)
return Result.Fail<long>(ValidationError.Create("Invalid or modified Long hash token."));
return Result.Ok(decoded[0]);
}
public async Task<Result<string>> DecodeHexHash(string hex) =>
@@ -24,3 +24,4 @@ VITE_ENABLE_HTTPS="true"
# HasherSettings for HashIds compatibility with backend pfm-api
VITE_HASHER_MIN_LENGTH="11"
VITE_HASHER_SALT="TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=="
@@ -30,16 +30,15 @@ OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__Cli
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
# 6. Hasher Settings
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}"
HASHER_SALT="${VITE_HASHER_SALT:-${HASHER_SALT:-${HasherSettings__Salt:-${HasherSettings_Salt:-TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==}}}}"
if [ -d "$TARGET_DIR" ]; then
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
# Generate env-config.js for instant, synchronous window.__RUNTIME_CONFIG__ initialization
# Public SPA clients use Authorization Code flow with PKCE; client secrets and hasher salts must NEVER be injected or exposed here.
cat <<EOF > "$TARGET_DIR/env-config.js"
// Generated dynamically at container startup from deployment environment variables
// Note: Secrets such as client secrets, private keys, or hashing salts must NEVER be exposed here.
window.__RUNTIME_CONFIG__ = {
API_BASE_URL: "${API_BASE_URL}",
VITE_API_BASE_URL: "${API_BASE_URL}",
@@ -53,6 +52,8 @@ window.__RUNTIME_CONFIG__ = {
VITE_OIDC_SCOPE: "${OIDC_SCOPE}",
HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
VITE_HASHER_MIN_LENGTH: "${HASHER_MIN_LENGTH}",
HASHER_SALT: "${HASHER_SALT}",
VITE_HASHER_SALT: "${HASHER_SALT}",
ApiSettings: {
BaseUrl: "${API_BASE_URL}"
},
@@ -63,6 +64,7 @@ window.__RUNTIME_CONFIG__ = {
Scope: "${OIDC_SCOPE}"
},
HasherSettings: {
Salt: "${HASHER_SALT}",
MinHashLength: "${HASHER_MIN_LENGTH}"
}
};
@@ -81,6 +83,7 @@ EOF
"Scope": "${OIDC_SCOPE}"
},
"HasherSettings": {
"Salt": "${HASHER_SALT}",
"MinHashLength": "${HASHER_MIN_LENGTH}"
}
}
@@ -9,6 +9,7 @@
"Scope": ""
},
"HasherSettings": {
"MinHashLength": ""
"Salt": "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
"MinHashLength": "11"
}
}
@@ -1,4 +1,13 @@
// Development and fallback runtime configuration.
// In Docker / Kubernetes / Cloud Run deployments, this file is dynamically
// populated at container startup by /docker-entrypoint.sh with deployment environment variables.
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {
HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
VITE_HASHER_SALT: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
HASHER_MIN_LENGTH: "11",
VITE_HASHER_MIN_LENGTH: "11",
HasherSettings: {
Salt: "TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==",
MinHashLength: "11"
}
};
@@ -186,7 +186,6 @@ export const AppLayout: React.FC = () => {
useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
// Query tenant endpoint to resolve tenant name if not provided in claims or profile
@@ -194,7 +193,6 @@ export const AppLayout: React.FC = () => {
queryKey: ['active-tenant-header', tenantId],
queryFn: () => getTenant(tenantId!),
enabled: !!tenantId && !claims?.tenant && !user?.tenant && !authTenantName,
staleTime: 5 * 60 * 1000,
});
// Ensure current logged-in user is registered in the email cache
@@ -1247,7 +1247,7 @@ export const ContractsManagement: React.FC<ContractsManagementProps> = ({
{Number(contractToUpdate?.status) === ContractStatus.Active ? (
<div className="w-full bg-[#151b2c] border border-emerald-500/30 rounded-xl px-3 py-2 text-xs text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Active / Executed (Immutable - Cannot Be Changed)</span>
<span>Active / Executed</span>
</div>
) : (
(() => {
@@ -807,7 +807,7 @@ export const EvidenceReviewQueue: React.FC<EvidenceReviewQueueProps> = ({
title="Approved evidence is immutable. Status cannot be modified."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable)</span>
<span>Approved</span>
</span>
) : (
<>
@@ -58,7 +58,6 @@ export const UserSearchPicker: React.FC<UserSearchPickerProps> = ({
const { data: users = [], isLoading } = useQuery<User[]>({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
// Filter active users and exclude the current logged-in user so they can never message themselves
+2 -2
View File
@@ -44,7 +44,7 @@ export const defaultApiSettings: ApiSettings = {
};
export const defaultHasherSettings: HasherSettings = {
Salt: '',
Salt: 'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==',
MinHashLength: 11,
};
@@ -300,7 +300,7 @@ function resolveConfig(fetchedJson?: Record<string, any>): AppConfig {
BaseUrl: normalizeBaseUrl(baseUrl),
},
HasherSettings: {
Salt: salt || '',
Salt: salt || defaultHasherSettings.Salt,
MinHashLength: minHashLength,
},
ConfigSource: source,
@@ -124,7 +124,6 @@ export function useInAppNotifications() {
},
// Live polling every 30 seconds
refetchInterval: 30000,
staleTime: 10000,
});
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
refetchOnWindowFocus: false,
refetchOnReconnect: true,
// Remove caching from all pages: zero stale time and zero cache retention
// Whenever a component needs data, it must call its corresponding endpoint(s)
staleTime: 0,
gcTime: 0,
refetchOnMount: 'always',
refetchOnWindowFocus: 'always',
refetchOnReconnect: 'always',
retry: 1,
},
},
});
// Retain caching ONLY for sync status, auth token, roles and grants
const RETAINED_CACHE_OPTIONS = {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
};
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
/**
* Invalidates queries while simultaneously bumping the mutation epoch.
* This guarantees that the background refetch generates a fresh query parameter (_v)
@@ -1020,7 +1020,7 @@ export const ChangeManagementPage: React.FC = () => {
{Number(cr.status) === ApprovalStatus.Approved ? (
<span className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default" title="Approved and enacted change requests are immutable.">
<Lock className="w-3 h-3 text-emerald-400" />
<span>Resolved &amp; Enacted (Immutable)</span>
<span>Resolved &amp; Enacted</span>
</span>
) : (
<>
@@ -1528,7 +1528,7 @@ export const ChangeManagementPage: React.FC = () => {
title="Approved and enacted change requests are immutable."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved &amp; Enacted (Immutable - Cannot Be Changed)</span>
<span>Approved &amp; Enacted</span>
</div>
) : (
<select
@@ -11,6 +11,7 @@ import {
Edit2,
Mail,
Shield,
ShieldCheck,
Layers,
ChevronRight,
Search,
@@ -24,9 +25,11 @@ import {
UserPlus,
ChevronDown,
ChevronUp,
CheckCircle,
CheckCircle2,
Loader2,
Lock,
AlertTriangle,
} from 'lucide-react';
import {
getOrganisations,
@@ -69,6 +72,7 @@ import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useAuth } from '../auth/AuthProvider';
import { useRbac } from '../auth/rbac';
import { bumpMutationEpoch } from '../services/httpClient';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { FundingInstrumentsSection, InstructionsSection } from '../components/governance/InstructionsSection';
import { RulesSection } from '../components/governance/RulesSection';
@@ -110,6 +114,7 @@ const organisationSchema = z.object({
registrationNo: z.string().min(4, 'Registration number is mandatory'),
email: z.string().email('Enter a valid email address'),
organisationUser: z.string().min(1, 'Please select a linked executive or manager user'),
idNumber: z.string().optional(),
type: z.coerce.number().min(1).max(27),
status: z.coerce.number().optional(),
});
@@ -134,8 +139,8 @@ type ProgrammeFormValues = z.infer<typeof programmeSchema>;
export const GovernancePage: React.FC = () => {
const queryClient = useQueryClient();
const { sub, user, claims } = useAuth();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker } = useRbac();
const { sub, user, claims, roles, tenantId } = useAuth();
const { canAdd, canApprove, canExport, canApproveRecord, isMaker, isUnrestricted } = useRbac();
const currentUserId = sub || user?.id || '';
const { success, error: toastError } = useToast();
@@ -241,7 +246,6 @@ export const GovernancePage: React.FC = () => {
const { data: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
const { data: systemRoles = [] } = useQuery<Role[]>({
@@ -263,6 +267,37 @@ export const GovernancePage: React.FC = () => {
});
}, [usersDirectory]);
const modalExecutiveUsers = React.useMemo(() => {
const list: User[] = [...executiveAndManagerUsers];
const linkedUserId =
editingOrg?.userId ||
editingOrg?.UserId ||
'';
if (linkedUserId) {
const linkedStr = String(linkedUserId).toLowerCase();
const alreadyIncluded = list.some((u) => u.id && String(u.id).toLowerCase() === linkedStr);
if (!alreadyIncluded) {
const inDirectory = usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === linkedStr);
if (inDirectory) {
list.unshift(inDirectory);
} else {
const email = getUserEmail(linkedUserId);
const hasRealEmail = email && email !== '—' && email.includes('@') && email !== String(linkedUserId);
list.unshift({
id: String(linkedUserId),
email: hasRealEmail ? email : String(linkedUserId),
role: 'Executive',
status: ActivityStatus.Active,
lastLoginAt: null,
});
}
}
}
return list;
}, [executiveAndManagerUsers, usersDirectory, editingOrg?.id, editingOrg?.userId]);
// Quick Add Executive User Subform state
const [isQuickAddUserOpen, setIsQuickAddUserOpen] = useState(false);
const [quickUserEmail, setQuickUserEmail] = useState('');
@@ -560,11 +595,23 @@ export const GovernancePage: React.FC = () => {
registrationNo: '',
email: '',
organisationUser: '',
idNumber: '',
type: OrganisationType.Enterprise,
status: ActivityStatus.Active,
},
});
const watchedOrgUser = orgForm.watch('organisationUser');
const selectedExecutiveUser = React.useMemo(() => {
if (!watchedOrgUser) return null;
const str = String(watchedOrgUser).toLowerCase();
return (
usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === str) ||
modalExecutiveUsers.find((u) => u.id && String(u.id).toLowerCase() === str) ||
null
);
}, [watchedOrgUser, usersDirectory, modalExecutiveUsers]);
const portfolioForm = useForm<PortfolioFormValues>({
resolver: zodResolver(portfolioSchema),
defaultValues: {
@@ -580,7 +627,7 @@ export const GovernancePage: React.FC = () => {
defaultValues: {
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved,
status: ApprovalStatus.Pending,
ownerEmail: effectiveUserEmail,
},
});
@@ -589,11 +636,20 @@ export const GovernancePage: React.FC = () => {
const orgMutation = useMutation({
mutationFn: (data: OrganisationFormValues) => {
if (editingOrg) {
if (!data.idNumber || !data.idNumber.trim()) {
orgForm.setError('idNumber', {
type: 'manual',
message: 'Government ID number is required to assign this executive user.',
});
throw new Error('Executive user ID number is required when updating an organisation.');
}
return updateOrganisation({
organisationId: editingOrg.id,
name: data.name,
registrationNo: data.registrationNo,
email: data.email,
idNumber: data.idNumber.trim(),
organisationUser: data.organisationUser,
type: Number(data.type),
status: data.status !== undefined ? Number(data.status) : ActivityStatus.Active,
@@ -603,11 +659,13 @@ export const GovernancePage: React.FC = () => {
name: data.name,
registrationNo: data.registrationNo,
email: data.email,
idNumber: data.idNumber?.trim() || null,
organisationUser: data.organisationUser,
type: Number(data.type),
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['organisations'] });
success(editingOrg ? 'Organisation updated' : 'Organisation registered successfully');
setIsOrgModalOpen(false);
@@ -639,6 +697,7 @@ export const GovernancePage: React.FC = () => {
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['portfolios'] });
success(editingPortfolio ? 'Portfolio updated successfully' : 'Portfolio created successfully');
setIsPortfolioModalOpen(false);
@@ -650,15 +709,43 @@ export const GovernancePage: React.FC = () => {
},
});
const approveProgrammeMutation = useMutation({
mutationFn: async (pr: Programme) => {
if (isMaker(pr.createdBy)) {
throw new Error('Maker-Checker policy: Record creator cannot approve their own programme.');
}
return updateProgramme({
programmeId: pr.id,
name: pr.name || '',
portfolioId: Number(pr.portfolioId),
status: ApprovalStatus.Approved,
ownerId: pr.ownedBy,
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['programmes'] });
queryClient.invalidateQueries({ queryKey: ['programme'] });
success('Programme Approved', 'Programme governance status successfully updated to Approved.');
},
onError: (err: unknown) => {
toastError('Programme Approval Failed', err);
},
});
const programmeMutation = useMutation({
mutationFn: (data: ProgrammeFormValues) => {
const resolvedOwnerId = resolveOwnerId(data.ownerEmail, editingProgramme?.ownedBy);
if (editingProgramme) {
const targetStatus = (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus;
if (targetStatus === ApprovalStatus.Approved && isMaker(editingProgramme.createdBy)) {
throw new Error('Maker-Checker policy violation: Record creators cannot approve their own programmes.');
}
return updateProgramme({
programmeId: editingProgramme.id,
name: data.name,
portfolioId: Number(data.portfolioId),
status: (data.status !== undefined ? Number(data.status) : 1) as ApprovalStatus,
status: targetStatus,
ownerId: resolvedOwnerId,
});
}
@@ -669,7 +756,9 @@ export const GovernancePage: React.FC = () => {
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['programmes'] });
queryClient.invalidateQueries({ queryKey: ['programme'] });
success(editingProgramme ? 'Programme updated successfully' : 'Programme created successfully');
setIsProgrammeModalOpen(false);
setEditingProgramme(null);
@@ -688,6 +777,7 @@ export const GovernancePage: React.FC = () => {
registrationNo: '',
email: '',
organisationUser: defaultUser?.id || '',
idNumber: defaultUser?.idNumber || '',
type: OrganisationType.Enterprise,
status: ActivityStatus.Active,
});
@@ -702,26 +792,51 @@ export const GovernancePage: React.FC = () => {
const targetUserId =
org.userId ||
org.UserId ||
org.organisationUser ||
org.OrganisationUser ||
org.createdBy ||
'';
const matchedUser = targetUserId
? usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === String(targetUserId).toLowerCase())
: null;
let selectedUserId = '';
let matchedUser: User | undefined;
if (targetUserId) {
const targetStr = String(targetUserId).toLowerCase();
matchedUser = usersDirectory.find(
(u) =>
(u.id && String(u.id).toLowerCase() === targetStr) ||
(u.email && u.email.toLowerCase() === targetStr)
);
const selectedUserId = matchedUser?.id || targetUserId || '';
if (matchedUser) {
selectedUserId = matchedUser.id;
if (matchedUser.email) {
registerUserEmail(matchedUser.id, matchedUser.email);
}
} else {
selectedUserId = String(targetUserId);
const email = getUserEmail(targetUserId);
if (email && email !== '—' && email.includes('@')) {
registerUserEmail(selectedUserId, email);
}
}
}
const resolvedIdNumber = matchedUser?.idNumber || org.idNumber || '';
orgForm.reset({
name: org.name || '',
registrationNo: org.registrationNo || '',
email: org.email || '',
organisationUser: selectedUserId,
idNumber: resolvedIdNumber,
type: org.type || OrganisationType.Enterprise,
status: org.status ?? ActivityStatus.Active,
});
if (selectedUserId && !resolvedIdNumber) {
orgForm.setError('idNumber', {
type: 'manual',
message: 'This executive user has no ID number on file. An ID number is required to update the organisation.',
});
}
setIsQuickAddUserOpen(false);
setQuickUserError(null);
setIsOrgModalOpen(true);
@@ -755,7 +870,7 @@ export const GovernancePage: React.FC = () => {
programmeForm.reset({
name: '',
portfolioId: portfolios[0]?.id ? Number(portfolios[0].id) : 201,
status: ApprovalStatus.Approved,
status: ApprovalStatus.Pending,
ownerEmail: effectiveUserEmail,
});
setIsProgrammeModalOpen(true);
@@ -833,6 +948,7 @@ export const GovernancePage: React.FC = () => {
status: (p) => getApprovalStatusBadge(p.status).label,
createdAt: (p) => p.createdAt,
ownedBy: (p) => getUserEmail(p.ownedBy),
createdBy: (p) => getUserEmail(p.createdBy),
});
// Export Columns Definitions
@@ -863,6 +979,7 @@ export const GovernancePage: React.FC = () => {
format: (id) => programmes.filter((pr) => String(pr.portfolioId) === String(id)).length,
},
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Description', key: 'description', format: (val) => val || '—' },
];
@@ -881,6 +998,7 @@ export const GovernancePage: React.FC = () => {
{ header: 'Governance Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
{ header: 'Creation Date', key: 'createdAt', format: (val) => formatDate(val) },
{ header: 'Owner Email', key: 'ownedBy', format: (val) => getUserEmail(val) },
{ header: 'Created By (Maker)', key: 'createdBy', format: (val) => getUserEmail(val) },
];
return (
@@ -1150,6 +1268,11 @@ export const GovernancePage: React.FC = () => {
<Mail className="w-3.5 h-3.5 text-slate-500" />
{org.email}
</span>
{org.userId && (
<span className="text-[10px] text-blue-400/90 font-mono block mt-0.5" title={`Executive User: ${org.userId}`}>
Exec: {getUserEmail(org.userId)}
</span>
)}
</td>
<td className="py-3.5 px-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border ${statusBadge.className}`}>
@@ -1382,19 +1505,22 @@ export const GovernancePage: React.FC = () => {
<SortableTh column="ownedBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Owner Email
</SortableTh>
<SortableTh column="createdBy" currentColumn={progSortCol} direction={progSortDir} onSort={handleProgSort} className="py-3.5 px-4">
Created By
</SortableTh>
<th className="py-3.5 px-5 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/60">
{loadingProgrammes ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
<td colSpan={7} className="py-8 text-center text-slate-500">
Loading programmes...
</td>
</tr>
) : sortedProgrammes.length === 0 ? (
<tr>
<td colSpan={6} className="py-8 text-center text-slate-500">
<td colSpan={7} className="py-8 text-center text-slate-500">
No programmes found matching query.
</td>
</tr>
@@ -1402,6 +1528,7 @@ export const GovernancePage: React.FC = () => {
sortedProgrammes.map((pr) => {
const port = portfolios.find((p) => String(p.id) === String(pr.portfolioId));
const statusBadge = getApprovalStatusBadge(pr.status);
const isApproved = Number(pr.status) === ApprovalStatus.Approved;
return (
<tr key={pr.id} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5">
@@ -1420,8 +1547,49 @@ export const GovernancePage: React.FC = () => {
<td className="py-3.5 px-4 font-mono text-slate-300 font-medium text-[11px]">
{getUserEmail(pr.ownedBy)}
</td>
<td className="py-3.5 px-4 font-mono text-slate-400 text-[11px]">
{getUserEmail(pr.createdBy)}
</td>
<td className="py-3.5 px-5 text-right">
<div className="flex items-center justify-end gap-1.5">
<div className="flex items-center justify-end gap-1.5 flex-wrap sm:flex-nowrap">
{isApproved ? (
<span
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1 rounded-lg border border-emerald-500/20 cursor-default"
title="Approved programmes cannot be modified."
>
<Lock className="w-3 h-3 text-emerald-400" />
<span>Approved</span>
</span>
) : (
<>
<button
type="button"
onClick={() => openEditProgramme(pr)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Edit Programme"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
{canApproveRecord('governance', pr.createdBy) && (
<button
type="button"
disabled={approveProgrammeMutation.isPending}
onClick={() => approveProgrammeMutation.mutate(pr)}
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
title="Approve Programme"
>
<CheckCircle className="w-3 h-3" />
<span>Approve</span>
</button>
)}
{canApprove('governance') && isMaker(pr.createdBy) && (
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
Maker-Checker: Record creator cannot approve
</span>
)}
</>
)}
<button
type="button"
onClick={() => {
@@ -1458,14 +1626,6 @@ export const GovernancePage: React.FC = () => {
<Scale className="w-3 h-3" />
Rules
</button>
<button
type="button"
onClick={() => openEditProgramme(pr)}
className="inline-flex items-center gap-1 text-xs font-semibold text-slate-200 hover:text-white bg-slate-900/80 hover:bg-slate-800 border border-slate-700/80 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<Edit2 className="w-3 h-3" />
Edit
</button>
</div>
</td>
</tr>
@@ -1611,11 +1771,11 @@ export const GovernancePage: React.FC = () => {
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1">
<label className="block text-xs font-semibold text-slate-300">
Linked Executive / Manager User *
Responsible Executive User *
</label>
<HelpTooltip
title="Designated Organisation User"
content="Only users with an Executive or Manager role can be assigned to an organisation."
title="Executive / Organisation User"
content="The Executive user responsible for this organisation. Stored as userId in the organisation record and submitted as organisationUser to the API."
size="sm"
placement="top"
/>
@@ -1636,11 +1796,36 @@ export const GovernancePage: React.FC = () => {
)}
</div>
<select
{...orgForm.register('organisationUser')}
{...orgForm.register('organisationUser', {
onChange: (e) => {
const selectedId = e.target.value;
if (!selectedId) {
orgForm.setValue('idNumber', '');
return;
}
const str = String(selectedId).toLowerCase();
const matched =
usersDirectory.find((u) => u.id && String(u.id).toLowerCase() === str) ||
modalExecutiveUsers.find((u) => u.id && String(u.id).toLowerCase() === str);
if (matched?.idNumber && matched.idNumber.trim()) {
orgForm.setValue('idNumber', matched.idNumber.trim(), { shouldValidate: true });
orgForm.clearErrors('idNumber');
} else {
orgForm.setValue('idNumber', '', { shouldValidate: true });
if (editingOrg) {
orgForm.setError('idNumber', {
type: 'manual',
message: 'Selected user has no ID number on file. Please enter their ID number to enable executive assignment.',
});
}
}
},
})}
className="w-full px-3 py-2 text-xs border border-slate-700/80 rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 bg-[#090d16] text-white font-medium"
>
<option value="">-- Select linked executive or manager account --</option>
{executiveAndManagerUsers.map((u) => (
{modalExecutiveUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.email || u.id} ({u.role || 'Executive'})
</option>
@@ -1654,9 +1839,61 @@ export const GovernancePage: React.FC = () => {
No Executive or Manager user found. Create one below to continue.
</p>
)}
{watchedOrgUser && selectedExecutiveUser && selectedExecutiveUser.idNumber && selectedExecutiveUser.idNumber.trim() && (
<div className="flex items-center gap-1.5 text-[11px] text-emerald-400 mt-1">
<CheckCircle className="w-3.5 h-3.5 text-emerald-400 shrink-0" />
<span>ID on file: <strong className="font-mono text-emerald-300">{selectedExecutiveUser.idNumber}</strong></span>
</div>
)}
</div>
</div>
{/* Missing ID Number Alert */}
{watchedOrgUser && selectedExecutiveUser && (!selectedExecutiveUser.idNumber || !selectedExecutiveUser.idNumber.trim()) && (
<div className="p-3 rounded-xl border border-amber-500/30 bg-amber-500/10 text-amber-300 text-xs flex items-start gap-2.5">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<div className="space-y-1">
<p className="font-semibold text-amber-200">
Missing Executive ID Number
</p>
<p className="text-[11px] text-amber-300/90 leading-relaxed">
The selected executive (<strong className="font-mono text-white">{selectedExecutiveUser.email || watchedOrgUser}</strong>) does not have a Government ID Number on file. An ID number is required to assign this executive user; otherwise the update will be ignored. Please enter their ID number below.
</p>
</div>
</div>
)}
{/* Executive User ID Number Input */}
<div>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1">
<label className="block text-xs font-semibold text-slate-300">
Executive ID Number {editingOrg ? '*' : '(Optional)'}
</label>
<HelpTooltip
title="Executive ID Requirement"
content="Government Issued ID or Passport of the executive. Required when updating to persist the executive user link; otherwise the update will be ignored."
size="sm"
placement="top"
/>
</div>
{selectedExecutiveUser?.idNumber && (
<span className="text-[10px] text-emerald-400 font-mono">Sourced from profile</span>
)}
</div>
<input
type="text"
{...orgForm.register('idNumber')}
className={`w-full px-3 py-2 text-xs border rounded-xl focus:outline-none focus:ring-1 focus:ring-blue-500 text-white bg-[#090d16] font-mono ${
orgForm.formState.errors.idNumber ? 'border-rose-500 ring-1 ring-rose-500' : 'border-slate-700/80'
}`}
placeholder="Enter 13-digit ID or Passport Number"
/>
{orgForm.formState.errors.idNumber && (
<p className="text-[10px] text-rose-500 mt-1">{orgForm.formState.errors.idNumber.message}</p>
)}
</div>
{/* Inline Collapsible Subform to Create Executive User */}
{isQuickAddUserOpen && !editingOrg && (
<div className="p-3.5 rounded-xl border border-blue-500/30 bg-blue-950/20 space-y-3">
@@ -2004,18 +2241,18 @@ export const GovernancePage: React.FC = () => {
placement="top"
/>
</div>
{selectedProgrammeForDetail && Number(selectedProgrammeForDetail.status) === ApprovalStatus.Approved ? (
{editingProgramme && Number(editingProgramme.status) === ApprovalStatus.Approved ? (
<div
className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default"
title="Approved programmes are immutable."
title="Approved programmes cannot be modified."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable - Cannot Be Changed)</span>
<span>Approved</span>
</div>
) : (
(() => {
const canApproveProgramme = canApproveRecord('governance', selectedProgrammeForDetail?.createdBy);
const isSelf = isMaker(selectedProgrammeForDetail?.createdBy);
const canApproveProgramme = canApproveRecord('governance', editingProgramme?.createdBy);
const isSelf = isMaker(editingProgramme?.createdBy);
return (
<select
@@ -2460,11 +2697,17 @@ export const GovernancePage: React.FC = () => {
<span className="text-slate-300">{getOrganisationTypeLabel(orgFreshDetail?.type ?? selectedOrgForDetail?.type ?? OrganisationType.Enterprise)}</span>
</div>
<div className="flex items-center gap-2 min-w-0">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Linked User:</span>
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Executive User:</span>
<span className="text-blue-400 font-mono text-[11px] truncate">
{getUserEmail(orgFreshDetail?.userId || selectedOrgForDetail?.userId) || orgFreshDetail?.userId || selectedOrgForDetail?.userId || '—'}
</span>
</div>
<div className="flex items-center gap-2 min-w-0">
<span className="text-slate-400 font-semibold text-[11px] uppercase tracking-wider shrink-0">Created By:</span>
<span className="text-slate-300 font-mono text-[11px] truncate">
{getUserEmail(orgFreshDetail?.createdBy || selectedOrgForDetail?.createdBy) || orgFreshDetail?.createdBy || selectedOrgForDetail?.createdBy || '—'}
</span>
</div>
</div>
</div>
@@ -2717,13 +2960,38 @@ export const GovernancePage: React.FC = () => {
<span className="text-slate-400 font-semibold block text-[11px] uppercase tracking-wider mb-1">Programme Title</span>
<span className="text-white font-semibold text-sm block">{programmeFreshDetail?.name || selectedProgrammeForDetail?.name}</span>
</div>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
</span>
<div className="flex items-center gap-2">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-semibold border whitespace-nowrap ${getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).className}`}>
{getApprovalStatusBadge(programmeFreshDetail?.status ?? selectedProgrammeForDetail?.status ?? ApprovalStatus.Approved).label}
</span>
{selectedProgrammeForDetail && Number(programmeFreshDetail?.status ?? selectedProgrammeForDetail.status) !== ApprovalStatus.Approved && (
canApproveRecord('governance', selectedProgrammeForDetail.createdBy) ? (
<button
type="button"
disabled={approveProgrammeMutation.isPending}
onClick={async () => {
await approveProgrammeMutation.mutateAsync(selectedProgrammeForDetail);
setIsProgrammeDetailModalOpen(false);
setSelectedProgrammeForDetail(null);
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-300 hover:text-white bg-emerald-500/10 hover:bg-emerald-500/20 border border-emerald-500/30 px-2.5 py-1 rounded-lg transition shadow-xs cursor-pointer"
>
<CheckCircle className="w-3.5 h-3.5" />
<span>Approve Programme</span>
</button>
) : (
isMaker(selectedProgrammeForDetail.createdBy) && canApprove('governance') && (
<span className="text-[10px] text-amber-400 font-mono italic px-1.5 py-0.5 bg-amber-500/10 border border-amber-500/20 rounded">
Maker-Checker: Record creator cannot approve
</span>
)
)
)}
</div>
</div>
{/* Core Details Grid: Owner & Creation Date */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 text-xs">
{/* Core Details Grid: Owner, Maker, Creation Date */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
{/* Creation Date */}
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
<Calendar className="w-4 h-4 text-slate-400 shrink-0" />
@@ -2745,6 +3013,17 @@ export const GovernancePage: React.FC = () => {
</span>
</div>
</div>
{/* Maker */}
<div className="p-3 bg-[#080d1a] rounded-lg border border-slate-800 flex items-center gap-2.5">
<ShieldCheck className="w-4 h-4 text-slate-400 shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-slate-500 font-medium block text-[10px] uppercase tracking-wider">Created By (Maker)</span>
<span className="font-mono text-slate-200 text-xs truncate block" title={getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}>
{getUserEmail(programmeFreshDetail?.createdBy || selectedProgrammeForDetail?.createdBy)}
</span>
</div>
</div>
</div>
{/* Metrics: Total Rules and Total Instructions */}
@@ -47,6 +47,7 @@ import { ExportButton } from '../components/ui/ExportButton';
import { ExportColumn } from '../utils/exportUtils';
import { useTableSort, SortableTh } from '../utils/tableSort';
import { useRbac } from '../auth/rbac';
import { bumpMutationEpoch } from '../services/httpClient';
import {
Award,
Budget,
@@ -233,6 +234,7 @@ export const GrantsPage: React.FC = () => {
const updateAwardMutation = useMutation({
mutationFn: (data: UpdateAwardRequest) => updateAward(data),
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['awards'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success('Award Updated', 'Award details saved successfully.');
@@ -248,6 +250,7 @@ export const GrantsPage: React.FC = () => {
const budgetMutation = useMutation({
mutationFn: (data: CreateBudgetRequest) => createBudget(data),
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['budgets'] });
success('Budget line established', 'Budget line created against project');
setIsBudgetModalOpen(false);
@@ -261,6 +264,7 @@ export const GrantsPage: React.FC = () => {
const updateBudgetMutation = useMutation({
mutationFn: (data: UpdateBudgetRequest) => updateBudget(data),
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['budgets'] });
success('Budget Updated', 'Budget compliance and approval status updated.');
setIsEditBudgetModalOpen(false);
@@ -287,6 +291,7 @@ export const GrantsPage: React.FC = () => {
});
},
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
success(
editingDisbursement ? 'Disbursement Updated' : 'Disbursement Scheduled',
@@ -497,6 +502,7 @@ export const GrantsPage: React.FC = () => {
const approveMutation = useMutation({
mutationFn: (id: string | number) => approveDisbursement(id),
onSuccess: () => {
bumpMutationEpoch();
queryClient.invalidateQueries({ queryKey: ['disbursements'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
success(
@@ -826,8 +832,10 @@ export const GrantsPage: React.FC = () => {
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchAwards();
onClick={async () => {
bumpMutationEpoch();
await queryClient.invalidateQueries({ queryKey: ['awards'] });
await refetchAwards();
success('Refreshed', 'Awards intake list refreshed from API.');
}}
disabled={isFetchingAwards}
@@ -919,10 +927,10 @@ export const GrantsPage: React.FC = () => {
{Number(awd.status) === ApprovalStatus.Approved ? (
<span
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
title="Approved award is immutable. Details cannot be modified."
title="Approved award details cannot be modified."
>
<Lock className="w-3 h-3 text-emerald-400" />
<span>Approved (Immutable)</span>
<span>Approved</span>
</span>
) : (
<button
@@ -973,8 +981,10 @@ export const GrantsPage: React.FC = () => {
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchBudgets();
onClick={async () => {
bumpMutationEpoch();
await queryClient.invalidateQueries({ queryKey: ['budgets'] });
await refetchBudgets();
success('Refreshed', 'Budget line categories refreshed from API.');
}}
disabled={isFetchingBudgets}
@@ -1059,10 +1069,10 @@ export const GrantsPage: React.FC = () => {
{Number(b.status) === ApprovalStatus.Approved ? (
<span
className="inline-flex items-center gap-1 text-xs font-semibold text-emerald-400 bg-emerald-500/10 px-2.5 py-1.5 rounded-lg border border-emerald-500/20 cursor-default"
title="Approved budget is immutable. Status cannot be modified."
title="Approved budget status cannot be modified."
>
<Lock className="w-3 h-3 text-emerald-400" />
<span>Approved (Immutable)</span>
<span>Approved</span>
</span>
) : (
<>
@@ -1133,8 +1143,10 @@ export const GrantsPage: React.FC = () => {
<div className="flex items-center gap-2 self-end sm:self-center">
<button
type="button"
onClick={() => {
refetchDisbursements();
onClick={async () => {
bumpMutationEpoch();
await queryClient.invalidateQueries({ queryKey: ['disbursements'] });
await refetchDisbursements();
success('Refreshed', 'Disbursement tranches refreshed from API.');
}}
disabled={isFetchingDisbursements}
@@ -1549,7 +1561,7 @@ export const GrantsPage: React.FC = () => {
onSubmit={editAwardForm.handleSubmit((values) => {
if (!editingAward) return;
if (Number(editingAward.status) === ApprovalStatus.Approved) {
toastError('Record Immutable', 'Approved awards cannot be modified.');
toastError('Record Approved', 'Approved awards cannot be modified.');
return;
}
updateAwardMutation.mutate({
@@ -1630,7 +1642,7 @@ export const GrantsPage: React.FC = () => {
{Number(editingAward?.status) === ApprovalStatus.Approved ? (
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable - Cannot Be Changed)</span>
<span>Approved</span>
</div>
) : (
<select
@@ -1783,7 +1795,7 @@ export const GrantsPage: React.FC = () => {
{Number(editingBudget?.status) === ApprovalStatus.Approved ? (
<div className="w-full px-3 py-2 text-xs border border-emerald-500/30 bg-emerald-500/10 rounded-xl text-emerald-300 font-semibold flex items-center gap-1.5 cursor-default">
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved (Immutable - Status Cannot Be Changed)</span>
<span>Approved</span>
</div>
) : (
<select
@@ -42,7 +42,6 @@ export const InboxPage: React.FC = () => {
const { data: directoryUsers = [] } = useQuery<User[]>({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
const {
@@ -152,7 +152,6 @@ export const SettingsPage: React.FC = () => {
const { data: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
const {
@@ -528,7 +528,7 @@ export const SiteVisitsPage: React.FC = () => {
title="Approved site visits are immutable."
>
<Lock className="w-3.5 h-3.5 text-emerald-400" />
<span>Approved &amp; Verified (Immutable - Cannot Be Changed)</span>
<span>Approved &amp; Verified</span>
</div>
) : (
<select
@@ -68,14 +68,12 @@ const TenantOrganisationsSubList: React.FC<{ tenantId: number | string; tenantNa
} = useQuery<TenantOrganisation[]>({
queryKey: ['tenant-organisations', tenantId],
queryFn: () => getTenantOrganisations(tenantId),
staleTime: 30000,
});
// Fetch tenant users directory to resolve linked user email per organisation
const { data: tenantUsers = [] } = useQuery<TenantUser[]>({
queryKey: ['tenant-users', tenantId],
queryFn: () => getTenantUsers(tenantId),
staleTime: 5 * 60 * 1000,
});
React.useEffect(() => {
@@ -261,7 +259,6 @@ const TenantUsersSubList: React.FC<{
const { data: tenantUsers = [], isLoading, refetch } = useQuery<TenantUser[]>({
queryKey: ['tenant-users', tenantId],
queryFn: () => getTenantUsers(tenantId),
staleTime: 30000,
});
const { canAdd } = useRbac();
@@ -460,7 +457,6 @@ export const TenantManagementPage: React.FC = () => {
} = useQuery<Tenant[]>({
queryKey: ['tenants-list'],
queryFn: () => getTenants(),
staleTime: 30000,
});
// Forms
+40 -296
View File
@@ -259,7 +259,7 @@ export const updateOrganisation = async (payload: UpdateOrganisationRequest): Pr
name: payload.name,
registrationNo: payload.registrationNo,
email: payload.email,
idNumber: payload.idNumber,
idNumber: payload.idNumber || null,
organisationUser: payload.organisationUser,
type: Number(payload.type),
status: orgStatus,
@@ -442,84 +442,21 @@ export const getProgrammeValue = async (programmeId: string | number): Promise<n
}
};
const LOCAL_INSTRUCTIONS_KEY = 'pfm_local_instructions';
export const purgeStoredInstructionDuplicates = (_apiItems?: Instrument[]): void => {};
export const purgeStoredInstructionDuplicates = (apiItems: Instrument[]): void => {
if (typeof window === 'undefined' || !Array.isArray(apiItems) || apiItems.length === 0) return;
try {
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
if (!raw) return;
const local: Instrument[] = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
export const getStoredInstructions = (): Instrument[] => [];
const apiIds = new Set(apiItems.map((i) => String(i.id)));
const apiKeys = new Set(apiItems.map((i) => `${i.programmeId}::${(i.name || '').toLowerCase().trim()}`));
const remaining = local.filter(
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
);
if (remaining.length !== local.length) {
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(remaining));
}
} catch (e) {
console.warn('Failed to purge stored instruction duplicates', e);
}
};
export const getStoredInstructions = (): Instrument[] => {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(LOCAL_INSTRUCTIONS_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
};
export const saveStoredInstruction = (inst: Instrument): void => {
if (typeof window === 'undefined') return;
try {
const current = getStoredInstructions();
const idx = current.findIndex(
(i) => String(i.id) === String(inst.id) ||
(String(i.programmeId) === String(inst.programmeId) && (i.name || '').trim().toLowerCase() === (inst.name || '').trim().toLowerCase())
);
if (idx >= 0) {
current[idx] = { ...current[idx], ...inst, updatedAt: new Date().toISOString() };
} else {
current.unshift(inst);
}
localStorage.setItem(LOCAL_INSTRUCTIONS_KEY, JSON.stringify(current));
} catch (e) {
console.warn('Failed to save stored instruction', e);
}
};
export const saveStoredInstruction = (_inst?: Instrument): void => {};
export const getInstruments = async (programmeId: string | number): Promise<Instrument[]> => {
let apiItems: Instrument[] = [];
let apiSucceeded = false;
try {
const hashedProgId = await toolsService.resolveProgrammeId(programmeId);
const res = await apiClient.get<Instrument[]>(`/v1/api/governance/instruments/${hashedProgId}`);
if (Array.isArray(res.data)) {
apiItems = res.data;
apiSucceeded = true;
}
return Array.isArray(res.data) ? res.data : [];
} catch (err) {
console.warn(`[getInstruments] Could not fetch instruments from API for prog ${programmeId}:`, err);
return [];
}
if (apiSucceeded) {
purgeStoredInstructionDuplicates(apiItems);
return apiItems;
}
// Fallback to local items only when API failed
const localItems = getStoredInstructions().filter(
(item) => String(item.programmeId) === String(programmeId)
);
return localItems;
};
export const getInstructions = getInstruments;
@@ -535,27 +472,17 @@ export const getAllInstruments = async (programmesList?: Programme[]): Promise<I
}
const map = new Map<string, Instrument>();
let hasApiData = false;
if (progList && progList.length > 0) {
const results = await Promise.allSettled(
progList.map((pr) => getInstruments(pr.id))
);
results.forEach((res) => {
if (res.status === 'fulfilled' && Array.isArray(res.value) && res.value.length > 0) {
hasApiData = true;
if (res.status === 'fulfilled' && Array.isArray(res.value)) {
res.value.forEach((inst) => map.set(String(inst.id), inst));
}
});
}
if (hasApiData || map.size > 0) {
const allApiInstruments = Array.from(map.values());
purgeStoredInstructionDuplicates(allApiInstruments);
return allApiInstruments;
}
return getStoredInstructions();
return Array.from(map.values());
};
export const getAllInstructions = getAllInstruments;
@@ -569,40 +496,8 @@ export const createInstrument = async (payload: CreateInstrumentRequest): Promis
description: payload.description,
};
try {
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
if (res.data && res.data.id) {
purgeStoredInstructionDuplicates([res.data]);
return res.data;
}
} catch (err: any) {
console.warn('[createInstrument] API create failed, falling back to local storage:', err);
const newId = Date.now();
const fallbackInstruction: Instrument = {
id: newId,
programmeId: toIntegerId(payload.programmeId),
code: code,
name: payload.name,
description: payload.description,
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
createdBy: payload.userId || 'system',
createdAt: new Date().toISOString(),
};
saveStoredInstruction(fallbackInstruction);
return fallbackInstruction;
}
// If API returned 200 without full body, return formatted item without saving duplicate to localStorage
return {
id: Date.now(),
programmeId: toIntegerId(payload.programmeId),
code: code,
name: payload.name,
description: payload.description,
status: payload.status !== undefined ? Number(payload.status) : ActivityStatus.Active,
createdBy: payload.userId || 'system',
createdAt: new Date().toISOString(),
};
const res = await apiClient.post<Instrument>('/v1/api/governance/instruments', body);
return res.data;
};
export const createInstruction = createInstrument;
@@ -619,32 +514,7 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
status: Number(payload.status),
};
try {
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
if (res.data && res.data.id) {
return res.data;
}
} catch (err) {
console.warn('[updateInstrument] API update failed:', err);
}
const existing = getStoredInstructions().find((i) => String(i.id) === String(targetId));
const updatedInstrument: Instrument = {
id: targetId!,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
code: payload.code || existing?.code || null,
name: payload.name,
description: payload.description,
status: Number(payload.status),
createdBy: existing?.createdBy || 'system',
createdAt: existing?.createdAt || new Date().toISOString(),
updatedBy: payload.userId || 'system',
updatedAt: new Date().toISOString(),
};
if (existing) {
saveStoredInstruction(updatedInstrument);
}
const res = await apiClient.put<Instrument>('/v1/api/governance/instruments', body);
if (isApprovedOrActiveStatus(payload.status)) {
notifyTenantManagersAndExecutivesOnRecordStatusChange({
recordType: 'Funding Instrument',
@@ -653,7 +523,7 @@ export const updateInstrument = async (payload: UpdateInstrumentRequest): Promis
title: payload.name,
}).catch(console.warn);
}
return updatedInstrument;
return res.data;
};
export const updateInstruction = updateInstrument;
@@ -1359,13 +1229,13 @@ export const getMilestoneEvidence = async (milestoneId: string | number): Promis
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
try {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${hashedProjectId}`);
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${hashedProjectId}`);
return res.data || [];
} catch (err: any) {
// If hashed path parameter returns 404 or 400 and projectId differs, attempt with raw ID
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
try {
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/${projectId}`);
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${projectId}`);
return rawRes.data || [];
} catch (fallbackErr: any) {
if (fallbackErr?.response?.status === 404) {
@@ -2246,75 +2116,20 @@ export const updateSiteVisit = async (payload: UpdateSiteVisitRequest): Promise<
}
};
const LOCAL_RULES_KEY = 'pfm_local_rules';
export const purgeStoredRuleDuplicates = (_apiRules?: Rule[]): void => {};
export const purgeStoredRuleDuplicates = (apiRules: Rule[]): void => {
if (typeof window === 'undefined' || !Array.isArray(apiRules) || apiRules.length === 0) return;
try {
const raw = localStorage.getItem(LOCAL_RULES_KEY);
if (!raw) return;
const local: Rule[] = JSON.parse(raw);
if (!Array.isArray(local) || local.length === 0) return;
export const getStoredRules = (): Rule[] => [];
const apiIds = new Set(apiRules.map((r) => String(r.id)));
const apiKeys = new Set(apiRules.map((r) => `${r.programmeId}::${(r.name || '').toLowerCase().trim()}`));
const remaining = local.filter(
(l) => !apiIds.has(String(l.id)) && !apiKeys.has(`${l.programmeId}::${(l.name || '').toLowerCase().trim()}`)
);
if (remaining.length !== local.length) {
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(remaining));
}
} catch (e) {
console.warn('Failed to purge stored rule duplicates', e);
}
};
export const getStoredRules = (): Rule[] => {
if (typeof window === 'undefined') return [];
try {
const raw = localStorage.getItem(LOCAL_RULES_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
};
export const saveStoredRule = (rule: Rule): void => {
if (typeof window === 'undefined') return;
try {
const current = getStoredRules();
const idx = current.findIndex(
(r) => String(r.id) === String(rule.id) ||
(String(r.programmeId) === String(rule.programmeId) && (r.name || '').trim().toLowerCase() === (rule.name || '').trim().toLowerCase())
);
if (idx >= 0) {
current[idx] = { ...current[idx], ...rule, updatedAt: new Date().toISOString() };
} else {
current.unshift(rule);
}
localStorage.setItem(LOCAL_RULES_KEY, JSON.stringify(current));
} catch (e) {
console.warn('Failed to save stored rule', e);
}
};
export const saveStoredRule = (_rule?: Rule): void => {};
// ==========================================
// 15. Execution: Programme Rules APIs
// ==========================================
export const getRule = async (ruleId: string | number): Promise<Rule> => {
try {
const hashedRuleId = await toolsService.encodeId(ruleId);
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
if (res.data) {
purgeStoredRuleDuplicates([res.data]);
return res.data;
}
} catch {
const localRule = getStoredRules().find((r) => String(r.id) === String(ruleId));
if (localRule) return localRule;
throw new Error('Rule not found');
const hashedRuleId = await toolsService.encodeId(ruleId);
const res = await apiClient.get<Rule>(`/v1/api/execution/rules/${hashedRuleId}`);
if (res.data) {
return res.data;
}
throw new Error('Rule not found');
};
@@ -2332,38 +2147,16 @@ export const getRules = async (
pagination: Pagination = { page: 1, pageSize: 100 },
programmeId?: string | number
): Promise<Rule[]> => {
let apiRules: Rule[] = [];
let apiSucceeded = false;
try {
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
index: pagination.index ? toIntegerId(pagination.index) : 0,
page: pagination.page || 1,
pageSize: pagination.pageSize || 100,
});
if (Array.isArray(res.data)) {
apiRules = res.data;
apiSucceeded = true;
}
} catch (err) {
console.warn('[getRules] Error fetching from backend API:', err);
}
if (apiSucceeded) {
purgeStoredRuleDuplicates(apiRules);
let result = apiRules;
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
result = result.filter((r) => String(r.programmeId) === String(programmeId));
}
return result;
}
// Fallback to local storage only if API call failed
const localRules = getStoredRules();
let combined = localRules;
const res = await apiClient.post<Rule[]>('/v1/api/execution/programmes/rules', {
index: pagination.index ? toIntegerId(pagination.index) : 0,
page: pagination.page || 1,
pageSize: pagination.pageSize || 100,
});
let result = Array.isArray(res.data) ? res.data : [];
if (programmeId != null && programmeId !== '' && programmeId !== 'all') {
combined = combined.filter((r) => String(r.programmeId) === String(programmeId));
result = result.filter((r) => String(r.programmeId) === String(programmeId));
}
return combined;
return result;
};
export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
@@ -2375,70 +2168,21 @@ export const createRule = async (payload: CreateRuleRequest): Promise<Rule> => {
description: payload.description,
};
try {
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
if (res.data && res.data.id) {
purgeStoredRuleDuplicates([res.data]);
return res.data;
}
} catch (err) {
console.warn('[createRule] API create failed, falling back to local storage:', err);
const newId = Date.now();
const fallbackRule: Rule = {
id: newId,
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
description: payload.description,
status: ActivityStatus.Active,
version: 1,
createdBy: validUserId,
createdAt: new Date().toISOString(),
};
saveStoredRule(fallbackRule);
return fallbackRule;
}
return {
id: Date.now(),
programmeId: toIntegerId(payload.programmeId),
name: payload.name,
description: payload.description,
status: ActivityStatus.Active,
version: 1,
createdBy: validUserId,
createdAt: new Date().toISOString(),
};
const res = await apiClient.post<Rule>('/v1/api/execution/rules', body);
return res.data;
};
export const updateRule = async (payload: UpdateRuleRequest): Promise<void> => {
const validUserId = toValidUuid(payload.userId);
try {
await apiClient.put('/v1/api/execution/rules', {
ruleId: toIntegerId(payload.ruleId),
userId: validUserId,
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version || 1),
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
});
} catch (err) {
console.warn('[updateRule] API update failed:', err);
const existing = getStoredRules().find((r) => String(r.id) === String(payload.ruleId));
const updatedRule: Rule = {
id: payload.ruleId,
programmeId: payload.programmeId ? toIntegerId(payload.programmeId) : (existing?.programmeId || 0),
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version || 1),
createdBy: existing?.createdBy || validUserId,
createdAt: existing?.createdAt || new Date().toISOString(),
updatedBy: validUserId,
updatedAt: new Date().toISOString(),
};
saveStoredRule(updatedRule);
}
await apiClient.put('/v1/api/execution/rules', {
ruleId: toIntegerId(payload.ruleId),
userId: validUserId,
name: payload.name,
description: payload.description,
status: Number(payload.status),
version: Number(payload.version || 1),
...(payload.programmeId ? { programmeId: toIntegerId(payload.programmeId) } : {}),
});
};
// ==========================================
@@ -60,6 +60,8 @@ export const apiClient = axios.create({
headers: {
'Content-Type': 'application/json; version=1',
Accept: 'application/json; version=1',
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
},
timeout: 15000,
});
@@ -81,34 +83,16 @@ apiClient.interceptors.request.use(
config.url?.includes('amazonaws.com') ||
config.url?.includes('garage');
// Attach mutation epoch or explicit timestamp to GET requests for OutputCache management
// Always attach freshness timestamp and mutation epoch to internal GET requests
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
const headers = config.headers || {};
const hasNoCacheHeader =
headers['Cache-Control'] === 'no-cache' ||
headers['cache-control'] === 'no-cache' ||
headers['Pragma'] === 'no-cache';
if (hasNoCacheHeader) {
// Explicit freshness requested: attach fresh timestamp
if (config.params instanceof URLSearchParams) {
config.params.set('_t', String(Date.now()));
} else {
config.params = config.params || {};
config.params._t = Date.now();
}
if (config.params instanceof URLSearchParams) {
config.params.set('_t', String(Date.now()));
config.params.set('_v', String(getMutationEpoch()));
} else {
// Standard GET: attach current mutation epoch
if (config.params instanceof URLSearchParams) {
if (!config.params.has('_v') && !config.params.has('_t')) {
config.params.set('_v', String(getMutationEpoch()));
}
} else {
config.params = config.params || {};
if (!config.params._v && !config.params._t) {
config.params._v = getMutationEpoch();
}
}
config.params = config.params || {};
config.params._t = Date.now();
config.params._v = getMutationEpoch();
}
}
@@ -10,15 +10,8 @@ export interface TenantLockoutResult {
statusCode?: number;
}
let cachedTenantLockout: {
key: string;
result: TenantLockoutResult;
timestamp: number;
} | null = null;
const LOCKOUT_CACHE_TTL_MS = 60 * 1000;
export function clearTenantLockoutCache(): void {
cachedTenantLockout = null;
// Caching removed to guarantee fresh tenant state on every verification
}
export function isStatusActive(status: any): boolean {
@@ -88,15 +81,7 @@ export async function verifyTenantLockout(
candidateId = tenantClaim;
}
const cacheKey = String(candidateId ?? tenantClaim ?? 'default-1').trim().toLowerCase();
if (!forceRefresh && cachedTenantLockout && cachedTenantLockout.key === cacheKey) {
if (
cachedTenantLockout.result.isActive &&
Date.now() - cachedTenantLockout.timestamp < LOCKOUT_CACHE_TTL_MS
) {
return cachedTenantLockout.result;
}
}
// Fresh check: query endpoint directly without cache
let tenantRes: Tenant | null = null;
let lastError: any = null;
@@ -209,10 +194,6 @@ export async function verifyTenantLockout(
isActive: true,
tenant: tenantObj,
};
cachedTenantLockout = {
key: cacheKey,
result: successResult,
timestamp: Date.now(),
};
// Direct return without caching
return successResult;
}
@@ -4,9 +4,13 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
? import.meta.env
: {}) as Record<string, string | undefined>;
export const DEFAULT_HASHER_SALT =
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
export const DEFAULT_MIN_HASH_LENGTH = 11;
export const HASHER_SETTINGS = {
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
salt: (env.VITE_HASHER_SALT as string) || '',
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
};
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
@@ -33,14 +37,27 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
* Validates if candidate is already a HashID generated by this hasher.
*/
export function isHashId(candidate: string | null | undefined): boolean {
if (!candidate || typeof candidate !== 'string' || candidate.length < HASHER_SETTINGS.minHashLength) {
if (
!candidate ||
typeof candidate !== 'string' ||
candidate.length < HASHER_SETTINGS.minHashLength ||
/^\d+$/.test(candidate)
) {
return false;
}
try {
const decodedNumbers = hasher.decode(candidate);
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) return true;
if (Array.isArray(decodedNumbers) && decodedNumbers.length > 0) {
if (hasher.encode(decodedNumbers as number[]) === candidate) {
return true;
}
}
const decodedHex = hasher.decodeHex(candidate);
if (decodedHex && decodedHex.length > 0) return true;
if (decodedHex && decodedHex.length > 0) {
if (hasher.encodeHex(decodedHex) === candidate) {
return true;
}
}
} catch {
return false;
}
@@ -198,23 +215,7 @@ export function decodeEntityId(hashId: string | number | null | undefined): stri
* Prevents double-encoding already-hashed route parameters or IDs.
*/
export function isAlreadyHashId(candidate: string | null | undefined): boolean {
if (
!candidate ||
typeof candidate !== 'string' ||
candidate.length < HASHER_SETTINGS.minHashLength ||
/^\d+$/.test(candidate)
) {
return false;
}
try {
const decoded = hasher.decode(candidate);
if (!Array.isArray(decoded) || decoded.length === 0) {
return false;
}
return hasher.encode(decoded as number[]) === candidate;
} catch {
return false;
}
return isHashId(candidate);
}
/**
@@ -246,6 +247,11 @@ export function decodeHashIdSync(hashId: string | number | null | undefined): nu
if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
}
const decodedHex = hasher.decodeHex(str);
if (decodedHex) {
const num = Number(BigInt('0x' + decodedHex));
if (!isNaN(num)) return Math.trunc(num);
}
} catch {}
return str;
@@ -270,6 +276,11 @@ export function decodeToNumberSync(hashId: number | string | null | undefined):
if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
}
const decodedHex = hasher.decodeHex(str);
if (decodedHex) {
const num = Number(BigInt('0x' + decodedHex));
if (!isNaN(num)) return Math.trunc(num);
}
} catch {}
const fallback = Number(str);
return isNaN(fallback) ? 0 : Math.trunc(fallback);
+2
View File
@@ -5,6 +5,8 @@ interface ImportMetaEnv {
readonly VITE_OIDC_AUDIENCE?: string;
readonly VITE_OIDC_CLIENT_ID?: string;
readonly VITE_API_BASE_URL?: string;
readonly VITE_HASHER_SALT?: string;
readonly VITE_HASHER_MIN_LENGTH?: string;
}
interface ImportMeta {
+4 -4
View File
@@ -1,9 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"
# HasherSettings for HashIds compatibility with backend pfm-api
VITE_HASHER_MIN_LENGTH="11"
VITE_HASHER_SALT="TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ=="
@@ -29,17 +29,15 @@ OIDC_CLIENT_ID="${VITE_OIDC_CLIENT_ID:-${OIDC_CLIENT_ID:-${SecuritySettings__Cli
# 5. OIDC Scope
OIDC_SCOPE="${VITE_OIDC_SCOPE:-${OIDC_SCOPE:-${SecuritySettings__Scope:-${SecuritySettings_Scope:-${SCOPE:-}}}}}"
# 6. Hasher Settings
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-}}}}}"
# 6. Hasher Settings (Fix: Exactly 4 closing braces matching the 4 open variable expansions)
HASHER_MIN_LENGTH="${VITE_HASHER_MIN_LENGTH:-${HASHER_MIN_LENGTH:-${HasherSettings__MinLength:-${HasherSettings_MinLength:-11}}}}"
if [ -d "$TARGET_DIR" ]; then
echo "[docker-entrypoint] Generating runtime configuration files in $TARGET_DIR..."
# Generate env-config.js for instant, synchronous window.__RUNTIME_CONFIG__ initialization
# Public SPA clients use Authorization Code flow with PKCE; client secrets and hasher salts must NEVER be injected or exposed here.
# Public SPA clients: salt secrets and client secrets must NEVER be injected or exposed in public static files.
cat <<EOF > "$TARGET_DIR/env-config.js"
// Generated dynamically at container startup from deployment environment variables
// Note: Secrets such as client secrets, private keys, or hashing salts must NEVER be exposed here.
window.__RUNTIME_CONFIG__ = {
API_BASE_URL: "${API_BASE_URL}",
VITE_API_BASE_URL: "${API_BASE_URL}",
@@ -10,6 +10,6 @@
"BaseUrl": ""
},
"HasherSettings": {
"MinHashLength": ""
"MinHashLength": "11"
}
}
@@ -1 +1,9 @@
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {};
// Runtime configuration for development or container environments
// Note: Secret values (like hashing salt or client secrets) are not exposed here.
window.__RUNTIME_CONFIG__ = window.__RUNTIME_CONFIG__ || {
HASHER_MIN_LENGTH: "11",
VITE_HASHER_MIN_LENGTH: "11",
HasherSettings: {
MinHashLength: "11"
}
};
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { api, extractApiErrors } from '../../services/api';
import { uploadEvidenceFile, extractApiErrors, toIntegerId } from '../../services/api';
import { Activity, Milestone, Project, EvidenceType } from '../../services/api/models';
import { EVIDENCE_TYPE_OPTIONS } from '../../lib/formatters';
import {
X,
UploadCloud,
@@ -17,7 +18,7 @@ interface EvidenceUploadModalProps {
isOpen: boolean;
onClose: () => void;
project: Project;
milestone?: Milestone;
milestone?: Milestone | null;
activity: Activity;
onEvidenceUploaded: () => void;
}
@@ -32,8 +33,7 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
}) => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [title, setTitle] = useState('');
const [documentUrl, setDocumentUrl] = useState('');
const [evidenceType, setEvidenceType] = useState<EvidenceType>(1); // Invoice / Document
const [evidenceType, setEvidenceType] = useState<number>(4); // Default: FinancialInvoiceOrReceipt
const [description, setDescription] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessages, setErrorMessages] = useState<string[]>([]);
@@ -56,53 +56,47 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
setErrorMessages([]);
setSuccessMessage(null);
if (!title.trim() && !selectedFile && !documentUrl.trim()) {
setErrorMessages(['Please provide an evidence title, file upload, or document URL.']);
if (!selectedFile) {
setErrorMessages(['A supporting document file is required. Please choose a file to upload.']);
return;
}
const numProjectId = toIntegerId(project.id);
const numActivityId = toIntegerId(activity.id);
const numMilestoneId = toIntegerId(milestone?.id || activity.milestoneId || 0);
if (!numProjectId || !numActivityId) {
setErrorMessages(['Cannot upload evidence: Missing projectId or activityId reference.']);
return;
}
setIsSubmitting(true);
try {
// Build upload payload
// Build multipart/form-data adhering strictly to EvidenceService.CreateEvidenceAsync
const formData = new FormData();
if (selectedFile) {
formData.append('file', selectedFile);
formData.append('file', selectedFile);
formData.append('projectId', String(numProjectId));
formData.append('activityId', String(numActivityId));
formData.append('milestoneId', String(numMilestoneId));
formData.append('indicatorId', '0');
formData.append('type', String(Number(evidenceType) || 4));
formData.append('title', title.trim() || selectedFile.name);
if (description.trim()) {
formData.append('description', description.trim());
}
formData.append('title', title.trim() || selectedFile?.name || 'Evidence Deliverable');
formData.append('projectId', String(project.id || ''));
if (milestone?.id) formData.append('milestoneId', String(milestone.id));
if (activity?.id) formData.append('activityId', String(activity.id));
formData.append('type', String(evidenceType));
if (documentUrl.trim()) formData.append('documentUrl', documentUrl.trim());
if (description.trim()) formData.append('description', description.trim());
// Also support JSON payload fallback if server expects JSON body
try {
await api.evidence.createEvidenceFile(formData);
} catch (err: any) {
// Fallback: Attempt JSON payload if multipart failed or route expects JSON
const jsonPayload = {
projectId: project.id,
milestoneId: milestone?.id,
activityId: activity.id,
title: title.trim() || selectedFile?.name || 'Evidence Deliverable',
type: evidenceType,
documentUrl: documentUrl.trim() || (selectedFile ? `s3://evidence/${selectedFile.name}` : undefined),
description: description.trim(),
};
await api.evidence.createEvidenceFile(jsonPayload);
}
await uploadEvidenceFile(formData);
setSuccessMessage('Evidence successfully uploaded and linked to activity!');
setTimeout(() => {
onEvidenceUploaded();
onClose();
}, 1000);
}, 800);
} catch (err: any) {
console.error('[Evidence Upload Error]:', err);
const errors = extractApiErrors(err);
setErrorMessages(errors);
setErrorMessages(errors.length > 0 ? errors : ['Failed to upload evidence file. Please try again.']);
} finally {
setIsSubmitting(false);
}
@@ -124,6 +118,7 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
<h3 className="text-base font-bold text-white">Upload Activity Deliverable</h3>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-xl hover:bg-white/10 text-slate-400 hover:text-white transition-colors cursor-pointer"
>
@@ -146,12 +141,12 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
</div>
</div>
{/* Error Alerts (Comma-delimited error list from ProblemDetails) */}
{/* Error Alerts */}
{errorMessages.length > 0 && (
<div className="mb-4 p-3.5 rounded-2xl bg-rose-950/60 border border-rose-500/30 text-rose-200 text-xs space-y-1">
<div className="flex items-center gap-1.5 font-bold text-rose-300">
<AlertTriangle className="w-4 h-4 text-rose-400 shrink-0" />
<span>Validation Notice ({errorMessages.length} issue{errorMessages.length > 1 ? 's' : ''}):</span>
<span>Upload Notice ({errorMessages.length} issue{errorMessages.length > 1 ? 's' : ''}):</span>
</div>
<ul className="list-disc pl-5 space-y-0.5 text-[11px] text-rose-200/90 font-mono">
{errorMessages.map((msg, idx) => (
@@ -173,10 +168,13 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
<form onSubmit={handleSubmit} className="space-y-4 text-xs">
{/* File Dropzone */}
<div>
<label className="block text-slate-300 font-medium mb-1.5">Evidence File (Document, Receipt, Report)</label>
<label className="block text-slate-300 font-medium mb-1.5">
Evidence File (Document, Invoice, Certificate, Photo) <span className="text-emerald-400">*</span>
</label>
<div className="relative border-2 border-dashed border-white/15 hover:border-emerald-500/50 rounded-2xl p-4 text-center bg-white/[0.02] hover:bg-emerald-500/[0.02] transition-colors cursor-pointer">
<input
type="file"
required
onChange={handleFileChange}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
@@ -193,8 +191,8 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
</div>
) : (
<div>
<span className="font-medium text-slate-300">Click to upload file</span>
<p className="text-[11px] text-slate-500 mt-0.5">PDF, DOCX, XLSX, PNG, JPG (Max 25MB)</p>
<span className="font-medium text-slate-300">Click to choose document</span>
<p className="text-[11px] text-slate-500 mt-0.5">PDF, DOCX, XLSX, PNG, JPG (Multiple uploads supported)</p>
</div>
)}
</div>
@@ -207,7 +205,7 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
<label className="block text-slate-300 font-medium mb-1">Evidence Title</label>
<input
type="text"
placeholder="e.g. Audit Signoff Dossier"
placeholder="e.g. Activity Verification Proof"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
@@ -217,37 +215,21 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
<label className="block text-slate-300 font-medium mb-1">Evidence Type</label>
<select
value={evidenceType}
onChange={(e) => setEvidenceType(Number(e.target.value) as EvidenceType)}
onChange={(e) => setEvidenceType(Number(e.target.value))}
className="w-full px-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
>
<option value={0}>Document</option>
<option value={1}>Invoice</option>
<option value={2}>Receipt</option>
<option value={3}>Site Photo</option>
<option value={4}>Verification Report</option>
<option value={5}>Other Deliverable</option>
{EVIDENCE_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value} className="bg-slate-900 text-slate-100">
{opt.label}
</option>
))}
</select>
</div>
</div>
{/* External Secure URL (Optional) */}
<div>
<label className="block text-slate-300 font-medium mb-1">Document Link / S3 URI (Optional)</label>
<div className="relative">
<LinkIcon className="w-3.5 h-3.5 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="https://... or s3://..."
value={documentUrl}
onChange={(e) => setDocumentUrl(e.target.value)}
className="w-full pl-8 pr-3 py-2 rounded-xl bg-slate-950/70 border border-white/10 text-slate-200 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
/>
</div>
</div>
{/* Description / Notes */}
<div>
<label className="block text-slate-300 font-medium mb-1">Deliverable Notes</label>
<label className="block text-slate-300 font-medium mb-1">Deliverable Notes (Optional)</label>
<textarea
rows={2}
placeholder="Provide verification notes or audit references..."
File diff suppressed because it is too large Load Diff
@@ -113,7 +113,6 @@ export function useInAppNotifications() {
return list || [];
},
refetchInterval: 30000,
staleTime: 10000,
});
const inAppNotifications = useMemo(() => {
@@ -462,14 +462,36 @@ export const getOrganisationTypeLabel = (type: OrganisationType | number): strin
return opt ? opt.label : 'Organisation';
};
export const getIndustrySectorLabel = (sector: IndustrySector | number): string => {
const opt = INDUSTRY_SECTOR_OPTIONS.find((o) => o.value === Number(sector));
return opt ? opt.label : 'Industry Sector';
export const getIndustrySectorLabel = (sector?: IndustrySector | number | string | null): string => {
if (sector === undefined || sector === null || sector === '') return '';
const num = Number(sector);
if (!isNaN(num)) {
const opt = INDUSTRY_SECTOR_OPTIONS.find((o) => o.value === num);
if (opt) return opt.label;
}
const str = String(sector).trim().toLowerCase();
for (const opt of INDUSTRY_SECTOR_OPTIONS) {
if (opt.label.toLowerCase() === str) return opt.label;
const enumKey = (IndustrySector as any)[opt.value];
if (enumKey && String(enumKey).toLowerCase() === str) return opt.label;
}
return String(sector).replace(/([a-z])([A-Z])/g, '$1 $2');
};
export const getProjectTypeLabel = (type: ProjectType | number): string => {
const opt = PROJECT_TYPE_OPTIONS.find((o) => o.value === Number(type));
return opt ? opt.label : 'Project Type';
export const getProjectTypeLabel = (type?: ProjectType | number | string | null): string => {
if (type === undefined || type === null || type === '') return '';
const num = Number(type);
if (!isNaN(num)) {
const opt = PROJECT_TYPE_OPTIONS.find((o) => o.value === num);
if (opt) return opt.label;
}
const str = String(type).trim().toLowerCase();
for (const opt of PROJECT_TYPE_OPTIONS) {
if (opt.label.toLowerCase() === str) return opt.label;
const enumKey = (ProjectType as any)[opt.value];
if (enumKey && String(enumKey).toLowerCase() === str) return opt.label;
}
return String(type).replace(/([a-z])([A-Z])/g, '$1 $2');
};
export const getEvidenceTypeLabel = (type: EvidenceType | number): string => {
@@ -4,15 +4,38 @@ import { bumpMutationEpoch } from '../services/httpClient';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 2, // 2 minutes fresh in memory
gcTime: 1000 * 60 * 10, // 10 minutes cache retention
refetchOnWindowFocus: false,
refetchOnReconnect: true,
// Remove caching from all pages: zero stale time and zero cache retention
// Whenever a component needs data, it must call its corresponding endpoint(s)
staleTime: 0,
gcTime: 0,
refetchOnMount: 'always',
refetchOnWindowFocus: 'always',
refetchOnReconnect: 'always',
retry: 1,
},
},
});
// Retain caching ONLY for sync status, auth token, roles and grants
const RETAINED_CACHE_OPTIONS = {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnMount: false,
refetchOnWindowFocus: false,
};
queryClient.setQueryDefaults(['roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['system-roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['role-claims'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-roles'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-claims'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['grants'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-grants'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['user-sync'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['sync-status'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['auth'], RETAINED_CACHE_OPTIONS);
queryClient.setQueryDefaults(['auth-token'], RETAINED_CACHE_OPTIONS);
/**
* Invalidates queries while simultaneously bumping the mutation epoch.
*/
@@ -374,7 +374,7 @@ export const evidenceApi = {
apiClient.get<Models.Evidence[]>(`/v1/api/evidence/milestone/${hashParam(milestoneId)}`),
getProjectEvidence: (projectId: string | number) =>
apiClient.get<Models.Evidence[]>(`/v1/api/evidence/${hashParam(projectId)}`),
apiClient.get<Models.Evidence[]>(`/v1/api/evidence/projects/${hashParam(projectId)}`),
createMilestoneRequest: (payload: Models.CreateMilestoneRequest) =>
apiClient.post<Models.Milestone>('/v1/api/evidence/milestones', payload),
@@ -810,6 +810,7 @@ export const updateActivity = async (payload: UpdateActivityRequest): Promise<Ac
: null;
const body = {
activityId: toIntegerId(payload.activityId),
projectId: toIntegerId(payload.projectId),
milestoneId: toNullableIntegerId(payload.milestoneId),
category: payload.category || 'Operations',
@@ -830,6 +831,46 @@ export const updateActivity = async (payload: UpdateActivityRequest): Promise<Ac
}
};
export const submitActivityForApproval = async (
activity: Activity,
projectId: number | string,
evidences?: Evidence[]
): Promise<void> => {
if (evidences && evidences.length > 0) {
for (const ev of evidences) {
if (Number(ev.status) === ApprovalStatus.Pending) {
try {
await updateEvidence({
evidenceId: ev.id,
version: ev.version || 1,
evidenceType: (ev as any).evidenceType ?? ev.type,
status: ApprovalStatus.Review,
});
} catch (e) {
console.warn('Could not update evidence status', ev.id, e);
}
}
}
}
try {
await updateActivity({
activityId: activity.id,
projectId,
milestoneId: activity.milestoneId,
category: activity.category || 'Operations',
amount: activity.amount || 0,
title: activity.title || 'Activity',
description: activity.description || '',
performedAt: new Date().toISOString(),
status: ApprovalStatus.Review,
});
} catch (e) {
console.warn('Could not update activity fulfillment record', e);
}
};
export const getMilestones = async (projectId: string | number): Promise<Milestone[]> => {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
try {
@@ -1377,12 +1418,12 @@ export const getMilestoneEvidence = async (milestoneId: string | number): Promis
export const getProjectEvidence = async (projectId: string | number): Promise<Evidence[]> => {
try {
const hashedProjectId = await toolsService.resolveProjectId(projectId);
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/${hashedProjectId}`);
const res = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${hashedProjectId}`);
return res.data || [];
} catch (err: any) {
if ((err?.response?.status === 404 || err?.response?.status === 400) && String(projectId) !== '') {
try {
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/${projectId}`);
const rawRes = await apiClient.get<Evidence[]>(`/v1/api/evidence/projects/${projectId}`);
return rawRes.data || [];
} catch (fallbackErr: any) {
if (fallbackErr?.response?.status === 404) {
@@ -39,6 +39,8 @@ export const apiClient = axios.create({
headers: {
'Content-Type': 'application/json; version=1',
Accept: 'application/json; version=1',
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
},
timeout: 15000,
});
@@ -58,31 +60,16 @@ apiClient.interceptors.request.use(
config.url?.includes('amazonaws.com') ||
config.url?.includes('garage');
// Always attach freshness timestamp and mutation epoch to internal GET requests
// to guarantee zero stale data, bypass browser HTTP caches, and prevent stale OutputCache hits
if (config.method?.toUpperCase() === 'GET' && !isExternal) {
const headers = config.headers || {};
const hasNoCacheHeader =
headers['Cache-Control'] === 'no-cache' ||
headers['cache-control'] === 'no-cache' ||
headers['Pragma'] === 'no-cache';
if (hasNoCacheHeader) {
if (config.params instanceof URLSearchParams) {
config.params.set('_t', String(Date.now()));
} else {
config.params = config.params || {};
config.params._t = Date.now();
}
if (config.params instanceof URLSearchParams) {
config.params.set('_t', String(Date.now()));
config.params.set('_v', String(getMutationEpoch()));
} else {
if (config.params instanceof URLSearchParams) {
if (!config.params.has('_v') && !config.params.has('_t')) {
config.params.set('_v', String(getMutationEpoch()));
}
} else {
config.params = config.params || {};
if (!config.params._v && !config.params._t) {
config.params._v = getMutationEpoch();
}
}
config.params = config.params || {};
config.params._t = Date.now();
config.params._v = getMutationEpoch();
}
}
@@ -5,13 +5,17 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
? import.meta.env
: {}) as Record<string, string | undefined>;
export const DEFAULT_HASHER_SALT =
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
export const DEFAULT_MIN_HASH_LENGTH = 11;
/**
* Deterministic Hashids configuration matching pfm-api HasherSettings.
* Ensures 100% interoperability with the C# Hashids.net NuGet package on the backend.
*/
export const HASHER_SETTINGS = {
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
salt: (env.VITE_HASHER_SALT as string) || '',
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
};
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
@@ -21,9 +25,13 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
if (salt && typeof salt === 'string' && salt.trim() !== '') {
HASHER_SETTINGS.salt = salt.trim();
updated = true;
} else if (!HASHER_SETTINGS.salt) {
HASHER_SETTINGS.salt = DEFAULT_HASHER_SALT;
updated = true;
}
if (minLength !== undefined && minLength !== null && minLength !== '') {
const parsed = Number(minLength);
const cleanMin = String(minLength).replace(/[^0-9]/g, '');
const parsed = Number(cleanMin);
if (!isNaN(parsed) && parsed > 0) {
HASHER_SETTINGS.minHashLength = parsed;
updated = true;
@@ -34,8 +42,9 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
}
}
export function isAlreadyHashId(candidate: string): boolean {
export function isAlreadyHashId(candidate: string | null | undefined): boolean {
if (
!candidate ||
typeof candidate !== 'string' ||
candidate.length < HASHER_SETTINGS.minHashLength ||
/^\d+$/.test(candidate)
@@ -47,20 +56,28 @@ export function isAlreadyHashId(candidate: string): boolean {
if (!Array.isArray(decoded) || decoded.length === 0) {
return false;
}
return hasher.encode(decoded as number[]) === candidate;
return hasher.encode(decoded as any) === candidate;
} catch {
return false;
}
}
export function encodeHashIdSync(id: number | string | null | undefined): string {
export function encodeHashIdSync(id: number | string | bigint | null | undefined): string {
if (id === undefined || id === null || id === '') {
return '';
}
if (typeof id === 'bigint') {
if (id < 0n) return '';
return hasher.encode(id);
}
if (typeof id === 'number') {
if (isNaN(id) || !isFinite(id) || id < 0) return '';
return hasher.encode(id);
if (id <= Number.MAX_SAFE_INTEGER) {
return hasher.encode(id);
}
return hasher.encode(BigInt(Math.trunc(id)));
}
const str = String(id).trim();
@@ -72,10 +89,8 @@ export function encodeHashIdSync(id: number | string | null | undefined): string
if (/^\d+$/.test(str)) {
try {
const num = parseInt(str, 10);
if (!isNaN(num) && num >= 0) {
return hasher.encode(num);
}
// JavaScript hashids v2 natively supports BigInt, matching C# Hashids.net EncodeLong without int64 precision loss
return hasher.encode(BigInt(str));
} catch {
// Fall through
}
@@ -84,22 +99,37 @@ export function encodeHashIdSync(id: number | string | null | undefined): string
return str;
}
export function decodeHashIdSync(hashId: string | number | null | undefined): number | string {
export function decodeHashIdSync(hashId: string | number | bigint | null | undefined): number | string {
if (hashId === undefined || hashId === null || hashId === '') return '';
if (typeof hashId === 'number') return hashId;
if (typeof hashId === 'bigint') return hashId.toString();
const str = String(hashId).trim();
if (!str) return '';
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
return isNaN(parsed) ? str : parsed;
try {
const b = BigInt(str);
if (b <= BigInt(Number.MAX_SAFE_INTEGER) && b >= BigInt(Number.MIN_SAFE_INTEGER)) {
return Number(b);
}
return str;
} catch {
return str;
}
}
try {
const decoded = hasher.decode(str);
if (Array.isArray(decoded) && decoded.length > 0) {
return Number(decoded[0]);
const val = decoded[0];
if (typeof val === 'bigint') {
if (val <= BigInt(Number.MAX_SAFE_INTEGER) && val >= BigInt(Number.MIN_SAFE_INTEGER)) {
return Number(val);
}
return val.toString();
}
return Number(val);
}
} catch {
// If decoding fails, return raw string
@@ -108,11 +138,14 @@ export function decodeHashIdSync(hashId: string | number | null | undefined): nu
return str;
}
export function decodeToNumberSync(hashId: number | string | null | undefined): number {
export function decodeToNumberSync(hashId: number | string | bigint | null | undefined): number {
if (hashId === null || hashId === undefined || hashId === '') return 0;
if (typeof hashId === 'number') {
return isNaN(hashId) ? 0 : Math.trunc(hashId);
}
if (typeof hashId === 'bigint') {
return Number(hashId);
}
const str = String(hashId).trim();
if (/^-?\d+$/.test(str)) {
const parsed = parseInt(str, 10);
@@ -129,19 +162,19 @@ export function decodeToNumberSync(hashId: number | string | null | undefined):
}
export const toolsService = {
encodeIdSync(id: number | string | null | undefined): string {
encodeIdSync(id: number | string | bigint | null | undefined): string {
return encodeHashIdSync(id);
},
decodeIdSync(hashId: string | number | null | undefined): number | string {
decodeIdSync(hashId: string | number | bigint | null | undefined): number | string {
return decodeHashIdSync(hashId);
},
decodeToNumberSync(hashId: number | string | null | undefined): number {
decodeToNumberSync(hashId: number | string | bigint | null | undefined): number {
return decodeToNumberSync(hashId);
},
async encodeId(id: number | string | null | undefined, _context = 'general'): Promise<string> {
async encodeId(id: number | string | bigint | null | undefined, _context = 'general'): Promise<string> {
return encodeHashIdSync(id);
},
@@ -149,35 +182,35 @@ export const toolsService = {
return decodeHashIdSync(hashId);
},
async resolveProjectId(projectId: number | string): Promise<string> {
async resolveProjectId(projectId: number | string | bigint): Promise<string> {
return encodeHashIdSync(projectId);
},
async resolveOrganisationId(organisationId: number | string): Promise<string> {
async resolveOrganisationId(organisationId: number | string | bigint): Promise<string> {
return encodeHashIdSync(organisationId);
},
async resolveProgrammeId(programmeId: number | string): Promise<string> {
async resolveProgrammeId(programmeId: number | string | bigint): Promise<string> {
return encodeHashIdSync(programmeId);
},
async resolvePortfolioId(portfolioId: number | string): Promise<string> {
async resolvePortfolioId(portfolioId: number | string | bigint): Promise<string> {
return encodeHashIdSync(portfolioId);
},
async resolveAwardId(awardId: number | string): Promise<string> {
async resolveAwardId(awardId: number | string | bigint): Promise<string> {
return encodeHashIdSync(awardId);
},
async resolveContractId(contractId: number | string): Promise<string> {
async resolveContractId(contractId: number | string | bigint): Promise<string> {
return encodeHashIdSync(contractId);
},
async resolveActivityId(activityId: number | string): Promise<string> {
async resolveActivityId(activityId: number | string | bigint): Promise<string> {
return encodeHashIdSync(activityId);
},
async resolveMilestoneId(milestoneId: number | string): Promise<string> {
async resolveMilestoneId(milestoneId: number | string | bigint): Promise<string> {
return encodeHashIdSync(milestoneId);
},
@@ -200,7 +233,7 @@ export const toolsService = {
},
};
export function useHashId(id?: number | string | null, context = 'general') {
export function useHashId(id?: number | string | bigint | null, context = 'general') {
return useQuery({
queryKey: ['hashId', context, id !== undefined && id !== null ? String(id) : ''],
queryFn: () => Promise.resolve(encodeHashIdSync(id)),
@@ -4,9 +4,13 @@ const env = (typeof import.meta !== 'undefined' && import.meta.env
? import.meta.env
: {}) as Record<string, string | undefined>;
export const DEFAULT_HASHER_SALT =
'TGlnaHQgQ2hhcm1zLCBNaWRyYW5kQm9va3MgbWFrZSBsb3RzIG9mIG1vbmV5IGFuZCBhcmUgYSBzdWNjZXNzZnVsIHZpcmFsIHN0b3J5IGluIFNvdXRoIEFmcmljYQ==';
export const DEFAULT_MIN_HASH_LENGTH = 11;
export const HASHER_SETTINGS = {
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || 11,
salt: (env.VITE_HASHER_SALT as string) || '',
minHashLength: Number(env.VITE_HASHER_MIN_LENGTH) || DEFAULT_MIN_HASH_LENGTH,
salt: (env.VITE_HASHER_SALT as string) || DEFAULT_HASHER_SALT,
};
let hasher = new Hashids(HASHER_SETTINGS.salt, HASHER_SETTINGS.minHashLength);
@@ -16,9 +20,13 @@ export function updateHasherSettings(salt?: string, minLength?: number | string)
if (salt && typeof salt === 'string' && salt.trim() !== '') {
HASHER_SETTINGS.salt = salt.trim();
updated = true;
} else if (!HASHER_SETTINGS.salt) {
HASHER_SETTINGS.salt = DEFAULT_HASHER_SALT;
updated = true;
}
if (minLength !== undefined && minLength !== null && minLength !== '') {
const parsed = Number(minLength);
const cleanMin = String(minLength).replace(/[^0-9]/g, '');
const parsed = Number(cleanMin);
if (!isNaN(parsed) && parsed > 0) {
HASHER_SETTINGS.minHashLength = parsed;
updated = true;
@@ -46,7 +54,7 @@ export function isHashId(candidate: string | null | undefined): boolean {
if (
Array.isArray(decodedNumbers) &&
decodedNumbers.length > 0 &&
hasher.encode(decodedNumbers as number[]) === candidate
hasher.encode(decodedNumbers as any) === candidate
) {
return true;
}
@@ -149,23 +157,24 @@ export function toSafeInt64String(val: string | number | bigint | null | undefin
/**
* Deterministically encodes a numeric ID or 64-bit integer into a standard HashID string.
* Uses BigInt for full int64 range, interoperable with C# Hashids.net EncodeLong.
*/
export function encodeEntityId(id: string | number | bigint | null | undefined): string {
if (id === undefined || id === null || id === '') return '';
const str = typeof id === 'bigint' ? id.toString() : String(id).trim();
if (typeof id === 'bigint') {
if (id < 0n) return '';
return hasher.encode(id);
}
const str = String(id).trim();
if (!str) return '';
if (isHashId(str)) return str;
if (/^\d+$/.test(str)) {
try {
const num = Number(str);
if (num <= Number.MAX_SAFE_INTEGER && num >= 0) {
return hasher.encode(num);
}
const bigHex = BigInt(str).toString(16);
return hasher.encodeHex(bigHex);
return hasher.encode(BigInt(str));
} catch {
// Fall through
}
@@ -189,18 +198,9 @@ export function decodeEntityId(hashId: string | number | null | undefined): stri
if (Array.isArray(decoded) && decoded.length > 0) {
return decoded[0].toString();
}
const decodedHex = hasher.decodeHex(str);
if (decodedHex) {
try {
return BigInt('0x' + decodedHex).toString();
} catch {
return decodedHex;
}
}
} catch {
// Return raw
}
return str;
}
+16
View File
@@ -0,0 +1,16 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_OIDC_AUTHORITY?: string;
readonly VITE_OIDC_AUDIENCE?: string;
readonly VITE_OIDC_CLIENT_ID?: string;
readonly VITE_API_BASE_URL?: string;
readonly VITE_HASHER_SALT?: string;
readonly VITE_HASHER_MIN_LENGTH?: string;
readonly [key: string]: any;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
@@ -4,7 +4,6 @@
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"types": ["vite/client"],
"lib": [
"ES2022",
"DOM",