201 Commits
Author SHA1 Message Date
hermes c11535ebfc Added scheduler 2026-10-27 21:31:27 +02:00
hermes 129d10c7d1 Added jobs project and updated nuget packages 2026-10-27 08:28:24 +02:00
hermes 9f132eeb29 Fixed UI misalignments 2026-10-26 19:57:32 +02:00
hermes 80c798e5e6 Added Portfolio Analytics refresh button and card-per-award with contract, programme, and portfolio details 2026-10-26 17:45:52 +02:00
hermes 2690b3230e Refactored Evidence to UpdateBy is optional When creating evidence 2026-10-26 17:03:53 +02:00
hermes 155a379ab4 Resolved reply recipient userId from TenantUsers and enabled Beneficiary compliance exploration modal 2026-10-26 10:02:14 +02:00
hermes f56cb70b55 Fixed dshboard render crash 2026-10-26 09:34:15 +02:00
hermes bb77a4dc9a PartnerUI evidence, notification, indicators and actuals fixes 2026-10-26 08:53:05 +02:00
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
khwezi d9b65010cf Merge pull request 'Redesigned PartnerUi dashboard' (#172) from finishline into main
Reviewed-on: #172
2026-10-25 08:02:27 +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
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
77 changed files with 7867 additions and 3159 deletions
+1
View File
@@ -44,6 +44,7 @@
<Using Include="Microsoft.Extensions.Logging" />
<Using Include="Microsoft.Extensions.Options" />
<Using Include="Microsoft.IdentityModel.Tokens" />
<Using Include="Microsoft.FeatureManagement" />
<!-- Domain Constants -->
<Using Static="true" Include="PostFundManagement.Domain.Extensions.Constants" />
+56 -55
View File
@@ -12,67 +12,68 @@
<PackageVersion Include="Scrutor" Version="7.0.0" />
<!-- Microsoft Extensions & Configuration -->
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.10.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.10.0" />
<PackageVersion Include="Microsoft.Extensions.Validation" Version="10.0.12" />
<PackageVersion Include="Microsoft.FeatureManagement.AspNetCore" Version="4.7.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Hybrid" Version="10.11.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Http.Polly" Version="10.0.14" />
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.11.0" />
<PackageVersion Include="Microsoft.Extensions.Validation" Version="10.0.14" />
<PackageVersion Include="Microsoft.FeatureManagement.AspNetCore" Version="4.8.0" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.14" />
<!-- Security, Authentication & Identity -->
<PackageVersion Include="AccessTokenClient.Extensions" Version="5.1.0" />
<PackageVersion Include="Duende.AccessTokenManagement" Version="4.2.0" />
<PackageVersion Include="Duende.AccessTokenManagement.OpenIdConnect" Version="4.2.0" />
<PackageVersion Include="Duende.AccessTokenManagement" Version="4.3.0" />
<PackageVersion Include="Duende.AccessTokenManagement.OpenIdConnect" Version="4.3.0" />
<PackageVersion Include="IdentityModel" Version="6.2.0" />
<PackageVersion Include="IdentityModel.AspNetCore" Version="4.3.0" />
<PackageVersion Include="IdentityModel.AspNetCore.OAuth2introspection" Version="6.2.0" />
<PackageVersion Include="IdentityServer4.AccessTokenValidation" Version="3.0.1" />
<PackageVersion Include="KubernetesClient" Version="19.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.12" />
<PackageVersion Include="KubernetesClient" Version="21.0.8" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Certificate" Version="10.0.14" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.14" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.14" />
<!-- Upgraded to latest non-vulnerable version -->
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.13" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.14" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.14" />
<!-- Resilience -->
<PackageVersion Include="Polly" Version="8.8.0" />
<PackageVersion Include="Polly.Extensions" Version="8.8.0" />
<PackageVersion Include="Polly" Version="8.8.1" />
<PackageVersion Include="Polly.Extensions" Version="8.8.1" />
<!-- API, Web & Documentation -->
<PackageVersion Include="Asp.Versioning.Abstractions" Version="10.2.1" />
<PackageVersion Include="Asp.Versioning.Http" Version="10.2.3" />
<PackageVersion Include="Asp.Versioning.Mvc.ApiExplorer" Version="10.2.1" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="10.0.12" />
<PackageVersion Include="Refit.HttpClientFactory" Version="15.2.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.17.4" />
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.14" />
<PackageVersion Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" Version="10.0.14" />
<PackageVersion Include="Refit.HttpClientFactory" Version="16.4.0" />
<PackageVersion Include="Scalar.AspNetCore" Version="2.18.3" />
<!-- Database & Entity Framework Core -->
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Analyzers" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Analyzers" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.14" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.14" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<!-- Quartz Scheduler -->
<PackageVersion Include="Quartz" Version="4.1.0" />
<PackageVersion Include="Quartz.AspNetCore" Version="4.1.0" />
<PackageVersion Include="Quartz.Extensions.DependencyInjection" Version="4.1.0" />
<PackageVersion Include="Quartz.Plugins" Version="4.1.0" />
<PackageVersion Include="Quartz.Plugins.TimeZoneConverter" Version="4.1.0" />
<PackageVersion Include="Quartz.Serialization.SystemTextJson" Version="4.1.0" />
<PackageVersion Include="Quartz" Version="4.6.2" />
<PackageVersion Include="Quartz.AspNetCore" Version="4.6.2" />
<PackageVersion Include="Quartz.Extensions.DependencyInjection" Version="4.6.2" />
<PackageVersion Include="Quartz.Plugins" Version="4.6.2" />
<PackageVersion Include="Quartz.Plugins.TimeZoneConverter" Version="4.6.2" />
<PackageVersion Include="Quartz.Serialization.SystemTextJson" Version="4.6.2" />
<!-- Health Checks -->
<PackageVersion Include="AspNetCore.HealthChecks.NpgSql" Version="9.0.0" />
@@ -83,17 +84,17 @@
<PackageVersion Include="AspNetCore.HealthChecks.UI.InMemory.Storage" Version="9.0.0" />
<!-- Observability & OpenTelemetry -->
<PackageVersion Include="OpenTelemetry" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.18.0" />
<PackageVersion Include="OpenTelemetry" Version="1.20.1" />
<PackageVersion Include="OpenTelemetry.Exporter.Console" Version="1.20.1" />
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.20.1" />
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.20.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.20.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.20.1" />
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.20.1" />
<!-- Email & Messaging -->
<PackageVersion Include="MailKit" Version="4.18.0" />
<PackageVersion Include="MimeKit" Version="4.18.0" />
<PackageVersion Include="MailKit" Version="4.18.2" />
<PackageVersion Include="MimeKit" Version="4.18.2" />
<!-- CQRS & Mediator -->
<PackageVersion Include="FluentResults" Version="4.0.0" />
@@ -101,17 +102,17 @@
<PackageVersion Include="Mediator.SourceGenerator" Version="3.0.2" />
<!-- Cloud Storage -->
<PackageVersion Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.101.4" />
<PackageVersion Include="AWSSDK.S3" Version="4.0.103.3" />
<PackageVersion Include="AWSSDK.Extensions.NetCore.Setup" Version="4.0.102.7" />
<PackageVersion Include="AWSSDK.S3" Version="4.0.104.7" />
<!-- Analyzers -->
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.259" />
<PackageVersion Include="Meziantou.Analyzer" Version="3.0.317" />
<!-- Testing -->
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="coverlet.collector" Version="10.1.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.11.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
<PackageVersion Include="xunit.runner.visualstudio" Version="4.0.1" />
</ItemGroup>
</Project>
@@ -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
{
@@ -12,7 +12,14 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
{
public void Map(IEndpointRouteBuilder builder)
{
builder.MapPost("api/projects/beneficiaries/outcomes", HandleCreateAsync)
builder.MapPost("api/projects/beneficiaries/outcomes", async (CreateOutcomeRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
})
.WithDescription("Add a new beneficiary outcome")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName())
@@ -22,25 +29,5 @@ public sealed class CreateOutcomeEndpoint : IEndpoint
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
builder.MapPost("api/projects/beneficiaris/outcomes", HandleCreateAsync)
.WithDescription("Add a new beneficiary outcome (legacy route)")
.RequireAuthorization(Constants.MainScope)
.WithName(typeof(CreateOutcomeEndpoint).ToEndpointName("TypoAlias"))
.MapToApiVersion(new ApiVersion(1))
.Produces(StatusCodes.Status200OK)
.Produces<Problems>(StatusCodes.Status400BadRequest)
.Produces<Problems>(StatusCodes.Status500InternalServerError)
.Produces(StatusCodes.Status401Unauthorized)
.WithTags(EndpointTags.Projects);
}
private static async Task<IResult> HandleCreateAsync(CreateOutcomeRequest request, HttpContext context,
IProjectService service, CancellationToken cancellationToken = default) =>
await service.CreateOutcomeAsync(request, context.GetUserId(), cancellationToken) switch
{
{ IsSuccess: true } => Results.Ok(),
var r when r.HasError<CreateError>() => Results.BadRequest(r.Errors.ToProblems()),
var r => Results.InternalServerError(r.Errors.ToProblems())
};
}
+4 -6
View File
@@ -4,7 +4,6 @@ using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Health;
using PostFundManagement.Domain.Mediator;
using PostFundManagement.Infrastructure.Database.Services;
using static PostFundManagement.Domain.Extensions.Constants;
var builder = WebApplication.CreateBuilder(args);
@@ -45,7 +44,7 @@ builder.Services.AddSkorubaApiService(builder.Configuration);
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>));
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>));
builder.Services.AddQuartzScheduler(DefaultSchedulerName, builder.Configuration);
builder.Services.AddQuartzSchedulerClient(DefaultSchedulerName, builder.Configuration);
builder.Services.AddEmailServices(builder.Configuration);
builder.Services.AddHashServices(builder.Configuration);
builder.Services.AddGarageS3(builder.Configuration);
@@ -119,7 +118,9 @@ builder.Services.AddCors(options =>
policy.SetIsOriginAllowed(origin =>
{
if (string.IsNullOrWhiteSpace(origin)) return false;
var clean = origin.TrimEnd('/');
if (allowedOrigins.Any(a => string.Equals(a.TrimEnd('/'), clean, StringComparison.OrdinalIgnoreCase)))
return true;
@@ -133,10 +134,7 @@ builder.Services.AddCors(options =>
return true;
}
}
catch
{
// ignore parse failure
}
catch { /* eat all exceptions */ }
return false;
})
+1 -8
View File
@@ -31,14 +31,7 @@
"Microsoft.EntityFrameworkCore.Database": "Error",
"Microsoft.EntityFrameworkCore.Update": "Error"
}
},
"EmailProcessorCron": "0 */5 * * * ?",
"Email": {
"SmtpServer": "smtp.example.com",
"SmtpPort": 587,
"SenderEmail": "noreply@example.com",
"SenderName": "Example"
},
},
"HasherSettings": {
"MinHashLength": 11
},
@@ -25,7 +25,6 @@ public interface ICommunicationsService
Task<Result> CreateNotificationAsync(CreateNotificationRequest request, Guid userId, CancellationToken cancellationToken = default);
[Log]
[Audit]
Task<Result> UpdateNotificationAsync(UpdateNotificationRequest request, CancellationToken cancellationToken = default);
[Log]
@@ -1,9 +1,3 @@
using System.Reflection;
using System.Security.Claims;
using System.Text.Json;
using Castle.DynamicProxy;
using FluentResults;
using Microsoft.AspNetCore.Http;
using PostFundManagement.Application.Services.Shared;
using PostFundManagement.Domain.Attributes;
@@ -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) =>
@@ -30,7 +30,7 @@ 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:-11}}}}}"
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
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { NavLink, Outlet, useNavigate, Link } from 'react-router-dom';
import { NavLink, Outlet, useNavigate, Link, useLocation } from 'react-router-dom';
import { NotificationBell } from './NotificationBell';
import {
Inbox,
@@ -144,6 +144,27 @@ export const AppLayout: React.FC = () => {
const [sidebarOpen, setSidebarOpen] = useState(true);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
const isSectionCollapsed = (path: string, subItems?: SubNavItem[]): boolean => {
if (collapsedSections[path] !== undefined) {
return collapsedSections[path];
}
if (subItems?.some((s) => location.pathname.startsWith(s.path))) {
return false;
}
return false;
};
const toggleSection = (path: string, e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setCollapsedSections((prev) => ({
...prev,
[path]: !isSectionCollapsed(path),
}));
};
const handleLogout = async () => {
await logout();
@@ -186,7 +207,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 +214,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
@@ -346,26 +365,45 @@ export const AppLayout: React.FC = () => {
);
}
const isCollapsed = isSectionCollapsed(item.path, item.subItems);
return (
<div key={item.path} className="space-y-1">
<NavLink
to={item.path}
end={hasSub}
className={({ isActive }) =>
`flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
isActive
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}`
}
title={item.name}
>
<Icon className="w-4.5 h-4.5 shrink-0" />
<span className="truncate flex-1">{item.name}</span>
</NavLink>
<div className="flex items-center gap-1">
<NavLink
to={item.path}
end={hasSub}
className={({ isActive }) =>
`flex-1 flex items-center gap-3 px-3.5 py-2.5 rounded-xl text-xs font-medium transition-all min-w-0 ${
isActive
? 'bg-gradient-to-r from-blue-600 to-indigo-600 text-white shadow-md shadow-blue-600/25 font-semibold'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}`
}
title={item.name}
>
<Icon className="w-4.5 h-4.5 shrink-0" />
<span className="truncate flex-1">{item.name}</span>
</NavLink>
{hasSub && (
<button
type="button"
onClick={(e) => toggleSection(item.path, e)}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800/80 rounded-lg transition-colors cursor-pointer"
title={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
aria-label={isCollapsed ? `Expand ${item.name}` : `Collapse ${item.name}`}
>
<ChevronDown
className={`w-3.5 h-3.5 transition-transform duration-200 ${
isCollapsed ? '-rotate-90 text-slate-500' : 'rotate-0 text-slate-300'
}`}
/>
</button>
)}
</div>
{/* Sub-items under parent item */}
{hasSub && (
{hasSub && !isCollapsed && (
<div className="pl-6 pr-1 py-1 space-y-1 border-l border-slate-800/80 ml-5">
{item.subItems?.map((subItem) => {
const SubIcon = subItem.icon;
@@ -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
@@ -37,7 +37,7 @@ const activitySchema = z.object({
title: z.string().min(3, 'Activity title required'),
description: z.string().min(5, 'Description required'),
category: z.string().min(2, 'Category required'),
amount: z.coerce.number().min(1, 'Amount must be positive'),
amount: z.coerce.number().min(0, 'Amount must be non-negative'),
milestoneId: z.string().optional().nullable(),
performedAt: z.string().optional().nullable(),
});
@@ -20,6 +20,7 @@ import {
getProjectIndicators,
getProjectRisks,
getBudgets,
getDisbursements,
} from '../../services/api';
import { Project, ApprovalStatus, Budget } from '../../types';
import {
@@ -86,9 +87,21 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
);
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
const { data: disbursements = [] } = useQuery({
queryKey: ['disbursements', projectId],
queryFn: () => getDisbursements(1, 100),
});
const projectDisbursements = disbursements.filter(
(d) => String(d.projectId) === String(projectId) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
);
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const projectValue = totalActivityBudget - totalDisbursed;
const projectVariance = approvedBudgetTotal - projectValue;
return (
<div className="p-4 sm:p-5 lg:p-6 bg-[#090d16]/95 border-t border-b border-blue-500/20 space-y-6 animate-in fade-in duration-200 w-full min-w-0">
@@ -108,6 +121,19 @@ export const ProjectExpandedRow: React.FC<ProjectExpandedRowProps> = ({
<span className="text-xs text-emerald-400 font-mono font-semibold shrink-0">
Approved Budget: {formatZAR(approvedBudgetTotal)}
</span>
<span className="text-slate-600">&bull;</span>
<span className="text-xs text-blue-400 font-mono font-semibold shrink-0" title={`Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}`}>
Project Value: {formatZAR(projectValue)}
</span>
<span className="text-slate-600">&bull;</span>
<span
className={`text-xs font-mono font-semibold shrink-0 ${
projectVariance >= 0 ? 'text-emerald-400' : 'text-rose-400'
}`}
title="Approved Budget minus Net Project Value"
>
Variance: {projectVariance >= 0 ? '+' : ''}{formatZAR(projectVariance)}
</span>
</>
)}
</div>
@@ -33,6 +33,7 @@ import {
getProjectIndicators,
getProjectRisks,
getBudgets,
getDisbursements,
updateProjectStatus,
} from '../../services/api';
import { Project, ApprovalStatus, Programme } from '../../types';
@@ -150,11 +151,23 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
);
const approvedBudgetTotal = approvedBudgets.reduce((sum, b) => sum + (Number(b.amount) || 0), 0);
// Milestone Progress
// Milestone Progress & Disbursements
const verifiedMilestonesCount = milestones.filter((m) => m.status === ApprovalStatus.Approved).length;
const milestoneProgress = milestones.length > 0 ? Math.round((verifiedMilestonesCount / milestones.length) * 100) : 0;
const totalActivityBudget = activities.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const { data: disbursements = [] } = useQuery({
queryKey: ['disbursements', project?.id],
queryFn: () => getDisbursements(1, 100),
enabled: !!project?.id,
});
const projectDisbursements = disbursements.filter(
(d) => String(d.projectId) === String(project?.id) && (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3)
);
const totalDisbursed = projectDisbursements.reduce((sum, d) => sum + (Number(d.amount) || 0), 0);
const projectValue = totalActivityBudget - totalDisbursed;
const projectVariance = approvedBudgetTotal - projectValue;
// Schedule Risk
const scheduleRisk = useMemo(() => {
if (!project) return null;
@@ -244,20 +257,20 @@ export const ProjectViewModal: React.FC<ProjectViewModalProps> = ({
Budget: {formatZAR(approvedBudgetTotal)}
</span>
<span className="text-slate-600">&bull;</span>
<span className="text-blue-400 font-mono font-bold">
Project Value: {formatZAR(totalActivityBudget)}
<span className="text-blue-400 font-mono font-bold" title={`Activities Total: ${formatZAR(totalActivityBudget)} minus Disbursements: ${formatZAR(totalDisbursed)}`}>
Project Value: {formatZAR(projectValue)}
</span>
<span className="text-slate-600">&bull;</span>
<span
className={`font-mono font-bold px-2 py-0.5 rounded text-[11px] border ${
approvedBudgetTotal - totalActivityBudget >= 0
projectVariance >= 0
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
}`}
title="Approved Budget minus Total Project Value"
title={`Approved Budget: ${formatZAR(approvedBudgetTotal)} | Net Project Value: ${formatZAR(projectValue)} (Activities: ${formatZAR(totalActivityBudget)} - Disbursements: ${formatZAR(totalDisbursed)}) | Variance: ${projectVariance >= 0 ? '+' : ''}${formatZAR(projectVariance)}`}
>
Variance: {approvedBudgetTotal - totalActivityBudget >= 0 ? '+' : ''}
{formatZAR(approvedBudgetTotal - totalActivityBudget)}
Variance: {projectVariance >= 0 ? '+' : ''}
{formatZAR(projectVariance)}
</span>
</div>
</div>
@@ -32,6 +32,7 @@ import { clearCachedUserRbac, fetchAndCacheRoleClaims } from '../../services/rol
import { HelpTooltip } from '../ui/HelpTooltip';
import { ObfuscatedIdNumber } from '../ui/ObfuscatedIdNumber';
import { decodeIdNumberHex } from '../../utils/hashids';
import { useAuth } from '../../auth/AuthProvider';
import { User, Role, Claim, ActivityStatus } from '../../types';
import { formatDate, getActivityStatusBadge, getUserRoleBadge } from '../../lib/formatters';
import { useRbac, ROLE_CLAIM_DEFINITIONS } from '../../auth/rbac';
@@ -49,9 +50,15 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
}) => {
const queryClient = useQueryClient();
const { success, error: toastError } = useToast();
const { user: currentAuthUser } = useAuth();
const { canApprove, canAdd, canView, canAccess, isUnrestricted } = useRbac();
const hasTenantSelfHelpAccess = canAccess('tenant_self_service') || canView('tenant_self_service');
const userId = user?.id || '';
const isSelf =
Boolean(currentAuthUser?.id && userId && String(currentAuthUser.id).toLowerCase() === String(userId).toLowerCase()) ||
Boolean(currentAuthUser?.email && user?.email && currentAuthUser.email.toLowerCase() === user.email.toLowerCase());
const canManageRolesAndClaims =
canApprove('settings_users') ||
canApprove('tenant_management') ||
@@ -63,18 +70,17 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
hasTenantSelfHelpAccess ||
isUnrestricted;
const canChangeStatus =
canAdd('settings_users') ||
canAdd('tenant_management') ||
canAdd('tenant_self_service') ||
canApprove('settings_users') ||
isUnrestricted;
!isSelf &&
(canAdd('settings_users') ||
canAdd('tenant_management') ||
canAdd('tenant_self_service') ||
canApprove('settings_users') ||
isUnrestricted);
const [activeTab, setActiveTab] = useState<'overview' | 'roles' | 'claims' | 'sessions'>('overview');
const [selectedRoleToAdd, setSelectedRoleToAdd] = useState('');
const userId = user?.id || '';
// 1. Fetch fresh user details
const { data: freshUser, isLoading: loadingUser, refetch: refetchUser } = useQuery<User | null>({
queryKey: ['user-detail', userId],
@@ -260,7 +266,12 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
});
const statusMutation = useMutation({
mutationFn: (newStatus: ActivityStatus) => updateUserStatus(userId, newStatus),
mutationFn: (newStatus: ActivityStatus) => {
if (isSelf) {
throw new Error('Maker-Checker Violation: You cannot alter your own account activity status.');
}
return updateUserStatus(userId, newStatus);
},
onSuccess: () => {
success('Status Updated', 'User activity status changed.');
refetchUser();
@@ -396,7 +407,14 @@ export const UserSecurityModal: React.FC<UserSecurityModalProps> = ({
</span>
</div>
<div className="flex items-center gap-2">
{effectiveUser?.status === ActivityStatus.Active ? (
{isSelf ? (
<span
className="px-2.5 py-1 text-[11px] font-semibold text-slate-400 bg-slate-900 border border-slate-800 rounded-lg"
title="Maker-Checker policy: Administrators cannot change their own account activity status"
>
Self-Account (Protected)
</span>
) : effectiveUser?.status === ActivityStatus.Active ? (
<button
type="button"
onClick={() => statusMutation.mutate(ActivityStatus.Inactive)}
@@ -6,6 +6,7 @@ import {
getNotifications,
createNotification,
markNotificationAsRead,
archiveNotification,
updateNotification,
getCurrentUserTenantId,
} from '../services/api';
@@ -124,7 +125,6 @@ export function useInAppNotifications() {
},
// Live polling every 30 seconds
refetchInterval: 30000,
staleTime: 10000,
});
// InApp notifications (strictly platform === InApp 5) for Bell & Counters
@@ -137,7 +137,10 @@ export function useInAppNotifications() {
// Active in-app notifications in inbox (excluding trashed and drafts)
const inboxNotifications = useMemo(() => {
return inAppNotifications.filter(
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
n =>
!trashedIds.includes(String(n.id)) &&
Number(n.status) !== NotificationStatus.Archived &&
Number(n.status) !== NotificationStatus.Draft
);
}, [inAppNotifications, trashedIds]);
@@ -168,7 +171,9 @@ export function useInAppNotifications() {
// Trashed notifications (In-App only)
const trashedNotifications = useMemo(() => {
return inAppNotifications.filter(n => trashedIds.includes(String(n.id)));
return inAppNotifications.filter(
n => trashedIds.includes(String(n.id)) || Number(n.status) === NotificationStatus.Archived
);
}, [inAppNotifications, trashedIds]);
// Mutation to mark notification as read
@@ -211,8 +216,8 @@ export function useInAppNotifications() {
});
}, [starredStorageKey]);
// Delete message (move to trash)
const deleteMessage = useCallback((id: string | number) => {
// Delete message (NotificationStatus == Archived & move to trash)
const deleteMessage = useCallback(async (id: string | number) => {
const strId = String(id);
setTrashedIds(prev => {
const next = prev.includes(strId) ? prev : [...prev, strId];
@@ -222,10 +227,22 @@ export function useInAppNotifications() {
} catch {}
return next;
});
}, [trashStorageKey]);
// Restore from trash
const restoreMessage = useCallback((id: string | number) => {
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Archived } : n))
);
try {
await archiveNotification(id);
} catch (err) {
console.warn('[deleteMessage] Error archiving notification on server:', err);
} finally {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
}
}, [trashStorageKey, userId, queryClient]);
// Restore from trash / unarchive
const restoreMessage = useCallback(async (id: string | number) => {
const strId = String(id);
setTrashedIds(prev => {
const next = prev.filter(x => x !== strId);
@@ -235,7 +252,19 @@ export function useInAppNotifications() {
} catch {}
return next;
});
}, [trashStorageKey]);
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Read } : n))
);
try {
await markNotificationAsRead(id);
} catch (err) {
console.warn('[restoreMessage] Error restoring notification on server:', err);
} finally {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
}
}, [trashStorageKey, userId, queryClient]);
// Permanent delete
const permanentDelete = useCallback((id: string | number) => {
@@ -80,6 +80,7 @@ export const NOTIFICATION_STATUS_OPTIONS = [
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
{ value: NotificationStatus.Bounced, label: 'Bounced' },
{ value: NotificationStatus.Draft, label: 'Draft' },
{ value: NotificationStatus.Archived, label: 'Archived' },
];
export const NOTIFICATION_PLATFORM_OPTIONS = [
@@ -510,6 +511,8 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
const s = Number(status);
switch (s) {
case NotificationStatus.Archived:
return { label: 'Archived', className: 'bg-slate-800 text-slate-300 border-slate-700 font-semibold' };
case NotificationStatus.Draft:
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
case NotificationStatus.Delivered:
@@ -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)
@@ -353,6 +353,12 @@ export const BeneficiariesPage: React.FC = () => {
const updateCompMutation = useMutation({
mutationFn: (vals: ComplianceFormValues) => {
if (!editingComp) throw new Error('No item selected');
if (
(vals.status === ComplianceStatus.Met || vals.status === ComplianceStatus.Exceeded) &&
isMaker(editingComp.createdBy)
) {
throw new Error('Maker-Checker Violation: You created this compliance item and cannot approve it.');
}
return updateComplianceItem({
complianceItemId: editingComp.id,
title: vals.title,
@@ -399,6 +405,12 @@ export const BeneficiariesPage: React.FC = () => {
const updateOutcomeMutation = useMutation({
mutationFn: (vals: OutcomeFormValues) => {
if (!editingOutcome) throw new Error('No outcome selected for update');
if (
(vals.status === ComplianceStatus.Met || vals.status === ComplianceStatus.Exceeded) &&
isMaker(editingOutcome.createdBy)
) {
throw new Error('Maker-Checker Violation: You created this outcome record and cannot approve or certify fulfillment.');
}
return updateOutcome({
outcomeId: editingOutcome.id,
name: vals.name,
@@ -1467,11 +1479,15 @@ export const BeneficiariesPage: React.FC = () => {
{...compForm.register('status')}
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
>
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
{COMPLIANCE_STATUS_OPTIONS.map((opt) => {
const isApprovedOpt = opt.value === ComplianceStatus.Met || opt.value === ComplianceStatus.Exceeded;
const makerRestricted = editingComp && isApprovedOpt && isMaker(editingComp.createdBy);
return (
<option key={opt.value} value={opt.value} disabled={makerRestricted}>
{opt.label} {makerRestricted ? '(Maker-Checker: Restricted)' : ''}
</option>
);
})}
</select>
</div>
@@ -1680,11 +1696,15 @@ export const BeneficiariesPage: React.FC = () => {
{...outcomeForm.register('status')}
className="w-full bg-slate-900 border border-slate-800 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-blue-500 cursor-pointer"
>
{COMPLIANCE_STATUS_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
{COMPLIANCE_STATUS_OPTIONS.map((opt) => {
const isMetOpt = opt.value === ComplianceStatus.Met || opt.value === ComplianceStatus.Exceeded;
const makerRestricted = editingOutcome && isMetOpt && isMaker(editingOutcome.createdBy);
return (
<option key={opt.value} value={opt.value} disabled={makerRestricted}>
{opt.label} {makerRestricted ? '(Maker-Checker: Restricted)' : ''}
</option>
);
})}
</select>
</div>
@@ -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,
status: ActivityStatus.Inactive,
},
});
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,8 +777,9 @@ export const GovernancePage: React.FC = () => {
registrationNo: '',
email: '',
organisationUser: defaultUser?.id || '',
idNumber: defaultUser?.idNumber || '',
type: OrganisationType.Enterprise,
status: ActivityStatus.Active,
status: ActivityStatus.Inactive,
});
setIsQuickAddUserOpen(false);
setQuickUserError(null);
@@ -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,
@@ -123,7 +124,7 @@ export const GrantsPage: React.FC = () => {
const [isBudgetModalOpen, setIsBudgetModalOpen] = useState(false);
const [editingBudget, setEditingBudget] = useState<Budget | null>(null);
const [isEditBudgetModalOpen, setIsEditBudgetModalOpen] = useState(false);
const [selectedBudgetStatus, setSelectedBudgetStatus] = useState<ApprovalStatus>(ApprovalStatus.Approved);
const [selectedBudgetStatus, setSelectedBudgetStatus] = useState<ApprovalStatus>(ApprovalStatus.Pending);
const [isDisbursementModalOpen, setIsDisbursementModalOpen] = useState(false);
const [editingDisbursement, setEditingDisbursement] = useState<Disbursement | null>(null);
@@ -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(
@@ -637,8 +643,14 @@ export const GrantsPage: React.FC = () => {
handleSort: handleAwardSort,
} = useTableSort(filteredAwards, null, null, {
referenceNo: (a) => a.referenceNo,
organisationId: (a) => a.organisationName || `Org #${a.organisationId}`,
programmeId: (a) => a.programmeName || `Programme #${a.programmeId}`,
organisationId: (a) => {
const org = organisations.find((o) => String(o.id) === String(a.organisationId));
return (org?.name || a.organisationName || `Organisation #${a.organisationId}`).toLowerCase();
},
programmeId: (a) => {
const prog = programmes.find((p) => String(p.id) === String(a.programmeId));
return (prog?.name || a.programmeName || `Programme #${a.programmeId}`).toLowerCase();
},
amount: (a) => a.amount,
status: (a) => getApprovalStatusBadge(a.status).label,
createdAt: (a) => a.createdAt,
@@ -651,7 +663,10 @@ export const GrantsPage: React.FC = () => {
handleSort: handleBudgetSort,
} = useTableSort(filteredBudgets, null, null, {
name: (b) => b.name,
projectId: (b) => b.projectId,
projectId: (b) => {
const proj = projects.find((p) => String(p.id) === String(b.projectId));
return (proj?.name || `Project #${b.projectId}`).toLowerCase();
},
amount: (b) => b.amount,
createdAt: (b) => b.createdAt,
status: (b) => getApprovalStatusBadge(b.status ?? ApprovalStatus.Approved).label,
@@ -665,8 +680,15 @@ export const GrantsPage: React.FC = () => {
handleSort: handleDisbSort,
} = useTableSort(filteredDisbursements, null, null, {
id: (d) => d.id,
projectId: (d) => d.projectId,
milestoneId: (d) => d.milestoneId || 0,
projectId: (d) => {
const proj = projects.find((p) => String(p.id) === String(d.projectId));
return (proj?.name || `Project #${d.projectId}`).toLowerCase();
},
milestoneId: (d) => {
if (!d.milestoneId) return 'Advance Tranche';
const ms = milestones.find((m) => String(m.id) === String(d.milestoneId));
return (ms?.name || `Milestone #${d.milestoneId}`).toLowerCase();
},
amount: (d) => d.amount,
paymentReference: (d) => d.paymentReference || '',
status: (d) => getApprovalStatusBadge(d.status).label,
@@ -826,8 +848,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}
@@ -899,10 +923,28 @@ export const GrantsPage: React.FC = () => {
<span className="text-[10px] text-slate-500 font-mono">System ID: #{awd.id}</span>
</td>
<td className="py-3.5 px-4 text-slate-200 font-medium">
{awd.organisationName || `Org #${awd.organisationId}`}
{(() => {
const org = organisations.find((o) => String(o.id) === String(awd.organisationId));
const orgName = org?.name || awd.organisationName || (awd.organisationId ? `Organisation #${awd.organisationId}` : '—');
return (
<div>
<span className="text-white block font-medium">{orgName}</span>
{awd.organisationId && <span className="text-[10px] text-slate-500 font-mono">ID: #{awd.organisationId}</span>}
</div>
);
})()}
</td>
<td className="py-3.5 px-4 text-slate-400">
{awd.programmeName || `Programme #${awd.programmeId}`}
{(() => {
const prog = programmes.find((p) => String(p.id) === String(awd.programmeId));
const progName = prog?.name || awd.programmeName || (awd.programmeId ? `Programme #${awd.programmeId}` : '—');
return (
<div>
<span className="text-slate-200 block">{progName}</span>
{awd.programmeId && <span className="text-[10px] text-slate-500 font-mono">Programme #{awd.programmeId}</span>}
</div>
);
})()}
</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
{formatZAR(awd.amount)}
@@ -919,10 +961,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 +1015,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}
@@ -1004,7 +1048,7 @@ export const GrantsPage: React.FC = () => {
Budget Line Category
</SortableTh>
<SortableTh column="projectId" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} className="py-3.5 px-4">
Project ID
Project
</SortableTh>
<SortableTh column="amount" currentColumn={budgetSortCol} direction={budgetSortDir} onSort={handleBudgetSort} align="right" className="py-3.5 px-4 text-right">
Approved Amount (ZAR)
@@ -1043,7 +1087,18 @@ export const GrantsPage: React.FC = () => {
<span className="font-semibold text-white block">{b.name}</span>
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.id}</span>
</td>
<td className="py-3.5 px-4 text-slate-400 font-mono">Project #{b.projectId}</td>
<td className="py-3.5 px-4 text-slate-200">
{(() => {
const proj = projects.find((p) => String(p.id) === String(b.projectId));
const projName = proj?.name || `Project #${b.projectId}`;
return (
<div>
<span className="font-medium text-white block">{projName}</span>
<span className="text-[10px] text-slate-500 font-mono">ID: #{b.projectId}</span>
</div>
);
})()}
</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white">
{formatZAR(b.amount)}
</td>
@@ -1059,10 +1114,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 +1188,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}
@@ -1172,7 +1229,7 @@ export const GrantsPage: React.FC = () => {
Disbursement ID
</SortableTh>
<SortableTh column="projectId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
Project ID
Project
</SortableTh>
<SortableTh column="milestoneId" currentColumn={disbSortCol} direction={disbSortDir} onSort={handleDisbSort} className="py-3.5 px-4">
Milestone Association
@@ -1211,9 +1268,32 @@ export const GrantsPage: React.FC = () => {
<td className="py-3.5 px-5 font-mono font-bold text-white">
DSB-{d.id}
</td>
<td className="py-3.5 px-4 text-slate-300 font-mono">Project #{d.projectId}</td>
<td className="py-3.5 px-4 text-slate-200">
{(() => {
const proj = projects.find((p) => String(p.id) === String(d.projectId));
const projName = proj?.name || `Project #${d.projectId}`;
return (
<div>
<span className="font-medium text-white block">{projName}</span>
<span className="text-[10px] text-slate-500 font-mono">Project ID: #{d.projectId}</span>
</div>
);
})()}
</td>
<td className="py-3.5 px-4 text-slate-400 font-medium">
{d.milestoneId ? `Milestone #${d.milestoneId}` : 'Initial Advance Tranche'}
{(() => {
if (!d.milestoneId) {
return <span className="text-slate-400 italic">Initial Advance Tranche</span>;
}
const ms = milestones.find((m) => String(m.id) === String(d.milestoneId));
const msName = ms?.name || `Milestone #${d.milestoneId}`;
return (
<div>
<span className="font-medium block">{msName}</span>
<span className="text-[10px] text-slate-500 font-mono">Milestone ID: #{d.milestoneId}</span>
</div>
);
})()}
</td>
<td className="py-3.5 px-4 text-right font-mono font-bold text-white text-xs">
{formatZAR(d.amount)}
@@ -1549,7 +1629,14 @@ 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;
}
if (
selectedAwardStatus === ApprovalStatus.Approved &&
!canApproveRecord('grant_financials', editingAward.createdBy)
) {
toastError('Maker-Checker Violation', 'Record creators cannot approve their own award records.');
return;
}
updateAwardMutation.mutate({
@@ -1630,7 +1717,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
@@ -1640,7 +1727,21 @@ export const GrantsPage: React.FC = () => {
>
<option value={ApprovalStatus.Pending}>Pending Approval</option>
<option value={ApprovalStatus.Review}>Under Review</option>
<option value={ApprovalStatus.Approved}>Approved &amp; Activated</option>
{(() => {
const canApproveAwd = canApproveRecord('grant_financials', editingAward?.createdBy);
const isSelf = isMaker(editingAward?.createdBy);
let reason = '';
if (!canApprove('grant_financials')) {
reason = ' (Requires Update Permission)';
} else if (isSelf) {
reason = ' (Maker-Checker: Cannot approve own record)';
}
return (
<option value={ApprovalStatus.Approved} disabled={!canApproveAwd}>
Approved &amp; Activated {reason}
</option>
);
})()}
<option value={ApprovalStatus.Rejected}>Rejected</option>
</select>
)}
@@ -1783,7 +1884,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 {
@@ -149,7 +148,11 @@ export const InboxPage: React.FC = () => {
}
}
} else if (!selectedMessage && folderMessages.length > 0 && activeFolder !== 'drafts') {
setSelectedMessage(folderMessages[0]);
const first = folderMessages[0];
setSelectedMessage(first);
if (activeFolder === 'inbox' && Number(first.status) !== NotificationStatus.Read) {
markAsRead(first.id);
}
}
}, [targetIdParam, folderMessages, activeFolder]);
@@ -666,6 +669,22 @@ export const InboxPage: React.FC = () => {
>
<Star className={`w-3.5 h-3.5 ${isStarred ? 'fill-amber-400' : ''}`} />
</button>
<button
type="button"
onClick={e => {
e.stopPropagation();
deleteMessage(item.id);
if (selectedMessage?.id === item.id) {
setSelectedMessage(null);
}
}}
className="p-0.5 rounded text-slate-400 hover:text-rose-400 transition cursor-pointer"
title="Delete message (Archive)"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
<span className="text-[10px] text-slate-400 font-mono">
{formatDate(item.createdAt)}
</span>
@@ -42,6 +42,7 @@ import {
recordIndicatorActual,
getBudgets,
getActivities,
getDisbursements,
} from '../services/api';
import { Modal } from '../components/ui/Modal';
import { HelpTooltip } from '../components/ui/HelpTooltip';
@@ -242,6 +243,11 @@ export const ProjectsPage: React.FC = () => {
queryFn: () => getBudgets(1, 100),
});
const { data: allDisbursements = [] } = useQuery({
queryKey: ['disbursements'],
queryFn: () => getDisbursements(1, 200),
});
const { data: allProjectActivities = new Map<string | number, any[]>() } = useQuery({
queryKey: ['allProjectActivities', projects.map((p) => p.id)],
queryFn: async () => {
@@ -311,6 +317,18 @@ export const ProjectsPage: React.FC = () => {
return map;
}, [allBudgets]);
// Disbursed capital per project
const disbursedByProject = useMemo(() => {
const map = new Map<string | number, number>();
allDisbursements.forEach((d) => {
if (Number(d.status) === ApprovalStatus.Approved || Number(d.status) === 3) {
const current = map.get(d.projectId) || 0;
map.set(d.projectId, current + (Number(d.amount) || 0));
}
});
return map;
}, [allDisbursements]);
// Filtered Projects
const filteredProjects = useMemo(() => {
return projects.filter((p) => {
@@ -380,9 +398,22 @@ export const ProjectsPage: React.FC = () => {
return amt > 0 ? formatZAR(amt) : 'R 0';
},
},
{
header: 'Project Variance (ZAR)',
key: 'id',
format: (_, p) => {
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
const pActs = allProjectActivities.get(p.id) || [];
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
const projectValue = pActsTotal - pDisbursedTotal;
const pVariance = approvedBudget - projectValue;
return `${pVariance >= 0 ? '+' : ''}${formatZAR(pVariance)}`;
},
},
{ header: 'Approval Status', key: 'status', format: (val) => getApprovalStatusBadge(val).label },
{ header: 'Created By', key: 'createdBy', format: (val) => getUserEmail(val) },
], [programmeMap, milestonesByProject, approvedBudgetsByProject]);
], [programmeMap, milestonesByProject, approvedBudgetsByProject, allProjectActivities, disbursedByProject]);
// Table Sorting
const customExtractors = {
@@ -404,6 +435,14 @@ export const ProjectsPage: React.FC = () => {
if (risk.isAtRisk) return 2;
return 3;
},
variance: (p: Project) => {
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
const pActs = allProjectActivities.get(p.id) || [];
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
const projectValue = pActsTotal - pDisbursedTotal;
return approvedBudget - projectValue;
},
};
const { sortedData: sortedProjects, sortColumn, sortDirection, handleSort } = useTableSort(
@@ -1007,17 +1046,17 @@ export const ProjectsPage: React.FC = () => {
/>
</div>
</SortableTh>
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-36">
<SortableTh column="variance" currentColumn={sortColumn} direction={sortDirection} onSort={handleSort} align="right" className="py-3.5 px-4 text-right whitespace-nowrap w-36">
<div className="flex items-center justify-end gap-1">
<span>Project Variance</span>
<HelpTooltip
title="Project Variance"
content="Approved allocated budget minus scheduled milestone activity values. Positive (green) indicates within budget constraints; negative (red) indicates cost overrun."
content="Approved allocated budget minus net project value (milestone activity commitments minus disbursements). Positive (green) indicates remaining budget headroom; negative (red) indicates cost overrun."
size="sm"
placement="bottom"
/>
</div>
</th>
</SortableTh>
<th className="py-3.5 px-4 text-right font-semibold whitespace-nowrap w-56">Actions</th>
</tr>
</thead>
@@ -1212,7 +1251,9 @@ export const ProjectsPage: React.FC = () => {
const approvedBudget = approvedBudgetsByProject.get(p.id) || 0;
const pActs = allProjectActivities.get(p.id) || [];
const pActsTotal = pActs.reduce((sum, a) => sum + (Number(a.amount) || 0), 0);
const pVariance = approvedBudget - pActsTotal;
const pDisbursedTotal = disbursedByProject.get(p.id) || 0;
const projectValue = pActsTotal - pDisbursedTotal;
const pVariance = approvedBudget - projectValue;
const isPos = pVariance >= 0;
return (
<td className="py-3.5 px-4 text-right whitespace-nowrap w-36 font-mono font-bold" onClick={(e) => e.stopPropagation()}>
@@ -1222,7 +1263,7 @@ export const ProjectsPage: React.FC = () => {
? 'text-emerald-400 bg-emerald-500/10 border-emerald-500/30'
: 'text-rose-400 bg-rose-500/10 border-rose-500/30'
}`}
title={`Approved Budget: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`}
title={`Approved Budget: ${formatZAR(approvedBudget)} | Milestone Activities: ${formatZAR(pActsTotal)} | Disbursements: ${formatZAR(pDisbursedTotal)} | Project Value: ${formatZAR(projectValue)} | Variance: ${isPos ? '+' : ''}${formatZAR(pVariance)}`}
>
{isPos ? `+${formatZAR(pVariance)}` : `-${formatZAR(Math.abs(pVariance))}`}
</span>
@@ -57,6 +57,7 @@ import {
getOrganisations,
getAwards,
getContracts,
getProjects,
getAuditLogs,
getNotifications,
notifyInviteApproved,
@@ -79,6 +80,8 @@ import {
NotificationStatus,
NotificationPlatform,
ActivityStatus,
ApprovalStatus,
ContractStatus,
User,
Invite,
InviteRequest,
@@ -110,7 +113,7 @@ export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'users' | 'invites' | 'notifications' | 'audit'>('users');
const { canView, canAdd, canExport } = useRbac();
const { canView, canAdd, canExport, canApproveRecord, isMaker } = useRbac();
const canViewUsers = canView('settings_users') || canView('tenant_self_service');
const canViewInvites = canView('settings_invites');
const canViewNotifications = canView('settings_notifications');
@@ -152,7 +155,6 @@ export const SettingsPage: React.FC = () => {
const { data: usersDirectory = [] } = useQuery({
queryKey: ['users-directory'],
queryFn: () => getUsers(1, 200),
staleTime: 5 * 60 * 1000,
});
const {
@@ -241,6 +243,7 @@ export const SettingsPage: React.FC = () => {
const [invitedOrgId, setInvitedOrgId] = useState<string | number>('');
const [inviteAwardId, setInviteAwardId] = useState<string | number>('');
const [inviteContractId, setInviteContractId] = useState<string | number>('');
const [inviteProjectId, setInviteProjectId] = useState<string | number>('');
const [inviteRecipientUuid, setInviteRecipientUuid] = useState('');
const [inviteMessage, setInviteMessage] = useState('');
const [invitePlatform, setInvitePlatform] = useState<NotificationPlatform>(NotificationPlatform.Email);
@@ -253,6 +256,10 @@ export const SettingsPage: React.FC = () => {
);
const handleApproveInvite = async (inv: Invite, idx: number) => {
if (isMaker(inv.createdBy)) {
toastError('Maker-Checker Violation', 'You cannot approve an invitation that you created.');
return;
}
const inviteKey = inv.id ? String(inv.id) : `inv_${idx}`;
markInviteAsApprovedInStorage(inviteKey, currentTenantId);
setApprovedInviteIds((prev) => (prev.includes(inviteKey) ? prev : [...prev, inviteKey]));
@@ -270,32 +277,6 @@ export const SettingsPage: React.FC = () => {
);
};
// Invites filtering & search
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all');
const {
data: invitesList = [],
isLoading: loadingInvites,
isFetching: isFetchingInvites,
refetch: refetchInvites,
} = useQuery({
queryKey: ['invites'],
queryFn: () => getInvites(1, 100),
});
const filteredInvites = useMemo(() => {
return invitesList.filter((inv) => {
const matchSearch =
!inviteSearchTerm.trim() ||
(inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) ||
(inv.message && inv.message.toLowerCase().includes(inviteSearchTerm.toLowerCase()));
const matchPlatform =
invitePlatformFilter === 'all' || Number(inv.platform) === Number(invitePlatformFilter);
return matchSearch && matchPlatform;
});
}, [invitesList, inviteSearchTerm, invitePlatformFilter]);
const { data: organisations = [] } = useQuery({
queryKey: ['organisations'],
queryFn: () => getOrganisations(1, 50),
@@ -311,6 +292,104 @@ export const SettingsPage: React.FC = () => {
queryFn: () => getContracts(1, 50),
});
const { data: projects = [] } = useQuery({
queryKey: ['projects'],
queryFn: () => getProjects(1, 100),
});
// Only awards that are Approved AND have an Approved/Active contract are eligible for invitations
const eligibleAwards = useMemo(() => {
return awards.filter((aw) => {
const isApprovedAward = Number(aw.status) === ApprovalStatus.Approved;
if (!isApprovedAward) return false;
const hasApprovedContract = contracts.some(
(c) =>
String(c.awardId) === String(aw.id) &&
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
);
return hasApprovedContract;
});
}, [awards, contracts]);
const handleSelectAward = (awardId: string | number) => {
setInviteAwardId(awardId);
const selected = eligibleAwards.find((a) => String(a.id) === String(awardId));
if (selected) {
setInvitedOrgId(selected.organisationId);
const matchingContract = contracts.find(
(c) =>
String(c.awardId) === String(selected.id) &&
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
);
if (matchingContract) {
setInviteContractId(matchingContract.id);
}
setInviteProjectId(selected.projectId || '');
}
};
React.useEffect(() => {
if (isSendInviteModalOpen) {
if (eligibleAwards.length > 0) {
const currentAward =
eligibleAwards.find((a) => String(a.id) === String(inviteAwardId)) || eligibleAwards[0];
setInviteAwardId(currentAward.id);
setInvitedOrgId(currentAward.organisationId);
const matchingContract = contracts.find(
(c) =>
String(c.awardId) === String(currentAward.id) &&
(Number(c.status) === ContractStatus.Active || Number(c.status) === ContractStatus.Executed)
);
if (matchingContract) {
setInviteContractId(matchingContract.id);
}
setInviteProjectId(currentAward.projectId || '');
} else {
setInviteAwardId('');
setInvitedOrgId('');
setInviteContractId('');
setInviteProjectId('');
}
}
}, [isSendInviteModalOpen, eligibleAwards, contracts]);
// Invites filtering & search
const [inviteSearchTerm, setInviteSearchTerm] = useState('');
const [invitePlatformFilter, setInvitePlatformFilter] = useState<'all' | number>('all');
const {
data: invitesList = [],
isLoading: loadingInvites,
isFetching: isFetchingInvites,
refetch: refetchInvites,
} = useQuery({
queryKey: ['invites'],
queryFn: () => getInvites(1, 100),
});
const getStoredInvitePlatform = (inviteId?: string | number): NotificationPlatform | undefined => {
if (!inviteId) return undefined;
try {
const raw = localStorage.getItem(`pfm_invite_platform_${inviteId}`);
return raw !== null ? (Number(raw) as NotificationPlatform) : undefined;
} catch {
return undefined;
}
};
const storeInvitePlatform = (inviteId: string | number, platform: NotificationPlatform) => {
try {
localStorage.setItem(`pfm_invite_platform_${inviteId}`, String(platform));
} catch {}
};
const resolveUserEmail = (userIdOrUuid?: string | null): string => {
if (!userIdOrUuid) return '—';
const u = usersDirectory.find((usr) => String(usr.id).toLowerCase() === String(userIdOrUuid).toLowerCase());
const email = u?.email || getUserEmail(userIdOrUuid);
return email && email !== '—' ? email : String(userIdOrUuid);
};
const sendInviteMutation = useMutation({
mutationFn: (payload: InviteRequest) => sendInvite(payload),
onSuccess: (data, variables) => {
@@ -320,8 +399,10 @@ export const SettingsPage: React.FC = () => {
setInviteRecipientUuid('');
setInviteMessage('');
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
storeInvitePlatform(invId, variables.platform);
if (inviteAutoApprove) {
const invId = (data as any)?.id ? String((data as any).id) : `inv_${Date.now()}`;
markInviteAsApprovedInStorage(invId, currentTenantId);
setApprovedInviteIds((prev) => (prev.includes(invId) ? prev : [...prev, invId]));
@@ -333,6 +414,7 @@ export const SettingsPage: React.FC = () => {
organisationId: variables.organisationId,
awardId: variables.awardId,
contractId: variables.contractId,
projectId: variables.projectId,
message: variables.message,
},
}).then(() => {
@@ -418,6 +500,38 @@ export const SettingsPage: React.FC = () => {
handleSort: handleNotifSort,
} = useTableSort(filteredNotifications, 'createdAt', 'desc');
const resolveInvitePlatform = (inv: Invite): NotificationPlatform => {
if (inv.platform !== undefined && inv.platform !== null) {
return Number(inv.platform) as NotificationPlatform;
}
const stored = getStoredInvitePlatform(inv.id);
if (stored !== undefined) return stored;
const linkedNotif = fetchedNotifications.find(
(n) =>
(inv.recipient && n.recipient?.toLowerCase() === inv.recipient?.toLowerCase()) ||
(inv.invitedOrganisationId && String(n.organisationId) === String(inv.invitedOrganisationId))
);
if (linkedNotif?.platform !== undefined && linkedNotif.platform !== null) {
return Number(linkedNotif.platform) as NotificationPlatform;
}
return NotificationPlatform.Email;
};
const filteredInvites = useMemo(() => {
return invitesList.filter((inv) => {
const recipientEmail = resolveUserEmail(inv.recipient).toLowerCase();
const matchSearch =
!inviteSearchTerm.trim() ||
(inv.recipient && inv.recipient.toLowerCase().includes(inviteSearchTerm.toLowerCase())) ||
recipientEmail.includes(inviteSearchTerm.toLowerCase());
const resolvedPlatform = resolveInvitePlatform(inv);
const matchPlatform =
invitePlatformFilter === 'all' || Number(resolvedPlatform) === Number(invitePlatformFilter);
return matchSearch && matchPlatform;
});
}, [invitesList, inviteSearchTerm, invitePlatformFilter, fetchedNotifications, usersDirectory]);
// ==========================================
// 4. Audit Log Ledger State
// ==========================================
@@ -486,9 +600,22 @@ export const SettingsPage: React.FC = () => {
{
header: 'Changes / Arguments',
key: 'changes',
format: (val) => (Array.isArray(val) ? val.join('; ') : val || '—'),
format: (val) => {
if (!val) return '—';
let str = Array.isArray(val) ? val.join('; ') : typeof val === 'object' ? JSON.stringify(val) : String(val);
str = str.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
return str.length > 250 ? `${str.slice(0, 247)}...` : str;
},
},
{
header: 'Error Details',
key: 'error',
format: (val) => {
if (!val) return '—';
let str = String(val).replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
return str.length > 250 ? `${str.slice(0, 247)}...` : str;
},
},
{ header: 'Error Details', key: 'error', format: (val) => val || '—' },
];
const uniqueAuditEntities = Array.from(
@@ -558,13 +685,25 @@ export const SettingsPage: React.FC = () => {
];
const inviteExportColumns: ExportColumn<Invite>[] = [
{ header: 'Recipient (UUID)', key: 'recipient' },
{ header: 'Channel / Platform', key: 'platform', format: (val) => getNotificationPlatformLabel(val) },
{ header: 'Invitation Message', key: 'message' },
{
header: 'Recipient Email',
key: 'recipient',
format: (val) => resolveUserEmail(val),
},
{
header: 'Channel / Platform',
key: 'platform',
format: (_val, inv) => getNotificationPlatformLabel(resolveInvitePlatform(inv)),
},
{
header: 'Target Organisation / Grant',
key: 'invitedOrganisationId',
format: (val, inv) => `Org #${val || inv.organisationId || '—'} (Award #${inv.awardId || '—'})`,
format: (val, inv) => {
const orgId = val || inv.organisationId;
const org = organisations.find((o) => String(o.id) === String(orgId));
const orgName = org?.name || `Organisation #${orgId || '—'}`;
return `${orgName} • Award #${inv.awardId || '—'}`;
},
},
{ header: 'Date Dispatched', key: 'createdAt', format: (val) => (val ? formatDate(val) : '—') },
];
@@ -590,7 +729,15 @@ export const SettingsPage: React.FC = () => {
sortColumn: inviteSortCol,
sortDirection: inviteSortDir,
handleSort: handleInviteSort,
} = useTableSort(filteredInvites, 'createdAt', 'desc');
} = useTableSort(filteredInvites, 'createdAt', 'desc', {
recipient: (inv: Invite) => resolveUserEmail(inv.recipient),
platform: (inv: Invite) => getNotificationPlatformLabel(resolveInvitePlatform(inv)),
invitedOrganisationId: (inv: Invite) => {
const orgId = inv.invitedOrganisationId || inv.organisationId;
const org = organisations.find((o) => String(o.id) === String(orgId));
return org?.name || `Organisation #${orgId || ''}`;
},
});
const getActionBadge = (action?: string | null) => {
const act = (action || 'GENERAL_EVENT').toUpperCase();
@@ -1181,14 +1328,11 @@ export const SettingsPage: React.FC = () => {
<thead>
<tr className="bg-slate-900/60 border-b border-slate-800/80 text-slate-400 uppercase tracking-wider font-semibold">
<SortableTh column="recipient" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-5">
Recipient (UUID)
Recipient Email
</SortableTh>
<SortableTh column="platform" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
Channel
</SortableTh>
<SortableTh column="message" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
Message
</SortableTh>
<SortableTh column="invitedOrganisationId" currentColumn={inviteSortCol} direction={inviteSortDir} onSort={handleInviteSort} className="py-3.5 px-4">
Target Org / Grant
</SortableTh>
@@ -1206,13 +1350,13 @@ export const SettingsPage: React.FC = () => {
<tbody className="divide-y divide-slate-800/50">
{loadingInvites ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
<td colSpan={6} className="py-8 text-center text-slate-500">
Loading invitations ledger...
</td>
</tr>
) : sortedInvites.length === 0 ? (
<tr>
<td colSpan={7} className="py-8 text-center text-slate-500">
<td colSpan={6} className="py-8 text-center text-slate-500">
No invitations recorded yet. Use &ldquo;Dispatch Invitation&rdquo; to send one.
</td>
</tr>
@@ -1222,22 +1366,34 @@ export const SettingsPage: React.FC = () => {
approvedInviteIds.includes(String(inv.id)) ||
approvedInviteIds.includes(`inv_${idx}`) ||
approvedInviteIds.includes(String(idx));
const resolvedPlatform = resolveInvitePlatform(inv);
const userEmail = resolveUserEmail(inv.recipient);
const orgId = inv.invitedOrganisationId || inv.organisationId;
const org = organisations.find((o) => String(o.id) === String(orgId));
const orgName = org?.name || `Organisation #${orgId || '—'}`;
const isCreator = isMaker(inv.createdBy);
return (
<tr key={idx} className="hover:bg-slate-800/40 transition-colors">
<td className="py-3.5 px-5 font-mono text-slate-300 text-[11px]">
{inv.recipient}
<td className="py-3.5 px-5 text-slate-200 text-xs">
<div className="font-medium text-white flex items-center gap-1.5">
<Mail className="w-3.5 h-3.5 text-blue-400 shrink-0" />
<span className="truncate">{userEmail}</span>
</div>
<div className="font-mono text-slate-500 text-[10px] truncate max-w-xs mt-0.5">
{inv.recipient}
</div>
</td>
<td className="py-3.5 px-4">
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold bg-blue-500/10 text-blue-400 border border-blue-500/20">
{getNotificationPlatformLabel(inv.platform)}
{getNotificationPlatformLabel(resolvedPlatform)}
</span>
</td>
<td className="py-3.5 px-4 text-white font-medium max-w-xs truncate">
{inv.message}
</td>
<td className="py-3.5 px-4 text-slate-400 text-[11px]">
Org #{inv.invitedOrganisationId || inv.organisationId} &bull; Award #{inv.awardId}
<td className="py-3.5 px-4 text-slate-300 text-xs">
<div className="font-medium text-white">{orgName}</div>
<div className="text-slate-400 text-[11px] mt-0.5">
Award #{inv.awardId || '—'}
</div>
</td>
<td className="py-3.5 px-4">
{isApproved ? (
@@ -1261,7 +1417,7 @@ export const SettingsPage: React.FC = () => {
<Check className="w-3 h-3" />
Partner Notified
</span>
) : (
) : canApproveRecord('settings_invites', inv.createdBy) ? (
<button
type="button"
onClick={() => handleApproveInvite(inv, idx)}
@@ -1271,6 +1427,17 @@ export const SettingsPage: React.FC = () => {
<Check className="w-3 h-3" />
<span>Approve</span>
</button>
) : isCreator ? (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-semibold bg-amber-500/10 text-amber-400 border border-amber-500/20"
title="Maker-Checker: You created this invite and cannot approve it"
>
Maker (Creator)
</span>
) : (
<span className="text-[11px] text-slate-500">
Pending Review
</span>
)}
</td>
</tr>
@@ -2491,15 +2658,20 @@ export const SettingsPage: React.FC = () => {
<form
onSubmit={(e) => {
e.preventDefault();
if (!inviteAwardId) {
toastError('Policy Violation', 'An approved award with an approved contract must be selected.');
return;
}
if (!inviteRecipientUuid || !inviteMessage) {
toastError('Validation Error', 'Recipient UUID and message content are mandatory.');
toastError('Validation Error', 'Recipient identifier and message content are mandatory.');
return;
}
sendInviteMutation.mutate({
organisationId: inviteOrgId || (organisations[0]?.id ? Number(organisations[0].id) : 101),
invitedOrganisationId: invitedOrgId || (organisations[1]?.id ? Number(organisations[1].id) : 102),
awardId: inviteAwardId || (awards[0]?.id ? Number(awards[0].id) : 301),
contractId: inviteContractId || (contracts[0]?.id ? Number(contracts[0].id) : 401),
awardId: inviteAwardId,
contractId: inviteContractId,
projectId: inviteProjectId || 0,
recipient: inviteRecipientUuid,
message: inviteMessage,
platform: invitePlatform,
@@ -2507,18 +2679,51 @@ export const SettingsPage: React.FC = () => {
}}
className="space-y-4"
>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Recipient User UUID *
</label>
<HelpTooltip
title="Recipient UUID"
content="Unique directory user identifier or email for targeted delivery."
size="sm"
placement="right"
/>
{eligibleAwards.length === 0 ? (
<div className="p-3.5 rounded-xl bg-amber-500/10 border border-amber-500/30 text-amber-300 text-xs flex items-start gap-2.5">
<RotateCcw className="w-4 h-4 shrink-0 text-amber-400 mt-0.5" />
<div>
<span className="font-semibold block text-white">No Approved Awards Eligible for Invitation</span>
<span className="text-[11px] text-amber-300/90 leading-relaxed block mt-0.5">
System governance policy strictly requires that invitations may only be dispatched for awards that have been formally <strong>Approved</strong> and whose associated contract is <strong>Approved &amp; Active</strong>.
</span>
</div>
</div>
) : null}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<label className="block text-xs font-semibold text-slate-300">
Recipient User *
</label>
<HelpTooltip
title="Recipient Directory Identifier"
content="Select a registered recipient user from directory or enter their unique user UUID."
size="sm"
placement="right"
/>
</div>
{usersDirectory.length > 0 && (
<span className="text-[10px] text-slate-400">Quick-select from registered users</span>
)}
</div>
{usersDirectory.length > 0 && (
<select
value={usersDirectory.some((u) => String(u.id).toLowerCase() === inviteRecipientUuid.toLowerCase()) ? inviteRecipientUuid : ''}
onChange={(e) => {
if (e.target.value) setInviteRecipientUuid(e.target.value);
}}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white mb-1"
>
<option value="">-- Choose from Registered Users --</option>
{usersDirectory.map((u) => (
<option key={u.id} value={u.id}>
{u.name ? `${u.name} — ` : ''}{u.email} ({u.id})
</option>
))}
</select>
)}
<input
type="text"
value={inviteRecipientUuid}
@@ -2529,6 +2734,98 @@ export const SettingsPage: React.FC = () => {
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Approved Award *
</label>
<HelpTooltip
title="Approved Award Required"
content="Only awards with status Approved and an Active/Approved contract are eligible for invitations."
size="sm"
placement="top"
/>
</div>
<select
value={inviteAwardId}
onChange={(e) => handleSelectAward(e.target.value)}
disabled={eligibleAwards.length === 0}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white disabled:opacity-50"
required
>
{eligibleAwards.length === 0 ? (
<option value="">No approved awards available</option>
) : (
eligibleAwards.map((aw) => {
const recipientOrg = organisations.find((o) => String(o.id) === String(aw.organisationId));
return (
<option key={aw.id} value={aw.id}>
Award #{aw.id} — {recipientOrg?.name || `Org #${aw.organisationId}`}
</option>
);
})
)}
</select>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Approved Contract *
</label>
<HelpTooltip
title="Associated Approved Contract"
content="The active/approved contract linked to the selected award."
size="sm"
placement="top"
/>
</div>
<input
type="text"
value={inviteContractId ? `Contract #${inviteContractId} (Approved & Active)` : 'No active contract'}
readOnly
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 font-mono focus:outline-none cursor-not-allowed"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Target Organisation (Recipient Org)
</label>
</div>
<input
type="text"
value={(() => {
const o = organisations.find((org) => String(org.id) === String(invitedOrgId));
return o ? `${o.name} (#${o.id})` : invitedOrgId ? `Org #${invitedOrgId}` : '—';
})()}
readOnly
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed"
/>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Associated Project
</label>
</div>
<input
type="text"
value={(() => {
const prj = projects.find((p) => String(p.id) === String(inviteProjectId));
return prj ? `${prj.name || 'Project'} (#${prj.id})` : inviteProjectId ? `Project #${inviteProjectId}` : 'Initial Award Project';
})()}
readOnly
className="w-full px-3 py-2 text-xs bg-slate-900/50 border border-slate-800 rounded-xl text-slate-300 focus:outline-none cursor-not-allowed"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
@@ -2558,7 +2855,7 @@ export const SettingsPage: React.FC = () => {
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Originating Organisation
Originating Authority
</label>
<HelpTooltip
title="Inviting Authority"
@@ -2581,58 +2878,6 @@ export const SettingsPage: React.FC = () => {
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Associated Award
</label>
<HelpTooltip
title="Linked Grant Award"
content="Optional award attachment to link recipient permissions directly to grant administration."
size="sm"
placement="top"
/>
</div>
<select
value={inviteAwardId}
onChange={(e) => setInviteAwardId(e.target.value)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
{awards.map((aw) => (
<option key={aw.id} value={aw.id}>
Award #{aw.id}
</option>
))}
</select>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
Associated Contract
</label>
<HelpTooltip
title="Linked Contract"
content="Optional bilateral agreement linking the recipient to contract execution."
size="sm"
placement="top"
/>
</div>
<select
value={inviteContractId}
onChange={(e) => setInviteContractId(e.target.value)}
className="w-full px-3 py-2 text-xs bg-slate-900/80 border border-slate-700/80 rounded-xl text-white focus:outline-none focus:ring-2 focus:ring-blue-500/50 [&>option]:bg-slate-900 [&>option]:text-white"
>
{contracts.map((ctr) => (
<option key={ctr.id} value={ctr.id}>
Contract #{ctr.id}
</option>
))}
</select>
</div>
</div>
<div>
<div className="flex items-center gap-1 mb-1.5">
<label className="block text-xs font-semibold text-slate-300">
@@ -2681,7 +2926,7 @@ export const SettingsPage: React.FC = () => {
</button>
<button
type="submit"
disabled={sendInviteMutation.isPending}
disabled={sendInviteMutation.isPending || eligibleAwards.length === 0}
className="px-4 py-2 text-xs font-semibold text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 rounded-xl shadow-lg shadow-blue-500/20 transition disabled:opacity-50"
>
{sendInviteMutation.isPending ? 'Sending...' : 'Send Invitation'}
@@ -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
+51 -297
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) {
@@ -1644,6 +1514,7 @@ export const sendInvite = async (payload: InviteRequest): Promise<Invite> => {
invitedOrganisationId: toSafeInt64String(payload.invitedOrganisationId),
awardId: toSafeInt64String(payload.awardId),
contractId: toSafeInt64String(payload.contractId),
projectId: toSafeInt64String(payload.projectId || 0),
recipient: payload.recipient,
message: payload.message,
platform: payload.platform !== undefined ? Number(payload.platform) : 1,
@@ -1731,7 +1602,9 @@ export const createNotification = async (payload: CreateNotificationRequest): Pr
subject: payload.subject || null,
destination: payload.destination || 'In-App User Inbox',
message: payload.message,
status: payload.status !== undefined ? payload.status : undefined,
status: payload.status !== undefined
? payload.status
: (Number(payload.platform) === NotificationPlatform.InApp ? NotificationStatus.Delivered : undefined),
});
};
@@ -1753,6 +1626,13 @@ export const markNotificationAsRead = async (notificationId: number | string): P
};
// ==========================================
export const archiveNotification = async (notificationId: number | string): Promise<void> => {
await updateNotification({
notificationId: toIntegerId(notificationId),
status: NotificationStatus.Archived,
});
};
// 9. Governance: Tenants APIs
// ==========================================
export const getTenants = async (): Promise<Tenant[]> => {
@@ -2246,75 +2126,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 +2157,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 +2178,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;
}
@@ -29,6 +29,7 @@ export enum NotificationStatus {
Cancelled = 8,
Bounced = 9,
Draft = 10,
Archived = 11,
}
export enum NotificationPlatform {
@@ -685,11 +686,13 @@ export interface CreateContractRequest {
export interface UpdateContractRequest {
contractId: number | string;
projectId?: number | string;
status: ContractStatus;
}
export interface ExecuteContractRequest {
contractId: number | string;
projectId?: number | string;
externalSignatureId: string;
signedDocumentUrl: string;
}
@@ -838,6 +841,7 @@ export interface Invite {
invitedOrganisationId: number | string;
awardId: number | string;
contractId: number | string;
projectId?: number | string;
recipient: string;
createdBy: string;
updatedBy?: string | null;
@@ -852,6 +856,7 @@ export interface InviteRequest {
invitedOrganisationId: number | string;
awardId: number | string;
contractId: number | string;
projectId?: number | string;
recipient: string;
message: string;
platform: NotificationPlatform;
@@ -146,6 +146,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
} else {
formatted = rawVal ?? '';
}
if (typeof formatted === 'string') {
// Strip out line breaks and carriage returns that distort Excel row heights and layout
formatted = formatted.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
}
rowObj[col.header] = formatted;
});
return rowObj;
@@ -166,9 +170,10 @@ export async function exportToSpreadsheet<T>(options: ExportOptions<T>): Promise
} else {
formatted = String(rawVal ?? '');
}
formatted = formatted.replace(/\r\n|\r|\n/g, ' ').replace(/\s{2,}/g, ' ').trim();
return Math.max(max, formatted.length);
}, headerLen);
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 50) };
return { wch: Math.min(Math.max(maxDataLen + 4, 12), 45) };
});
worksheet['!cols'] = colWidths;
@@ -9,4 +9,6 @@ public sealed class SmtpSettings
public string? Host { get; set; }
public bool UseSsl { get; set; }
public string? ProcessorJobCron { get; set; }
}
@@ -33,8 +33,8 @@ public sealed class EvidenceConfiguration : IEntityTypeConfiguration<Evidence>
builder.Property(f => f.IndicatorId).IsRequired();
builder.Property(f => f.ActivityId).IsRequired();
builder.Property(f => f.CreatedBy).IsRequired();
builder.Property(f => f.UpdatedBy).IsRequired();
builder.Property(f => f.CreatedAt).IsRequired().HasDefaultValueSql("now()");
builder.Property(f => f.UpdatedBy).IsRequired(false);
builder.Property(f => f.UpdatedAt).IsRequired(false);
builder.Property(f => f.Version).IsRequired().HasDefaultValue(1);
builder.Property(f => f.DocumentUrl).IsRequired();
+1
View File
@@ -24,6 +24,7 @@ public enum NotificationStatus : int
Cancelled = 8,
Bounced = 9,
Draft = 10,
Archived = 11,
}
public enum NotificationPlatform : int
@@ -0,0 +1,37 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PostFundManagement.Infrastructure.Database.Migrations
{
/// <inheritdoc />
public partial class UpdatedByIsOptionalOnEvidence : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<Guid>(
name: "UpdatedBy",
table: "Evidences",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<Guid>(
name: "UpdatedBy",
table: "Evidences",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
}
}
}
@@ -604,7 +604,7 @@ namespace PostFundManagement.Infrastructure.Database.Migrations
b.Property<DateTime?>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UpdatedBy")
b.Property<Guid?>("UpdatedBy")
.HasColumnType("uuid");
b.Property<int>("Version")
@@ -0,0 +1,16 @@
using PostFundManagement.Domain.Abstractions;
namespace PostFundManagement.Infrastructure.Database.Services;
public sealed class HeadlessTenantContextService : ITenantContextService
{
public string? TenantName => "Ifri Technologies";
public long TenantId => 1;
public bool ByPassTenantQueryFilter { get; set; } = true;
public bool IsAdmin => true;
public bool HasTenantFilter => true;
}
+40
View File
@@ -0,0 +1,40 @@
# Stage 1: Runtime base
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
USER app
WORKDIR /app
EXPOSE 8080
# Stage 2: Build & Restore
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy props and csproj files for layer caching
COPY ["Directory.Build.props", "./"]
COPY ["Directory.Packages.props", "./"]
COPY ["PostFundManagement.snk", "./"]
COPY ["LICENSE", "./"]
COPY ["PostFundManagement.Jobs/PostFundManagement.Jobs.csproj", "PostFundManagement.Jobs/"]
COPY ["PostFundManagement.Application/PostFundManagement.Application.csproj", "PostFundManagement.Application/"]
COPY ["PostFundManagement.Domain/PostFundManagement.Domain.csproj", "PostFundManagement.Domain/"]
COPY ["PostFundManagement.Infrastructure/PostFundManagement.Infrastructure.csproj", "PostFundManagement.Infrastructure/"]
# Restore packages
RUN dotnet restore "PostFundManagement.Jobs/PostFundManagement.Jobs.csproj"
# Copy full source code
COPY . .
# Build from the project folder using relative filename
WORKDIR "/src/PostFundManagement.Jobs"
RUN dotnet build "PostFundManagement.Jobs.csproj" -c Release -o /app/build
# Stage 3: Publish
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "PostFundManagement.Jobs.csproj" -c Release -o /app/publish /p:UseAppHost=false
# Stage 4: Final Runtime
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "PostFundManagement.Jobs.dll"]
+21
View File
@@ -0,0 +1,21 @@
using PostFundManagement.Domain.Abstractions;
namespace PostFundManagement.Jobs;
public class JobsWorker(IJobOrchestrator jobOrchestrator, IFeatureManager featureManager,
ILogger<JobsWorker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if(!await featureManager.IsEnabledAsync(nameof(JobsWorker)))
{
logger.LogInformation("JobsWorker is disabled.");
return;
}
// TODO: scheduler reccuring jobs
logger.LogInformation("JobsWorker is stopping.");
}
}
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk.Worker">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>dotnet-PostFundManagement.Jobs-97b98845-fddb-4212-b877-8dac9f7f86e3</UserSecretsId>
</PropertyGroup>
<!-- File Nesting -->
<ItemGroup>
<ProjectCapability Include="ConfigurableFileNesting" />
<ProjectCapability Include="ConfigurableFileNestingFeatureEnabled" />
</ItemGroup>
<!-- CQRS Source Generator -->
<ItemGroup>
<PackageReference Include="Mediator.SourceGenerator">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PostFundManagement.Application\PostFundManagement.Application.csproj" />
</ItemGroup>
</Project>
+38
View File
@@ -0,0 +1,38 @@
using PostFundManagement.Application.Extensions;
using PostFundManagement.Domain.Abstractions;
using PostFundManagement.Domain.Mediator;
using PostFundManagement.Infrastructure.Database.Services;
using PostFundManagement.Jobs;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddFeatureManagement();
builder.Services.AddSharedServices();
builder.Services.AddHostedService<JobsWorker>();
builder.Services.AddRegisterServicesWithCaching(builder.Configuration);
builder.Services.AddEndpointAndServiceCaching(builder.Configuration);
builder.Services.AddTransient<ITenantContextService, HeadlessTenantContextService>();
builder.Services.AddMediator();
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(TelemetryPipelineBehavior<,>));
builder.Services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingPipelineBehavior<,>));
builder.Services.AddQuartzScheduler(DefaultSchedulerName, builder.Configuration);
builder.Services.AddEmailServices(builder.Configuration);
builder.Services.AddHashServices(builder.Configuration);
builder.Services.AddGarageS3(builder.Configuration);
builder.Services.AddHttpClient();
builder.Services.AddDataProtectionDatabase(builder.Configuration);
builder.Services.AddApplicationDbContext(builder.Configuration);
var host = builder.Build();
var schedulerFactory = host.Services.GetRequiredService<ISchedulerFactory>();
var scheduler = await schedulerFactory.GetScheduler();
if (scheduler!.Status != SchedulerStatus.Running)
await scheduler.Start();
host.Run();
@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"PostFundManagement.Jobs": {
"commandName": "Project",
"dotnetRunMessages": true,
"environmentVariables": {
"DOTNET_ENVIRONMENT": "Development"
}
}
}
}
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.Hosting.Lifetime": "Information"
}
}
}
+37
View File
@@ -0,0 +1,37 @@
{
"FeatureManagement": {
"JobsWorker": true,
"EmailProcessorJobEnabled": true,
"ServiceCaching": false
},
"GeneralS3Settings": {
"ServiceUrl": "https://s3.garage.khongisa.co.za",
"Region": "garage",
"BucketName": "pfm",
"CdnBaseUrl": "https://pfm.cdn.khongisa.co.za"
},
"Monitoring": {
"Address": "http://aspire-dashboard-service.aspire.svc.cluster.local:18889",
"ServiceName": "Pfm.Jobs.DEV"
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"Quartz": "Error",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database": "Error",
"Microsoft.EntityFrameworkCore.Update": "Error"
}
},
"Email": {
"ProcessorJobCron": "0 */5 * * * ?",
"SmtpServer": "smtp.example.com",
"SmtpPort": 587,
"SenderEmail": "noreply@example.com",
"SenderName": "Example"
},
"HasherSettings": {
"MinHashLength": 11
},
"AllowedHosts": "*"
}
+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,23 +1,33 @@
import React, { useState } from 'react';
import { api, extractApiErrors } from '../../services/api';
import { Activity, Milestone, Project, EvidenceType } from '../../services/api/models';
import {
X,
UploadCloud,
FileText,
CheckCircle2,
AlertTriangle,
Loader2,
Plus,
import React, { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useQuery } from '@tanstack/react-query';
import {
uploadEvidenceFile,
extractApiErrors,
toIntegerId,
getProjectIndicators,
recordIndicatorActual,
} from '../../services/api';
import { Activity, Milestone, Project, Indicator } from '../../services/api/models';
import { EVIDENCE_TYPE_OPTIONS } from '../../lib/formatters';
import {
X,
UploadCloud,
FileText,
CheckCircle2,
AlertTriangle,
Loader2,
Plus,
Sparkles,
Link as LinkIcon
Target,
BarChart3,
} from 'lucide-react';
interface EvidenceUploadModalProps {
isOpen: boolean;
onClose: () => void;
project: Project;
milestone?: Milestone;
milestone?: Milestone | null;
activity: Activity;
onEvidenceUploaded: () => void;
}
@@ -32,14 +42,45 @@ 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 [selectedIndicatorId, setSelectedIndicatorId] = useState<string>('');
const [actualAmount, setActualAmount] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessages, setErrorMessages] = useState<string[]>([]);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
if (!isOpen) return null;
const projectId = project?.id || 0;
// Requirement #4: Fetch linked indicators for project & activity
const { data: projectIndicators = [], isLoading: isLoadingIndicators } = useQuery<Indicator[]>({
queryKey: ['evidence-project-indicators', String(projectId)],
queryFn: () => getProjectIndicators(projectId),
enabled: isOpen && Boolean(projectId),
});
// Prioritize indicators directly linked to this activity, then project indicators
const availableIndicators = React.useMemo(() => {
if (!projectIndicators || projectIndicators.length === 0) return [];
const directMatches = projectIndicators.filter(
ind => ind.activityId && String(ind.activityId) === String(activity.id)
);
if (directMatches.length > 0) return directMatches;
return projectIndicators;
}, [projectIndicators, activity.id]);
// Auto-select first indicator if available
useEffect(() => {
if (availableIndicators.length > 0 && !selectedIndicatorId) {
const firstId = String(availableIndicators[0].id);
setSelectedIndicatorId(firstId);
if (availableIndicators[0].actualAmount !== undefined) {
setActualAmount(String(availableIndicators[0].actualAmount || ''));
}
}
}, [availableIndicators, selectedIndicatorId]);
if (!isOpen || typeof document === 'undefined') return null;
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
@@ -51,66 +92,94 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
}
};
const selectedIndicatorObj = availableIndicators.find(
i => String(i.id) === String(selectedIndicatorId)
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
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);
const numIndicatorId = toIntegerId(selectedIndicatorId);
// Requirement #4: IndicatorId must not be 0 to maintain referential integrity in database
if (!numIndicatorId || numIndicatorId <= 0) {
setErrorMessages([
'A valid linked Indicator is mandatory for evidence verification. Please select an indicator from the list.',
]);
return;
}
if (!actualAmount.trim() || isNaN(Number(actualAmount))) {
setErrorMessages([
'Recording an Actual amount/metric against the selected indicator is mandatory. Please provide a numeric value.',
]);
return;
}
if (!numProjectId || !numActivityId) {
setErrorMessages(['Cannot upload evidence: Missing projectId or activityId reference.']);
return;
}
setIsSubmitting(true);
try {
// Build upload payload
// 1. Build multipart/form-data adhering strictly to EvidenceService.CreateEvidenceAsync
// Requirement #7: When uploading, IForm does not need the userId field in it; API detects userId from JWT sub
const formData = new FormData();
if (selectedFile) {
formData.append('file', selectedFile);
}
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);
formData.append('file', selectedFile);
formData.append('projectId', String(numProjectId));
formData.append('activityId', String(numActivityId));
formData.append('milestoneId', String(numMilestoneId));
formData.append('indicatorId', String(numIndicatorId));
formData.append('type', String(Number(evidenceType) || 4));
formData.append('title', title.trim() || selectedFile.name);
if (description.trim()) {
formData.append('description', description.trim());
}
setSuccessMessage('Evidence successfully uploaded and linked to activity!');
// 2. Upload evidence file
await uploadEvidenceFile(formData);
// 3. Requirement #4: Record Actual against linked indicator
const parsedActual = Number(actualAmount);
if (!isNaN(parsedActual)) {
try {
await recordIndicatorActual(numIndicatorId, parsedActual);
} catch (indErr) {
console.warn('[Record Indicator Actual Warning]:', indErr);
}
}
setSuccessMessage('Evidence successfully uploaded and linked to activity with recorded indicator actual!');
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 verify parameters and try again.']
);
} finally {
setIsSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200">
<div className="relative w-full max-w-lg rounded-3xl border border-white/15 bg-slate-900/95 p-6 shadow-2xl backdrop-blur-2xl text-slate-100 overflow-hidden">
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
<div className="relative w-full max-w-lg rounded-3xl border border-white/15 bg-slate-900/95 p-6 shadow-2xl backdrop-blur-2xl text-slate-100 overflow-hidden my-auto">
{/* Ambient background glow */}
<div className="absolute top-0 right-0 -mr-16 -mt-16 w-60 h-60 bg-emerald-500/10 rounded-full blur-3xl pointer-events-none" />
@@ -124,6 +193,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 +216,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 +243,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,23 +266,88 @@ 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>
</div>
</div>
{/* Requirement #4: Mandatory Indicator Selection & Actual Amount Recording */}
<div className="p-3.5 rounded-2xl bg-slate-950/80 border border-emerald-500/30 space-y-3">
<div className="flex items-center gap-2 text-emerald-400 font-semibold text-xs">
<Target className="w-4 h-4" />
<span>Linked Indicator & Performance Actual (Mandatory)</span>
</div>
<div>
<label className="block text-slate-300 font-medium mb-1">
Select Linked Indicator <span className="text-emerald-400">*</span>
</label>
{isLoadingIndicators ? (
<div className="text-[11px] text-slate-400 py-1">Loading indicators...</div>
) : availableIndicators.length === 0 ? (
<div className="text-[11px] text-amber-300 bg-amber-950/40 border border-amber-500/30 p-2.5 rounded-xl">
No indicators currently recorded for this project. Please contact programme management or select standard output.
</div>
) : (
<select
required
value={selectedIndicatorId}
onChange={e => setSelectedIndicatorId(e.target.value)}
className="w-full px-3 py-2 rounded-xl bg-slate-900 border border-white/10 text-slate-100 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
>
<option value="">-- Choose Indicator for this Deliverable --</option>
{availableIndicators.map(ind => (
<option key={ind.id} value={ind.id} className="bg-slate-900 text-slate-100">
{ind.name || `Indicator #${ind.id}`} (Target: {ind.targetAmount} • Baseline: {ind.baselineAmount})
</option>
))}
</select>
)}
</div>
<div>
<label className="block text-slate-300 font-medium mb-1">
Actual Amount / Metric Recorded <span className="text-emerald-400">*</span>
</label>
<div className="relative">
<input
type="number"
step="any"
min="0"
required
placeholder={
selectedIndicatorObj
? `Target is ${selectedIndicatorObj.targetAmount}`
: 'e.g. 10 or 1500'
}
value={actualAmount}
onChange={e => setActualAmount(e.target.value)}
className="w-full px-3.5 py-2 rounded-xl bg-slate-900 border border-white/10 text-slate-100 placeholder:text-slate-500 focus:outline-none focus:border-emerald-500/50"
/>
<BarChart3 className="w-4 h-4 text-emerald-400 absolute right-3 top-1/2 -translate-y-1/2 pointer-events-none" />
</div>
{selectedIndicatorObj && (
<p className="text-[11px] text-slate-400 mt-1">
Baseline: {selectedIndicatorObj.baselineAmount} • Target: {selectedIndicatorObj.targetAmount} • Current Actual: {selectedIndicatorObj.actualAmount}
</p>
)}
</div>
</div>
{/* Evidence Title & Type */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<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)}
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"
/>
</div>
@@ -217,42 +355,26 @@ 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..."
value={description}
onChange={(e) => setDescription(e.target.value)}
onChange={e => setDescription(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 resize-none"
/>
</div>
@@ -280,13 +402,14 @@ export const EvidenceUploadModal: React.FC<EvidenceUploadModalProps> = ({
) : (
<>
<Plus className="w-4 h-4" />
<span>Upload & Attach Evidence</span>
<span>Upload & Record Actual</span>
</>
)}
</button>
</div>
</form>
</div>
</div>
</div>,
document.body
);
};
@@ -0,0 +1,483 @@
import React, { useState, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { useQuery } from '@tanstack/react-query';
import {
X,
ShieldAlert,
ShieldCheck,
AlertTriangle,
Scale,
FileCheck,
Info,
Calendar,
User,
Users,
CheckCircle2,
Clock,
Layers,
Sparkles,
Filter,
MapPin,
Target,
} from 'lucide-react';
import {
Project,
Milestone,
Activity,
ComplianceItem,
Risk,
Rule,
Beneficiary,
ComplianceStatus,
} from '../../services/api/models';
import {
getProjectComplianceItems,
getProjectBeneficiaries,
getProjectRisks,
getRules,
} from '../../services/api';
import { formatDate } from '../../lib/formatters';
interface GovernanceInfoModalProps {
isOpen: boolean;
onClose: () => void;
project: Project;
milestone?: Milestone | null;
activity?: Activity | null;
initialTab?: 'compliance' | 'beneficiaries' | 'risks' | 'rules';
}
export const GovernanceInfoModal: React.FC<GovernanceInfoModalProps> = ({
isOpen,
onClose,
project,
milestone,
activity,
initialTab = 'compliance',
}) => {
const [activeTab, setActiveTab] = useState<'compliance' | 'beneficiaries' | 'risks' | 'rules'>(initialTab);
const [selectedBeneficiaryFilter, setSelectedBeneficiaryFilter] = useState<string>('ALL');
const projectId = project?.id || 0;
// 1. Fetch compliance items for project
const { data: complianceItems = [], isLoading: isLoadingCompliance } = useQuery<ComplianceItem[]>({
queryKey: ['governance-compliance', String(projectId)],
queryFn: () => getProjectComplianceItems(projectId),
enabled: isOpen && Boolean(projectId),
});
// 2. Fetch beneficiaries for project (Requirement: explore compliance items by beneficiary)
const { data: projectBeneficiaries = [], isLoading: isLoadingBeneficiaries } = useQuery<Beneficiary[]>({
queryKey: ['governance-beneficiaries', String(projectId)],
queryFn: () => getProjectBeneficiaries(projectId),
enabled: isOpen && Boolean(projectId),
});
// 3. Fetch risks for project
const { data: projectRisks = [], isLoading: isLoadingRisks } = useQuery<Risk[]>({
queryKey: ['governance-risks', String(projectId)],
queryFn: () => getProjectRisks(projectId),
enabled: isOpen && Boolean(projectId),
});
// 4. Fetch associated governance rules
const { data: governanceRules = [], isLoading: isLoadingRules } = useQuery<Rule[]>({
queryKey: ['governance-rules'],
queryFn: () => getRules({ page: 1, pageSize: 100 }),
enabled: isOpen,
});
// Map of beneficiary by ID for fast lookup
const beneficiaryMap = useMemo(() => {
const map = new Map<string, Beneficiary>();
for (const b of projectBeneficiaries) {
if (b && b.id !== undefined && b.id !== null) {
map.set(String(b.id), b);
}
}
return map;
}, [projectBeneficiaries]);
// Filter compliance items by selected beneficiary
const filteredComplianceItems = useMemo(() => {
if (selectedBeneficiaryFilter === 'ALL') {
return complianceItems;
}
if (selectedBeneficiaryFilter === 'PROJECT_WIDE') {
return complianceItems.filter(
item => !item.beneficiaryId || Number(item.beneficiaryId) === 0
);
}
return complianceItems.filter(
item => String(item.beneficiaryId) === selectedBeneficiaryFilter
);
}, [complianceItems, selectedBeneficiaryFilter]);
if (!isOpen || typeof document === 'undefined') return null;
const renderComplianceStatus = (status?: number) => {
const s = Number(status);
if (s === ComplianceStatus.Met || s === ComplianceStatus.Exceeded) {
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
<CheckCircle2 className="w-3 h-3 text-emerald-400" />
<span>{s === ComplianceStatus.Exceeded ? 'Exceeded' : 'Compliant'}</span>
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-[10px] font-bold bg-rose-500/15 text-rose-300 border border-rose-500/30">
<AlertTriangle className="w-3 h-3 text-rose-400" />
<span>Action Required (Not Met)</span>
</span>
);
};
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
<div className="relative w-full max-w-4xl rounded-3xl border border-white/10 bg-slate-900 shadow-2xl p-6 sm:p-8 space-y-6 my-auto text-slate-100 max-h-[90vh] flex flex-col">
{/* Modal Header */}
<div className="flex items-start justify-between border-b border-white/[0.08] pb-4 shrink-0">
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono font-bold bg-emerald-500/15 text-emerald-300 border border-emerald-500/30">
<ShieldAlert className="w-3 h-3 text-emerald-400" />
<span>Governance & Compliance Guidance</span>
</span>
{activity ? (
<span className="text-[11px] text-slate-400 font-mono">Activity: {activity.title}</span>
) : milestone ? (
<span className="text-[11px] text-slate-400 font-mono">Milestone: {milestone.name}</span>
) : (
<span className="text-[11px] text-slate-400 font-mono">Project: {project?.name || (project as any)?.title || `#${project?.id || 'PFM'}`}</span>
)}
</div>
<h3 className="text-lg sm:text-xl font-bold text-white tracking-tight">
Project Compliance Items, Beneficiaries & Associated Rules
</h3>
<p className="text-xs text-slate-400">
Contractual obligations, beneficiary-linked compliance criteria, risk registers, and institutional rules.
</p>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors cursor-pointer shrink-0"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Tab Navigation */}
<div className="flex items-center gap-2 border-b border-white/[0.08] pb-3 shrink-0 flex-wrap">
<button
type="button"
onClick={() => setActiveTab('compliance')}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
activeTab === 'compliance'
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40 shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
}`}
>
<FileCheck className="w-4 h-4" />
<span>Compliance Items ({complianceItems.length})</span>
</button>
<button
type="button"
onClick={() => setActiveTab('beneficiaries')}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
activeTab === 'beneficiaries'
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/40 shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
}`}
>
<Users className="w-4 h-4" />
<span>Beneficiaries ({projectBeneficiaries.length})</span>
</button>
<button
type="button"
onClick={() => setActiveTab('risks')}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
activeTab === 'risks'
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40 shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
}`}
>
<AlertTriangle className="w-4 h-4" />
<span>Risk Register ({projectRisks.length})</span>
</button>
<button
type="button"
onClick={() => setActiveTab('rules')}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
activeTab === 'rules'
? 'bg-teal-500/20 text-teal-300 border border-teal-500/40 shadow-sm'
: 'text-slate-400 hover:text-white hover:bg-white/[0.04]'
}`}
>
<Scale className="w-4 h-4" />
<span>Associated Rules ({governanceRules.length})</span>
</button>
</div>
{/* Tab Content Panels */}
<div className="flex-1 overflow-y-auto space-y-4 pr-1 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]">
{/* TAB 1: COMPLIANCE ITEMS (With Beneficiary Explorer) */}
{activeTab === 'compliance' && (
<div className="space-y-4">
{/* Beneficiary Filter Bar */}
<div className="p-3.5 rounded-2xl bg-slate-950/70 border border-white/[0.08] flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div className="flex items-center gap-2 text-xs text-slate-300">
<Filter className="w-4 h-4 text-emerald-400 shrink-0" />
<span className="font-semibold">Explore by Beneficiary:</span>
</div>
<div className="flex items-center gap-2">
<select
value={selectedBeneficiaryFilter}
onChange={e => setSelectedBeneficiaryFilter(e.target.value)}
className="px-3 py-1.5 rounded-xl bg-slate-900 border border-white/10 text-xs text-slate-200 focus:outline-none focus:border-emerald-500/50 cursor-pointer"
>
<option value="ALL">All Compliance Items ({complianceItems.length})</option>
<option value="PROJECT_WIDE">
Project-Wide General Items (
{
complianceItems.filter(
i => !i.beneficiaryId || Number(i.beneficiaryId) === 0
).length
}
)
</option>
{projectBeneficiaries.map(b => {
const count = complianceItems.filter(
i => String(i.beneficiaryId) === String(b.id)
).length;
return (
<option key={b.id} value={String(b.id)}>
Beneficiary: {b.name || `Beneficiary #${b.id}`} ({count} items)
</option>
);
})}
</select>
</div>
</div>
{isLoadingCompliance ? (
<div className="p-8 text-center text-xs text-slate-400">Loading compliance requirements...</div>
) : filteredComplianceItems.length === 0 ? (
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
<FileCheck className="w-8 h-8 text-slate-500 mx-auto" />
<p>No compliance items found matching this beneficiary filter.</p>
<p className="text-[11px] text-slate-500">
Switch to &quot;All Compliance Items&quot; to inspect project-wide statutory deliverables.
</p>
</div>
) : (
filteredComplianceItems.map((item, idx) => {
const linkedBeneficiary = item.beneficiaryId
? beneficiaryMap.get(String(item.beneficiaryId))
: null;
return (
<div
key={item.id || idx}
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2.5"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm text-slate-100">
{item.title || `Compliance Item #${idx + 1}`}
</span>
{renderComplianceStatus(item.status)}
{linkedBeneficiary ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono bg-cyan-950/80 border border-cyan-500/30 text-cyan-300">
<Users className="w-3 h-3 text-cyan-400" />
<span>Beneficiary: {linkedBeneficiary.name}</span>
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-md text-[10px] font-mono bg-slate-900 border border-white/10 text-slate-400">
<span>Project-Wide General</span>
</span>
)}
</div>
<div className="text-[11px] text-slate-400 flex items-center gap-1 font-mono">
<Calendar className="w-3.5 h-3.5 text-slate-500" />
<span>Created: {formatDate(item.createdAt)}</span>
</div>
</div>
{item.requirements && (
<p className="text-xs text-slate-300 leading-relaxed">{item.requirements}</p>
)}
</div>
);
})
)}
</div>
)}
{/* TAB 2: BENEFICIARIES LISTING & EXPLORATION */}
{activeTab === 'beneficiaries' && (
<div className="space-y-3">
{isLoadingBeneficiaries ? (
<div className="p-8 text-center text-xs text-slate-400">Loading project beneficiaries...</div>
) : projectBeneficiaries.length === 0 ? (
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
<Users className="w-8 h-8 text-slate-500 mx-auto" />
<p>No individual beneficiary cohorts registered for this project.</p>
<p className="text-[11px] text-slate-500">
Beneficiaries are captured during project execution and onboarding.
</p>
</div>
) : (
projectBeneficiaries.map(ben => {
const linkedItems = complianceItems.filter(
ci => String(ci.beneficiaryId) === String(ben.id)
);
return (
<div
key={ben.id}
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-3"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<div className="flex items-center gap-2.5 flex-wrap">
<div className="p-2 rounded-xl bg-cyan-500/10 text-cyan-400">
<Users className="w-4 h-4" />
</div>
<div>
<h4 className="text-sm font-bold text-slate-100">{ben.name || 'Unnamed Beneficiary'}</h4>
<p className="text-[11px] text-slate-400 font-mono">
Category: {ben.category || 'Direct Participant'}
{ben.location && ` • Location: ${ben.location}`}
</p>
</div>
</div>
<div className="flex items-center gap-3 text-xs">
<div className="text-right">
<span className="text-[10px] text-slate-500 block uppercase font-bold">Target / Reached</span>
<span className="text-emerald-400 font-mono font-semibold">
{ben.reachedCount ?? 0} / {ben.targetCount ?? 0}
</span>
</div>
<button
type="button"
onClick={() => {
setSelectedBeneficiaryFilter(String(ben.id));
setActiveTab('compliance');
}}
className="px-3 py-1.5 rounded-xl bg-cyan-500/15 hover:bg-cyan-500/25 border border-cyan-500/30 text-cyan-300 text-[11px] font-semibold transition-colors cursor-pointer"
>
Explore Compliance ({linkedItems.length})
</button>
</div>
</div>
</div>
);
})
)}
</div>
)}
{/* TAB 3: RISK REGISTER */}
{activeTab === 'risks' && (
<div className="space-y-3">
{isLoadingRisks ? (
<div className="p-8 text-center text-xs text-slate-400">Loading risk register...</div>
) : projectRisks.length === 0 ? (
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
<ShieldCheck className="w-8 h-8 text-emerald-500 mx-auto" />
<p>No high-severity risks recorded for this project.</p>
<p className="text-[11px] text-slate-500">
Maintain milestone delivery cadence to preserve low-risk standing.
</p>
</div>
) : (
projectRisks.map((risk, idx) => (
<div
key={risk.id || idx}
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2.5"
>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h4 className="font-semibold text-sm text-slate-100">
{risk.title || `Risk Item #${idx + 1}`}
</h4>
<div className="flex items-center gap-2 text-[10px] font-mono">
<span className="px-2 py-0.5 rounded-md bg-amber-500/15 text-amber-300 border border-amber-500/30">
Likelihood: {risk.likelihood ?? 'Medium'}
</span>
<span className="px-2 py-0.5 rounded-md bg-rose-500/15 text-rose-300 border border-rose-500/30">
Impact: {risk.impact ?? 'Moderate'}
</span>
</div>
</div>
{risk.description && (
<p className="text-xs text-slate-300 leading-relaxed">{risk.description}</p>
)}
{risk.mitigation && (
<div className="text-[11px] text-teal-300 bg-teal-950/40 border border-teal-500/20 p-2.5 rounded-xl">
<strong>Mitigation Strategy:</strong> {risk.mitigation}
</div>
)}
</div>
))
)}
</div>
)}
{/* TAB 4: ASSOCIATED GOVERNANCE RULES */}
{activeTab === 'rules' && (
<div className="space-y-3">
{isLoadingRules ? (
<div className="p-8 text-center text-xs text-slate-400">Loading institutional rules...</div>
) : governanceRules.length === 0 ? (
<div className="p-8 rounded-2xl bg-slate-950/60 border border-white/5 text-center text-xs text-slate-400 space-y-2">
<Scale className="w-8 h-8 text-slate-500 mx-auto" />
<p>Standard PFM Funding Portfolio Governance Rules apply.</p>
<p className="text-[11px] text-slate-500">
All disbursements gate against verified milestone deliverables and evidence.
</p>
</div>
) : (
governanceRules.map((rule, idx) => (
<div
key={rule.id || idx}
className="p-4 rounded-2xl bg-slate-950/70 border border-white/[0.08] space-y-2"
>
<div className="flex items-center justify-between gap-2">
<h4 className="font-semibold text-sm text-slate-100">
{rule.name || `Rule #${idx + 1}`}
</h4>
<span className="text-[10px] font-mono px-2 py-0.5 rounded-md bg-slate-800 text-slate-300 border border-white/10">
v{rule.version || 1}
</span>
</div>
{rule.description && (
<p className="text-xs text-slate-300 leading-relaxed">{rule.description}</p>
)}
</div>
))
)}
</div>
)}
</div>
{/* Modal Footer */}
<div className="flex items-center justify-between pt-3 border-t border-white/[0.08] shrink-0">
<span className="text-[11px] text-slate-500 font-mono">
PFM Governance Architecture • Beneficiary & Compliance Tracking
</span>
<button
type="button"
onClick={onClose}
className="px-5 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-200 text-xs font-semibold transition-colors cursor-pointer"
>
Close
</button>
</div>
</div>
</div>,
document.body
);
};
File diff suppressed because it is too large Load Diff
@@ -1,37 +1,32 @@
import React, { useState, useMemo } from 'react';
import {
Copy,
Check,
Mail,
MessageSquare,
Code2,
Eye,
ShieldCheck,
} from 'lucide-react';
import { Copy, Check } from 'lucide-react';
import { NotificationPlatform } from '../../types';
import { parseMarkdownToHtml, isHtmlContent, stripMarkdown } from '../../lib/markdownUtils';
export interface MessageViewerProps {
content: string;
content?: string;
message?: string;
platform?: NotificationPlatform | number | string;
subject?: string;
className?: string;
showFormatHeader?: boolean;
maxHeightClass?: string;
variant?: string;
createdDate?: string;
}
export const MessageViewer: React.FC<MessageViewerProps> = ({
content,
message,
platform,
subject,
className = '',
showFormatHeader = true,
maxHeightClass = 'max-h-[60vh]',
maxHeightClass = 'max-h-[65vh]',
}) => {
const rawContent = (content !== undefined && content !== null ? content : message) ?? '';
const [copied, setCopied] = useState(false);
const [showRaw, setShowRaw] = useState(false);
// Determine whether this message represents an Email (HTML) or In-App (Markup)
// Determine whether this message represents an Email (HTML) or In-App (Markdown)
const isEmail = useMemo(() => {
if (
platform === NotificationPlatform.Email ||
@@ -41,148 +36,81 @@ export const MessageViewer: React.FC<MessageViewerProps> = ({
) {
return true;
}
// If platform is not explicitly InApp, detect if content is structured HTML
if (
platform !== NotificationPlatform.InApp &&
platform !== 5 &&
platform !== '5' &&
String(platform).toLowerCase() !== 'inapp' &&
isHtmlContent(content)
isHtmlContent(rawContent)
) {
return true;
}
return false;
}, [platform, content]);
}, [platform, rawContent]);
const isInApp = useMemo(() => {
if (
platform === NotificationPlatform.InApp ||
platform === 5 ||
platform === '5' ||
String(platform).toLowerCase() === 'inapp'
) {
return true;
}
return !isEmail;
}, [platform, isEmail]);
// Convert the message to its respective format:
// - Email: HTML rendered
// - In-App: Markup (Markdown) parsed to HTML
// Requirement #1: In-App message contents encoded as markdown, rendered for user; Email in HTML
// Requirement #8: User is none the wiser of raw form or format in any Messages UI
const renderedHtml = useMemo(() => {
if (!content) return '<p class="text-slate-400 italic">No message content</p>';
// Format 1: Email => HTML
if (isEmail) {
if (isHtmlContent(content)) {
return content.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
}
return parseMarkdownToHtml(content);
if (!rawContent || !rawContent.trim()) {
return '<p class="text-slate-400 italic">No message content</p>';
}
// Format 2: In-App => Markup (Markdown)
return parseMarkdownToHtml(content);
}, [content, isEmail]);
if (isEmail) {
if (isHtmlContent(rawContent)) {
return rawContent.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
}
return parseMarkdownToHtml(rawContent);
}
// In-App: parsed from Markdown to styled HTML
return parseMarkdownToHtml(rawContent);
}, [rawContent, isEmail]);
const handleCopy = () => {
const textToCopy = showRaw ? content : stripMarkdown(content) || content;
const textToCopy = stripMarkdown(rawContent) || rawContent;
navigator.clipboard.writeText(textToCopy);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className={`flex flex-col rounded-2xl bg-slate-900/50 border border-white/10 overflow-hidden w-full max-w-full ${className}`}>
{/* Format Header & Controls Bar */}
{showFormatHeader && (
<div className="px-4 py-2.5 border-b border-white/[0.08] bg-[#0b101d]/90 flex items-center justify-between text-xs text-slate-400 gap-3 shrink-0">
<div className="flex items-center gap-2 flex-wrap">
{isEmail ? (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-cyan-500/10 border border-cyan-500/20 text-cyan-300 font-semibold text-[11px]">
<Mail className="w-3.5 h-3.5 text-cyan-400" />
<span>Email (HTML)</span>
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-emerald-300 font-semibold text-[11px]">
<MessageSquare className="w-3.5 h-3.5 text-emerald-400" />
<span>In-App (Markup)</span>
</span>
)}
<div className={`flex flex-col rounded-2xl bg-slate-900/60 border border-white/10 overflow-hidden w-full max-w-full ${className}`}>
{/* Top action toolbar - clean without technical format metadata */}
<div className="px-5 py-3 border-b border-white/[0.08] bg-[#0b101d]/90 flex items-center justify-between text-xs text-slate-400 gap-3 shrink-0">
<span className="text-xs font-semibold text-slate-200 truncate">
{subject || 'Message Content'}
</span>
<span className="text-[11px] text-slate-400 hidden sm:inline">
{isEmail ? 'Rendered as HTML Email' : 'Converted from Markup to Formatted View'}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
{/* View Mode Toggle: Formatted vs Raw */}
<button
type="button"
onClick={() => setShowRaw(!showRaw)}
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-white/10"
title={showRaw ? 'Switch to Formatted view' : 'View Raw message content'}
>
{showRaw ? (
<>
<Eye className="w-3 h-3 text-emerald-400" />
<span>Formatted</span>
</>
) : (
<>
<Code2 className="w-3 h-3" />
<span>Raw</span>
</>
)}
</button>
{/* Copy Button */}
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 px-2 py-1 rounded-lg bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white text-[11px] font-medium transition cursor-pointer border border-white/10"
title="Copy message content"
>
{copied ? (
<>
<Check className="w-3 h-3 text-emerald-400" />
<span className="text-emerald-300">Copied</span>
</>
) : (
<>
<Copy className="w-3 h-3" />
<span>Copy</span>
</>
)}
</button>
</div>
</div>
)}
{/* Message Body Content - with explicit max height, max width, word wrapping, and automatic scrollbars */}
<div className={`w-full max-w-full p-4 sm:p-6 overflow-x-auto overflow-y-auto ${maxHeightClass} text-sm leading-relaxed text-slate-200 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]`}>
{showRaw ? (
<pre className="font-mono text-xs text-slate-300 whitespace-pre-wrap break-words [word-break:break-word] bg-[#080d18] p-4 rounded-xl border border-white/5 max-w-full overflow-x-auto [scrollbar-width:thin] [scrollbar-color:#334155_transparent]">
{content}
</pre>
) : (
<div
className="prose prose-invert max-w-full w-full break-words [word-break:break-word] text-xs sm:text-sm leading-relaxed text-slate-200 [&_h1]:text-white [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-white [&_h2]:text-lg [&_h2]:font-bold [&_h2]:mb-2 [&_h3]:text-white [&_h3]:text-base [&_h3]:font-bold [&_h3]:mb-2 [&_h4]:text-white [&_h4]:font-semibold [&_a]:text-emerald-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_li]:text-slate-300 [&_table]:w-full [&_table]:max-w-full [&_table]:border-collapse [&_table]:my-3 [&_th]:border [&_th]:border-slate-700 [&_th]:bg-slate-800/80 [&_th]:p-2.5 [&_th]:break-words [&_td]:border [&_td]:border-slate-800 [&_td]:p-2.5 [&_td]:break-words [&_blockquote]:border-l-4 [&_blockquote]:border-emerald-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-2 [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-emerald-300 [&_code]:break-all [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_img]:max-w-full [&_img]:h-auto [&_iframe]:max-w-full"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
)}
{/* Copy Clean Text */}
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-xl bg-slate-800/90 hover:bg-slate-700 text-slate-300 hover:text-white text-xs font-medium transition cursor-pointer border border-white/10"
title="Copy message content"
>
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-emerald-400" />
<span className="text-emerald-300">Copied</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5 text-slate-400" />
<span>Copy</span>
</>
)}
</button>
</div>
{/* Footer Info Stamp */}
<div className="px-5 py-2.5 border-t border-white/[0.08] bg-[#090e1a] flex items-center justify-between text-[11px] text-slate-400 shrink-0">
<div className="flex items-center gap-1.5">
<ShieldCheck className="w-3.5 h-3.5 text-emerald-400" />
<span>PFM Communications • {isEmail ? 'Email HTML Format' : 'In-App Markup Format'}</span>
</div>
<span className="font-mono text-[10px] text-slate-400">
{stripMarkdown(content).length} characters
</span>
{/* Rendered Body - Prose & Markdown styling */}
<div
className={`w-full max-w-full p-5 sm:p-7 overflow-x-auto overflow-y-auto ${maxHeightClass} text-sm leading-relaxed text-slate-200 [scrollbar-width:thin] [scrollbar-color:#334155_transparent]`}
>
<div
className="prose prose-invert max-w-full w-full break-words [word-break:break-word] text-xs sm:text-sm leading-relaxed text-slate-200 [&_h1]:text-white [&_h1]:text-xl [&_h1]:font-bold [&_h1]:mb-3 [&_h2]:text-white [&_h2]:text-lg [&_h2]:font-bold [&_h2]:mb-2 [&_h3]:text-white [&_h3]:text-base [&_h3]:font-bold [&_h3]:mb-2 [&_h4]:text-white [&_h4]:font-semibold [&_a]:text-emerald-400 [&_a:hover]:underline [&_ul]:list-disc [&_ul]:pl-5 [&_ul]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_ol]:my-2 [&_li]:text-slate-300 [&_table]:w-full [&_table]:max-w-full [&_table]:border-collapse [&_table]:my-3 [&_th]:border [&_th]:border-slate-700 [&_th]:bg-slate-800/80 [&_th]:p-2.5 [&_th]:break-words [&_td]:border [&_td]:border-slate-800 [&_td]:p-2.5 [&_td]:break-words [&_blockquote]:border-l-4 [&_blockquote]:border-emerald-500 [&_blockquote]:pl-4 [&_blockquote]:italic [&_blockquote]:my-2 [&_code]:bg-slate-800 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-emerald-300 [&_code]:break-all [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_img]:max-w-full [&_img]:h-auto [&_iframe]:max-w-full"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
</div>
</div>
);
};
@@ -0,0 +1,287 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import {
X,
Send,
Bold,
Italic,
Underline,
Heading2,
List,
ListOrdered,
Quote,
Minus,
MessageSquare,
Sparkles,
Eraser,
} from 'lucide-react';
import { htmlToMarkdown } from '../../lib/markdownUtils';
interface PartnerMessageComposerModalProps {
isOpen: boolean;
onClose: () => void;
recipientEmail: string;
recipientName?: string;
organisationName?: string;
initialSubject?: string;
onSend: (subject: string, markdownMessage: string) => Promise<void>;
isSending?: boolean;
}
export const PartnerMessageComposerModal: React.FC<PartnerMessageComposerModalProps> = ({
isOpen,
onClose,
recipientEmail,
recipientName,
organisationName,
initialSubject = '',
onSend,
isSending = false,
}) => {
const [subject, setSubject] = useState(initialSubject);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const editorRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setSubject(initialSubject);
setErrorMessage(null);
}, [initialSubject, isOpen]);
if (!isOpen || typeof document === 'undefined') return null;
const applyFormat = (command: string, value: string | undefined = undefined) => {
const editor = editorRef.current;
if (!editor) return;
editor.focus();
document.execCommand(command, false, value);
};
const handleClearFormat = () => {
applyFormat('removeFormat');
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setErrorMessage(null);
if (!subject.trim()) {
setErrorMessage('Please enter a message subject.');
return;
}
const htmlContent = editorRef.current ? editorRef.current.innerHTML : '';
const textContent = editorRef.current ? editorRef.current.innerText.trim() : '';
if (!textContent && !htmlContent.trim()) {
setErrorMessage('Please write your message in the composer space.');
return;
}
// Requirement #1: In-App messages are encoded as Markdown
// Requirement #8: User is none the wiser of raw format
const markdown = htmlToMarkdown(htmlContent);
try {
await onSend(subject.trim(), markdown);
onClose();
} catch (err: any) {
setErrorMessage(err?.message || 'Failed to dispatch message. Please try again.');
}
};
return createPortal(
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4 sm:p-6 bg-slate-950/85 backdrop-blur-md animate-in fade-in duration-200 overflow-y-auto">
<div className="relative w-full max-w-2xl rounded-3xl border border-white/10 bg-slate-900 shadow-2xl p-6 sm:p-7 space-y-5 my-auto text-slate-100">
{/* Modal Header */}
<div className="flex items-center justify-between border-b border-white/[0.08] pb-4">
<div className="flex items-center gap-3">
<div className="p-2.5 rounded-2xl bg-emerald-500/15 border border-emerald-500/30 text-emerald-400">
<MessageSquare className="w-5 h-5" />
</div>
<div>
<h3 className="text-base sm:text-lg font-bold text-white tracking-tight">
{initialSubject.startsWith('Re:') ? 'Reply Message' : 'Compose Message'}
</h3>
<p className="text-xs text-slate-400">
Direct transmission to your programme funding administration
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-xl text-slate-400 hover:text-white hover:bg-white/[0.06] transition-colors cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{errorMessage && (
<div className="p-3 rounded-xl bg-rose-500/15 border border-rose-500/30 text-rose-300 text-xs">
{errorMessage}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Recipient Field */}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
To (Recipient Support Email)
</label>
<input
type="text"
disabled
value={recipientEmail || 'support@ifri.khongisa.co.za'}
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs font-mono text-emerald-400 select-none"
/>
{(organisationName || recipientName) && (
<p className="text-[11px] text-slate-400 mt-1">
Organisation: <span className="text-slate-200">{organisationName || recipientName}</span>
</p>
)}
</div>
{/* Subject Field */}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">Subject</label>
<input
type="text"
required
value={subject}
onChange={e => setSubject(e.target.value)}
placeholder="e.g. Milestone 2 Deliverable Verification Enquiry"
className="w-full px-3.5 py-2.5 rounded-xl bg-slate-950 border border-white/10 text-xs text-white placeholder-slate-500 focus:outline-none focus:border-emerald-500/50"
/>
</div>
{/* WYSIWYG Message Composition Space (Requirement #2 & #8) */}
<div>
<label className="block text-xs font-semibold text-slate-300 mb-1">
Message Composition Space
</label>
<div className="rounded-2xl border border-white/10 bg-slate-950 overflow-hidden shadow-inner focus-within:border-emerald-500/50 transition">
{/* WYSIWYG Toolbar */}
<div className="px-3 py-2 border-b border-white/[0.08] bg-slate-900/80 flex flex-wrap items-center gap-1 text-slate-300">
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('bold')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Bold"
>
<Bold className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('italic')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Italic"
>
<Italic className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('underline')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Underline"
>
<Underline className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('formatBlock', 'H3')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Heading"
>
<Heading2 className="w-4 h-4" />
</button>
<span className="w-px h-4 bg-white/10 mx-1" />
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('insertUnorderedList')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Bullet List"
>
<List className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('insertOrderedList')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Numbered List"
>
<ListOrdered className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('formatBlock', 'BLOCKQUOTE')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Quote Block"
>
<Quote className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={() => applyFormat('insertHorizontalRule')}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer"
title="Horizontal Divider"
>
<Minus className="w-4 h-4" />
</button>
<button
type="button"
onMouseDown={e => e.preventDefault()}
onClick={handleClearFormat}
className="p-1.5 rounded-lg hover:text-white hover:bg-white/[0.08] transition cursor-pointer ml-auto"
title="Clear Formatting"
>
<Eraser className="w-4 h-4 text-slate-400" />
</button>
</div>
{/* Rich ContentEditable Area */}
<div
ref={editorRef}
contentEditable
className="w-full min-h-[160px] max-h-[300px] p-4 text-xs sm:text-sm text-slate-100 leading-relaxed overflow-y-auto focus:outline-none [scrollbar-width:thin] [scrollbar-color:#334155_transparent] prose prose-invert max-w-none [&_blockquote]:border-l-4 [&_blockquote]:border-emerald-500 [&_blockquote]:pl-3 [&_blockquote]:italic [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5"
data-placeholder="Write your message here..."
tabIndex={0}
/>
</div>
</div>
{/* Modal Actions */}
<div className="flex items-center justify-end gap-3 pt-3 border-t border-white/[0.08]">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 text-xs font-semibold transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={isSending}
className="px-5 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-xs flex items-center gap-2 shadow-lg shadow-emerald-500/20 transition-all cursor-pointer disabled:opacity-50"
>
<Send className="w-3.5 h-3.5" />
<span>{isSending ? 'Sending...' : 'Send Message'}</span>
</button>
</div>
</form>
</div>
</div>,
document.body
);
};
@@ -5,8 +5,11 @@ import {
getUserNotifications,
getNotifications,
createNotification,
updateNotification,
archiveNotification,
markNotificationAsRead,
getCurrentUserTenantId,
resolveUserIdByEmail,
} from '../services/api';
import {
Notification,
@@ -113,7 +116,6 @@ export function useInAppNotifications() {
return list || [];
},
refetchInterval: 30000,
staleTime: 10000,
});
const inAppNotifications = useMemo(() => {
@@ -124,7 +126,10 @@ export function useInAppNotifications() {
const inboxNotifications = useMemo(() => {
return inAppNotifications.filter(
n => !trashedIds.includes(String(n.id)) && Number(n.status) !== NotificationStatus.Draft
n =>
!trashedIds.includes(String(n.id)) &&
Number(n.status) !== NotificationStatus.Archived &&
Number(n.status) !== NotificationStatus.Draft
);
}, [inAppNotifications, trashedIds]);
@@ -151,7 +156,9 @@ export function useInAppNotifications() {
}, [inAppNotifications, starredIds, trashedIds]);
const trashedNotifications = useMemo(() => {
return inAppNotifications.filter(n => trashedIds.includes(String(n.id)));
return inAppNotifications.filter(
n => trashedIds.includes(String(n.id)) || Number(n.status) === NotificationStatus.Archived
);
}, [inAppNotifications, trashedIds]);
const markAsReadMutation = useMutation({
@@ -191,7 +198,8 @@ export function useInAppNotifications() {
});
}, [starredStorageKey]);
const deleteMessage = useCallback((id: string | number) => {
// Delete message (NotificationStatus == Archived & move to trash)
const deleteMessage = useCallback(async (id: string | number) => {
const strId = String(id);
setTrashedIds(prev => {
const next = prev.includes(strId) ? prev : [...prev, strId];
@@ -201,9 +209,22 @@ export function useInAppNotifications() {
} catch {}
return next;
});
}, [trashStorageKey]);
const restoreMessage = useCallback((id: string | number) => {
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Archived } : n))
);
try {
await archiveNotification(id);
} catch (err) {
console.warn('[deleteMessage] Error archiving notification on server:', err);
} finally {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
}
}, [trashStorageKey, userId, queryClient]);
// Restore from trash / unarchive
const restoreMessage = useCallback(async (id: string | number) => {
const strId = String(id);
setTrashedIds(prev => {
const next = prev.filter(x => x !== strId);
@@ -213,7 +234,19 @@ export function useInAppNotifications() {
} catch {}
return next;
});
}, [trashStorageKey]);
queryClient.setQueryData<Notification[]>(['inapp-notifications', userId], (old = []) =>
old.map(n => (String(n.id) === strId ? { ...n, status: NotificationStatus.Read } : n))
);
try {
await markNotificationAsRead(id);
} catch (err) {
console.warn('[restoreMessage] Error restoring notification on server:', err);
} finally {
queryClient.invalidateQueries({ queryKey: ['inapp-notifications'] });
}
}, [trashStorageKey, userId, queryClient]);
const permanentDelete = useCallback((id: string | number) => {
const strId = String(id);
@@ -270,8 +303,9 @@ export function useInAppNotifications() {
const sendMessageMutation = useMutation({
mutationFn: async (payload: {
recipientId: string;
recipientEmail: string;
recipientId?: string;
recipientEmail?: string;
recipient?: string;
recipientName?: string;
subject: string;
message: string;
@@ -281,11 +315,23 @@ export function useInAppNotifications() {
}) => {
const orgId = payload.organisationId || getCurrentUserTenantId() || 1;
const targetPlatform = payload.platform || NotificationPlatform.InApp;
const formattedDestination = `To: ${payload.recipientEmail} | From: ${userEmail || 'Partner User'}`;
const email = payload.recipientEmail || (payload.recipient?.includes('@') ? payload.recipient : '') || 'support@ifri.khongisa.co.za';
let id = payload.recipientId && payload.recipientId !== '00000000-0000-0000-0000-000000000000'
? payload.recipientId
: (!payload.recipient?.includes('@') && payload.recipient !== '00000000-0000-0000-0000-000000000000' ? payload.recipient : '');
if (!id && email) {
id = (await resolveUserIdByEmail(email, orgId)) || '';
}
if (!id) {
id = '00000000-0000-0000-0000-000000000000';
}
const formattedDestination = `To: ${email} | From: ${userEmail || 'Partner User'}`;
const req: CreateNotificationRequest = {
organisationId: orgId,
recipient: payload.recipientId,
recipient: id,
platform: targetPlatform,
subject: payload.subject,
destination: formattedDestination,
@@ -298,7 +344,7 @@ export function useInAppNotifications() {
const sentItem: Notification = {
id: `sent_${Date.now()}`,
organisationId: orgId,
recipient: payload.recipientId,
recipient: payload.recipientId || payload.recipient || "",
platform: targetPlatform,
status: NotificationStatus.Sent,
subject: payload.subject,
@@ -93,6 +93,7 @@ export const NOTIFICATION_STATUS_OPTIONS = [
{ value: NotificationStatus.Cancelled, label: 'Cancelled' },
{ value: NotificationStatus.Bounced, label: 'Bounced' },
{ value: NotificationStatus.Draft, label: 'Draft' },
{ value: NotificationStatus.Archived, label: 'Archived' },
];
export const NOTIFICATION_PLATFORM_OPTIONS = [
@@ -462,14 +463,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 => {
@@ -514,6 +537,8 @@ export const getEmailStatusBadge = (status?: EmailStatuses | number | null) => {
export const getNotificationStatusBadge = (status?: NotificationStatus | number | null) => {
const s = Number(status);
switch (s) {
case NotificationStatus.Archived:
return { label: 'Archived', className: 'bg-slate-800 text-slate-300 border-slate-700 font-semibold' };
case NotificationStatus.Draft:
return { label: 'Draft', className: 'bg-purple-50 text-purple-800 border-purple-300 font-semibold' };
case NotificationStatus.Delivered:
@@ -149,3 +149,79 @@ export function parseMarkdownToHtml(markdown: string): string {
return html;
}
/**
* Converts formatted HTML from WYSIWYG composition into clean, semantic Markdown.
* Preserves headings, bold, italic, blockquotes, lists, dividers, and links.
*/
export function htmlToMarkdown(html: string): string {
if (!html) return '';
let md = html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '');
// Line breaks & paragraph breaks
md = md.replace(/<br\s*\/?>/gi, '\n');
md = md.replace(/<\/p>/gi, '\n\n');
md = md.replace(/<p[^>]*>/gi, '');
// Headers
md = md.replace(/<h1[^>]*>(.*?)<\/h1>/gis, '# $1\n\n');
md = md.replace(/<h2[^>]*>(.*?)<\/h2>/gis, '## $1\n\n');
md = md.replace(/<h3[^>]*>(.*?)<\/h3>/gis, '### $1\n\n');
md = md.replace(/<h4[^>]*>(.*?)<\/h4>/gis, '#### $1\n\n');
// Bold / Italic / Underline
md = md.replace(/<(strong|b)[^>]*>(.*?)<\/\1>/gis, '**$2**');
md = md.replace(/<(em|i)[^>]*>(.*?)<\/\1>/gis, '*$2*');
md = md.replace(/<u[^>]*>(.*?)<\/u>/gis, '$1');
// Blockquote
md = md.replace(/<blockquote[^>]*>(.*?)<\/blockquote>/gis, (_match, p1) => {
return (
p1
.trim()
.split('\n')
.map((l: string) => `> ${l.trim()}`)
.join('\n') + '\n\n'
);
});
// Unordered Lists
md = md.replace(/<ul[^>]*>(.*?)<\/ul>/gis, (_match, p1) => {
return p1.replace(/<li[^>]*>(.*?)<\/li>/gis, '- $1\n') + '\n';
});
// Ordered Lists
md = md.replace(/<ol[^>]*>(.*?)<\/ol>/gis, (_match, p1) => {
let index = 1;
return p1.replace(/<li[^>]*>(.*?)<\/li>/gis, () => `${index++}. $1\n`) + '\n';
});
// Horizontal Rules
md = md.replace(/<hr\s*\/?>/gi, '\n---\n\n');
// Links
md = md.replace(/<a\b[^>]*href=["']([^"']*)["'][^>]*>(.*?)<\/a>/gis, '[$2]($1)');
// Divs
md = md.replace(/<\/div>/gi, '\n');
md = md.replace(/<div[^>]*>/gi, '');
// Strip any remaining HTML tags
md = md.replace(/<[^>]+>/g, '');
// Unescape standard HTML entities
md = md
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
// Collapse 3+ newlines into 2
return md.replace(/\n{3,}/g, '\n\n').trim();
}
@@ -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),
@@ -109,6 +109,7 @@ import {
UpdateRuleRequest,
Pagination,
Notification,
NotificationPlatform,
NotificationStatus,
Role,
Claim,
@@ -810,6 +811,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 +832,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 +1419,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) {
@@ -1857,7 +1899,9 @@ export const createNotification = async (payload: CreateNotificationRequest): Pr
subject: payload.subject || null,
destination: payload.destination || 'In-App User Inbox',
message: payload.message,
status: payload.status !== undefined ? payload.status : undefined,
status: payload.status !== undefined
? payload.status
: (Number(payload.platform) === NotificationPlatform.InApp ? NotificationStatus.Delivered : undefined),
});
};
@@ -1879,6 +1923,13 @@ export const markNotificationAsRead = async (notificationId: number | string): P
};
// ==========================================
export const archiveNotification = async (notificationId: number | string): Promise<void> => {
await updateNotification({
notificationId: toIntegerId(notificationId),
status: NotificationStatus.Archived,
});
};
// 9. Governance: Tenants APIs
// ==========================================
export const getTenants = async (): Promise<Tenant[]> => {
@@ -1955,6 +2006,91 @@ export const getTenantUsers = async (tenantId: string | number): Promise<TenantU
};
// ==========================================
/**
* Resolves a recipient user GUID by email address across TenantUsers and Directory.
* Automatically caches any found user emails and IDs in local memory.
*/
export const resolveUserIdByEmail = async (
email: string,
hintTenantOrOrgId?: number | string
): Promise<string | undefined> => {
if (!email || typeof email !== 'string' || !email.includes('@')) {
return undefined;
}
const cleanEmail = email.trim().toLowerCase();
// 1. Fast cache check
const cachedId = getUserIdByEmail(cleanEmail);
if (cachedId && cachedId !== '00000000-0000-0000-0000-000000000000') {
return cachedId;
}
const matchInTenantUsers = (tenantUsers: TenantUser[]): string | undefined => {
if (!Array.isArray(tenantUsers)) return undefined;
let match: string | undefined = undefined;
for (const tu of tenantUsers) {
const uId = tu.userId || tu.user?.id;
const uEmail = tu.user?.email || (tu as any)?.email;
if (uId && uEmail) {
registerUserEmail(uId, uEmail);
if (String(uEmail).trim().toLowerCase() === cleanEmail) {
match = String(uId).trim();
}
}
}
return match;
};
// 2. Query tenant users with hintTenantOrOrgId
if (hintTenantOrOrgId) {
try {
const users = await getTenantUsers(hintTenantOrOrgId);
const match = matchInTenantUsers(users);
if (match) return match;
} catch {}
}
// 3. Query all tenants from getTenants and their users
try {
const tenants = await getTenants();
for (const t of tenants) {
if (hintTenantOrOrgId && String(t.id) === String(hintTenantOrOrgId)) continue;
try {
const users = await getTenantUsers(t.id);
const match = matchInTenantUsers(users);
if (match) return match;
} catch {}
}
} catch {}
// 4. Try standard fallback tenant IDs
for (const tid of [1, 2, 3]) {
if (hintTenantOrOrgId && String(tid) === String(hintTenantOrOrgId)) continue;
try {
const users = await getTenantUsers(tid);
const match = matchInTenantUsers(users);
if (match) return match;
} catch {}
}
// 5. Query user directory fallback
try {
const dirUsers = await apiClient.post<User[]>('/v1/api/users/1/200', {});
if (Array.isArray(dirUsers.data)) {
for (const u of dirUsers.data) {
if (u && u.id && u.email) {
registerUserEmail(u.id, u.email);
if (u.email.trim().toLowerCase() === cleanEmail) {
return String(u.id).trim();
}
}
}
}
} catch {}
return undefined;
};
// 10. Projects: Beneficiaries & Outcomes APIs
// ==========================================
export const getProjectBeneficiaries = async (projectId: string | number): Promise<Beneficiary[]> => {
@@ -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)),
@@ -29,6 +29,7 @@ export enum NotificationStatus {
Cancelled = 8,
Bounced = 9,
Draft = 10,
Archived = 11,
}
export enum NotificationPlatform {
@@ -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",
+12 -5
View File
@@ -1,4 +1,13 @@
<Solution>
<Folder Name="/Presentation/">
<Project Path="PostFundManagement.Api/PostFundManagement.Api.csproj" />
<Project Path="PostFundManagement.Jobs/PostFundManagement.Jobs.csproj" Id="81281806-b5ca-4784-87af-3e8feeac988c" />
</Folder>
<Folder Name="/Shared/">
<Project Path="PostFundManagement.Application/PostFundManagement.Application.csproj" Id="750c7754-53a7-4573-abed-5c436246a04d" />
<Project Path="PostFundManagement.Domain/PostFundManagement.Domain.csproj" />
<Project Path="PostFundManagement.Infrastructure/PostFundManagement.Infrastructure.csproj" />
</Folder>
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
<File Path=".gitignore" />
@@ -9,9 +18,7 @@
<File Path="PostFundManagement.snk" />
<File Path="README.md" />
</Folder>
<Project Path="PostFundManagement.Api/PostFundManagement.Api.csproj" />
<Project Path="PostFundManagement.Application/PostFundManagement.Application.csproj" Id="750c7754-53a7-4573-abed-5c436246a04d" />
<Project Path="PostFundManagement.Domain/PostFundManagement.Domain.csproj" />
<Project Path="PostFundManagement.Infrastructure/PostFundManagement.Infrastructure.csproj" />
<Project Path="PostFundManagement.Tests/PostFundManagement.Tests.csproj" Id="ce650d27-8a68-436d-b5c1-db25f2286e85" />
<Folder Name="/Tests/">
<Project Path="PostFundManagement.Tests/PostFundManagement.Tests.csproj" Id="ce650d27-8a68-436d-b5c1-db25f2286e85" />
</Folder>
</Solution>
+52 -11
View File
@@ -1,4 +1,55 @@
services:
pfm-scheduler:
image: pfm-scheduler
build:
context: .
dockerfile: ./PostFundManagement.Jobs/Dockerfile
network: host
container_name: pfm-scheduler
environment:
# Service layer caching feature flag
- FeatureManagement__JobsWorker=${FEATURE_JOBS_WORKER:-true}}
- FeatureManagement__EmailProcessorJobEnabled=${FEATURE_EMAIL_PROCESSOR_JOB_ENABLED:-true}}
- FeatureManagement__ServiceCaching=${FEATURE_SERVICE_CACHING:-false}}
# Header forwarding overrides
- DOTNET_ENVIRONMENT=${ENVIRONMENT:-Development}
# Postgres Database Overrides
- ConnectionStrings__PfmDatabase=Server=${POSTGRES_HOST:-192.168.1.170};Port=${POSTGRES_PORT:-5432};Database=${POSTGRES_DB:-pfm};User Id=${POSTGRES_USER:-pfm-user};Password=${POSTGRES_PASSWORD:-};
- ConnectionStrings__PfmSchedulerDatabase=Server=${POSTGRES_HOST:-192.168.1.170};Port=${POSTGRES_PORT:-5432};Database=pfm-scheduler;User Id=${POSTGRES_USER:-pfm-user};Password=${POSTGRES_PASSWORD:-};
# Garage S3 Settings
- GeneralS3Settings__ServiceUrl=${GENERAL_S3_SERVICE_URL:-https://s3.garage.khongisa.co.za}
- GeneralS3Settings__Region=${GENERAL_S3_REGION:-garage}
- GeneralS3Settings__BucketName=${GENERAL_S3_BUCKET_NAME:-pfm}
- GeneralS3Settings__CdnBaseUrl=${GENERAL_S3_CDN_BASE_URL:-https://pfm.cdn.khongisa.co.za}
- GeneralS3Settings__AccessKey=${GENERAL_S3_ACCESS_KEY:-}
- GeneralS3Settings__SecretKey=${GENERAL_S3_SECRET_KEY:-}
# HashIds overrides
- HasherSettings__MinHashLength=${HASHER_MIN_HASH_LENGTH:-11}
- HasherSettings__Salt=${HASHER_SALT:-}
# Data Protection Overrides
- DataProtection__Certificate=${DATA_PROTECTION_CERTIFICATE:-}
- DataProtection__Password=${DATA_PROTECTION_CERTIFICATE_PASSWORD:-}
# Monitoring Overrides
- Monitoring__Address=${MONITORING_ADDRESS:-http://aspire-dashboard-service.aspire.svc.cluster.local:18889}
- Monitoring__ServiceName=${MONITORING_SERVICE_NAME:-Pfm.DEV}
- Monitoring__ApiKey=${MONITORING_API_KEY:-}
# Email Processor Overrides
- Email__ProcessorJobCron=${EMAIL_PROCESSOR_CRON:-0 */5 * * * ?}
- Email__Credentials__Password=${EMAIL_CREDENTIALS_PASSWORD:-}
- Email__Credentials__Username=${EMAIL_CREDENTIALS_USERNAME:-}
- Email__SmtpServer=${EMAIL_SMTP_SERVER:-}
- Email__SmtpPort=${EMAIL_SMTP_PORT:-587}
# Redis overrides
- ConnectionStrings__Redis=${REDIS_CONNECTION_STRING:-192.168.1.171:6379,ssl=false,abortConnect=false}
restart: unless-stopped
pfm-api:
image: pfm-api
build:
@@ -8,10 +59,7 @@ services:
container_name: pfm-api
ports:
- "8075:8080"
environment:
# Service layer caching feature flag
- EnableServiceCaching=false
environment:
# Header forwarding overrides
- ASPNETCORE_ENVIRONMENT=${ENVIRONMENT:-Development}
- ForwardedHeadersConfiguration__Enabled=true
@@ -61,13 +109,6 @@ services:
- Monitoring__Address=${MONITORING_ADDRESS:-http://aspire-dashboard-service.aspire.svc.cluster.local:18889}
- Monitoring__ServiceName=${MONITORING_SERVICE_NAME:-Pfm.DEV}
- Monitoring__ApiKey=${MONITORING_API_KEY:-}
# Email Processor Overrides
- EmailProcessorCron=${EMAIL_PROCESSOR_CRON:-}
- Email__Credentials__Password=${EMAIL_CREDENTIALS_PASSWORD:-}
- Email__Credentials__Username=${EMAIL_CREDENTIALS_USERNAME:-}
- Email__SmtpServer=${EMAIL_SMTP_SERVER:-}
- Email__SmtpPort=${EMAIL_SMTP_PORT:-587}
# Redis overrides
- ConnectionStrings__Redis=${REDIS_CONNECTION_STRING:-192.168.1.171:6379,ssl=false,abortConnect=false}